Option Types
Language enthusiasts, particularly those of other languages, tend to scold Go for having a nil
value and happily cite Tony Hoare (see also the Completely Unrelated article about “marker values”), even though they often miss the point that Go's nil
is a typed value and therefore better than the untyped null
value of other languages.
Still, the desire of having “something safer” than nil
exists. Everyone who accidentally dereferenced a nil
pointer without checking knows what I mean. Option types to the rescue! An option type is a wrapper around a value that allows checking the value for absence before accessing it.
Are option types useful in Go? Let's check it out.
A homemade option type in Go
Here is an example of a very simple option type:
The type is basically just a struct with a value and an absence marker. A “constructor”, often named Some()
, wraps a given value into an option type. Likewise, a None()
constructor returns an option type with no value.
type Option[T any] struct {
val T
none bool
}
func Some[T any](v T) Option[T] {
return Option[T]{
val: v,
}
}
func None[T any]() Option[T] {
return Option[T]{
none: true,
}
}
A Wrap()
method allows constructing an option type including the absence marker. This comes handy if, for example, a library function wants to wrap a pointer (that could be nil
) into an option type.
func Wrap[T any](v T, null bool) Option[T] {
return Option[T]{
val: v,
none: null,
}
}
When working with an instance of this option type, the IsSome()
method reveals whether the instance contains a valid value.
func (opt *Option[T]) IsSome() bool {
return !opt.none
}
Note that the above implementation is just sufficient for this example. What if the value passed to the Some()
constructor is a nil
value? Real-life option type libraries would check for nil
values, too, as in this example.
The Or()
method conveniently allows accessing an option type instance or use a fallback value in case the option type instance contains no value.
func (opt *Option[T]) Or(fallback T) T {
if !opt.none {
return opt.val
} else {
return fallback
}
}
Likewise, this Value()
method provides a fallback mechanism through the “comma, ok” idiom. Value()
returns the inner value and a boolean that is true
if the value is valid, else false
.
func (opt *Option[T]) Value() (T, bool) {
if opt.none {
var zero T // the zero value of T
return zero, false
}
return opt.val, true
}
Using the option type
Here is this little option type in action:
func main() {
var null *int
absent := -1
// wrap the pointer
optPtr := Wrap(null, true)
fmt.Println(optPtr.IsSome())
// comma, ok idiom:
if msg, ok := optPtr.Value(); ok {
fmt.Println(msg)
}
// dereferencing with fallback:
fmt.Println(*optPtr.Or(&absent))
}
Run this to see how the option value can be safely accessed through the comma, ok idiom or through the Or()
fallback mechanism.
What did we win?
Arguably, even an option type requires to either test the validity of the encapsulated value or take measures to deal with absent values.
Especially, for pointer types, we could as well use the classic nil
check to avoid dereferencing a nil pointer:
if ptr != nil {
fmt.Println(*ptr)
}
An option type, though, makes it impossible to forget the check for nil
. The user must go through the “getter” methods Or()
or Value()
to access a wrapped pointer, and thus cannot forget to check the ok
value or provide a fallback value.
A small win only, you might argue, and I wouldn't oppose your standpoint. Especially, as linters like golangci-lint
can detect possible nil dereferencing.
Yet, depending on the situation, the elevated safety may be worth using an option type.