Southclaws Opt Save

A simple and ergonomic optional type for Go.

Project README

opt

Optional types and utilities for egonomic data transformation.

GoDoc Go Report Card

opt provides a simple generic optional type with a variety of utilities for performing various transformations without the need for explicit branching.

So, while there are many Optional[T] style packages out there, this one has a focus on making data transformations easier to write and easier to read.

It also prevents certain categories of bug such as nil pointer dereferencing. Of course this comes at a cost and if you're writing performance sensitive code, this library may not be for you and you may be better off just being explicit.

The status of this library is pre-1.0 but the API is stable and probably won't change. It has been dogfooded in 3 production codebases for about a year and all APIs were built to solve some real problems in those projects.

Basics

Let's get the obvious out of the way first...

func main() {
    maybe := opt.New("I exist!")

    maybe.Ok() // true
    value, exists := maybe.Get() // "I exist!", true
    ptr := maybe.Ptr() // some address

    maybe_not := opt.NewEmpty[string]()

    maybe_not.Ok() // false
    value, exists := maybe_not.Get() // "", false
    ptr := maybe_not.Ptr() // nil
}

Optional, generic, yada yada, whatever. Every other optional package does it.

The interesting parts are the construction, mapping and access utilities...

Accessing

Once you've constructed an optional value, you can access the underlying data in a few ways. These make it easy to build branching logic without the need for explicit if statements. Which can be useful for transforming large structures.

The simplest ones are Ok and Get which have examples above. See the GoDoc for more info on these, they're fairly simple and do what you'd expect.

One method that isn't mentioned above is Call. Which simply lets you call a function with the value if it's present:

maybe := opt.New("I exist!")
maybe.Call(func(value string) {
    fmt.Println(value)
})

These have been handy for some ORM setter APIs:

email.Call(accountQuery.SetEmailAddress)

Mapping

One of the core reasons this library was written was to facilitate easy mapping of data types that may or may not be present. Without the need for code that looks like this:

var newValue *T
if oldValue != nil {
    newValue = transform(*oldValue)
}

Which is fine on its own, but if you have many values, it can get quite verbose.

opt instead provides a way to map data as an access or map data as a pipeline. To access the data, you already know about Get but if you want to change the type at the same time as accessing, you can use GetMap:

maybe := opt.New("I exist!")
value, exists := opt.GetMap(maybe, strings.ToUpper)
// "I EXIST!", true

If your destination is expecting a pointer, you can use PtrMap:

maybe := opt.New("I exist!")
value := opt.PtrMap(maybe, strings.ToUpper)
// "I EXIST!" as a `*string`

Note how these are functions of the library, not methods on the type. It would be nice to be able to write maybe.PtrMap(strings.ToUpper) but currently, this is not possible to do in the current version of Go's generics.

If you want to transform the data but keep it wrapped as an optional type, you can use Map or MapErr to execute the closure, only if the value is present:

maybe := opt.New("I exist!")
maybe = opt.Map(maybe, strings.ToUpper)
// opt.Optional[string]("I EXIST!")

And of course MapErr does the same thing but allows you to return an error:

maybe := opt.New("5629")
maybe, err := opt.MapErr(maybe, strconv.Atoi)
// opt.Optional[int](5629)

maybe_not := opt.New("not a number :(")
maybe_not, err := opt.MapErr(maybe, strconv.Atoi)
// Empty optional plus the error from Atoi.

And, as an escape hatch, a .String() method which is useful for tests:

maybe := opt.New("5629")
maybe.String()
// "5629"

If the value exists, it'll use fmt to stringify, if not it'll just be empty.

When there's nothing inside

There are also methods to deal with empty values Or, OrZero and OrCall:

maybe := opt.New("I exist!")
maybe.Or("I don't exist!") // "I exist!"
maybe := opt.Empty[string]()
maybe.Or("I don't exist!") // "I don't exist!"

The Or method simply lets you return a default value if the optional value is empty. This is handy for providing defaults.

The OrZero method simply returns the type's zero-value:

maybe := opt.Empty[time.Time]()
t := maybe.OrZero()
t.IsZero() // true

And finally, OrCall lets you call a function to provide a default value:

maybe := opt.Empty[string]()
t := maybe.OrCall(func() string {
    return "a default value from somewhere"
})
// "a default value from somewhere"

Curried C Functions

Some APIs will have a second version with C appended to the name. These are curried versions of those functions to aid in ergonomic usage.

Say for example you have a function that converts a number to a GBP currency representation. You want to apply this function to a few values in a struct or to a slice of items.

// Given: ConvertUSD(value int) string

func Convert(input Table) PriceBreakdown {
    return PriceBreakdown{
        Cost:          ConvertGBP(input.UnitCost),
        ShippingFee:   NewPtrMap(input.ShippingFee, ConvertGBP),
        ServiceCharge: NewPtrMap(input.ServiceCharge, ConvertGBP),
        Discount:      NewPtrMap(input.Discount, ConvertGBP),
    }
}

A small example, but you could imagine how much this can get in a larger system.

Using curried APIs, we can make this a little more terse:

func Convert(input Table) PriceBreakdown {
    gbp := NewPtrMapC(ConvertGBP)
    return PriceBreakdown{
        Cost:          ConvertGBP(input.UnitCost),
        ShippingFee:   gbp(input.ShippingFee),
        ServiceCharge: gbp(input.ServiceCharge),
        Discount:      gbp(input.Discount),
    }
}

Now this may not seem like much but it can make refactors easier and keep diffs small. Once you start thinking in curried functions, certain tasks get simpler!

Let's see what this looks like for a slice of items:

func ConvertMany(prices []*int) []Optional[string] {
    output := []Optional[string]{}
    for _, v := range prices {
        output = append(output, NewPtrMap(v, ConvertGBP))
    }
    return output
}

If you like to use functional libraries like lo and fp-go then this might be useful:

func ConvertMany(prices []*int) []Optional[string] {
    fn := PtrMapC(ConvertGBP)
    mapper := fp.Map(fn)
    return mapper(prices)
}

Construction

There are quite a few places data can come from. opt provides a few helpers to create optional wrappers from various sources.

We've covered the boring ones already, New and NewEmpty just create values from either something or nothing.

NewMap

This tool creates an optional type but facilitates mapping the data type using a function first. This is similar to .map( x => y ) in many other languages.

v := opt.NewMap("hello", strings.ToUpper)

v now contains an optional string value set to "HELLO". because, before storing the data, it passed the input value through strings.ToUpper.

NewSafe

A common Go pattern is return values that look like (T, bool) where the bool represents validity. NewSafe lets you easily build optional values from this.

// where getThing is: func getThing() (v string, ok bool)
v := opt.NewSafe(getThing())

It's also just handy sometimes for simple logic:

v := opt.NewSafe(account.Email, account.IsEmailPublic)

Here, we're storing the optional value of the account's email only if the value of IsEmailPublic is true.

Sadly this does not work with built-in operations:

hash := map[string]string{"s": "asd"}
NewSafe(hash["dsf"])
// not enough arguments in call to NewSafe have (string) want (T, bool)

var cast any = "hi"
NewSafe(cast.(string))
// not enough arguments in call to NewSafe have (string) want (T, bool)

This is because the bool part of these expressions is optional.

NewIf

This one is another way to encode optionality based on some branching logic. In this variant, the logic exists within a closure that returns a bool.

v := opt.NewIf(account.Email, isValidEmailAddress)
v := opt.NewIf(company.LegalName, func(s string) bool { return s != "" })
v := opt.NewIf(createdAt, func(t time.Time) bool { return !t.IsZero() })

NewPtr, NewPtrMap, NewPtrIf and NewPtrOr

If one area of your application is using pointers already but you want to expose optionals, you can use this one to easily construct an optional from a pointer.

type Account struct {
    Twitter *string
}

// ...

v := opt.NewPtr(account.Twitter)
v := opt.NewPtrOr(account.Twitter, "@southclaws")

Prior Art

Contributing

Issues and pull requests welcome!

Open Source Agenda is not affiliated with "Southclaws Opt" Project. README Source: Southclaws/opt
Stars
34
Open Issues
0
Last Commit
3 months ago
Repository
License
MIT

Open Source Agenda Badge

Open Source Agenda Rating