Implementing Optional in Go
Optional is a type of data that can handle a true absence. For example, in a survey form, we may want to distinguish between agree, disagree, or no answer. If we represent such data using a boolean, we should differentiate between false and null values. In this article, we will discuss common patterns of optional impelementation in Go.
Use Cases
Perhaps the most common use case for optional is an API, whether it is for fetching data from the database (e.g sql.Scanner.Scan) or processing data from client’s POST request (e.g json.Marshal or json.Unmarshal)
Unlike Java or other languages, Go doesn’t have a straightforward optional type. Instead, developers rely on either nil (along with null checking) or an extra boolean to check absence.
Approach 1: Pointer
This is the simplest approach because all we need is initializing a pointer which can hold a nil.
package main
import "fmt"
type CreditAccount struct {
HolderName string
Score *int
}
func NewCreditAccount(name string) *CreditAccount {
newAccount := CreditAccount{}
newAccount.HolderName = name
return &newAccount
}
func main() {
fmt.Println(NewCreditAccount("Alice").Score)
}
Because score is a pointer to int, it defaults to nil instead of zero value 0 if not defined.
This approach can be applied to any data type, such as bool, int, string, or any struct.
One thing to note about this approach is that nil checking is important to avoid panic before dereferencing or exposing a pointer’s value. If not done carefully, this can lead to problems (for example on a web server).
Approach 2: Presence Flag
Given a simple struct as an example
type OptionalInt struct {
Value int
Valid bool
}
We can check for the equality of two variables as follows.
package main
import "fmt"
type OptionalInt struct {
Value int
Valid bool
}
func main() {
foo := OptionalInt{Value: 0, Valid: true}
bar := OptionalInt{}
if foo.Valid == bar.Valid && foo.Value == bar.Value {
fmt.Println("Equal")
return
}
fmt.Println("Not Equal")
}
Although bar.Value returns the same zero value 0 as foo, it is meant to be different.
This approach is somehow idiomatic for basic data types that the sql package has a lot of wrapper types such as sql.NullString, sql.NullInt32, and so on. Go also has a nice feature to represent this kind of wrapper.
Let’s make a “getter” for our “OptionalInt” type…
func (o OptionalInt) Get() (int, bool) {
return o.Value, o.Valid
}
…and it’s caller.
func main() {
foo := OptionalInt{Value: 0, Valid: true}
if val, ok := foo.Get(); ok {
fmt.Println(val)
}
// ...
}
With this approach, we won’t encounter nil panic but we still can check for absence.
Approach 3: Generics
Since generics was introduced in Golang, a handful of data structures and generic functions can be implemented in a breeze. One of the most interesting uses of Go generics is an optional type.
For example, we can refactor our OptionalInt type into a generic type…
type Optional[T any] struct {
Value T
Valid bool
}
func (o Optional[T]) Get() (T, bool) {
return o.Value, o.Valid
}
…and instantiate it.
func main() {
foo := Optional[int]{Value: 0, Valid: true}
bar := Optional[string]{Value: "asd", Valid: true}
if val, ok := foo.Get(); ok {
fmt.Println(val)
}
// ...
}
Some Go packages such as mo.Option even implement the interfaces related to optional use case such as sql.Scanner.Scan and json.Marshal, so we don’t need to construct the type manually.
Best Practices
Absence of complex structs is usually represented by nil and delivered along with an error or boolean flag.
// func FetchObject() (*Object, error)
obj, err := FetchObject()
if err != nil {
fmt.Println("Error: ", err)
return
}
fmt.Println(obj)
}
It is good practice to return an error when a function returns an absent or nil value, so it doesn’t confuse the user.
For basic data types like int or bool, distinguishing zero/false from nil can be tricky. In my opinion, all approaches we discussed above are fine, but I like the generics approach more although it requires a recent version of Go.
Lastly, there are widely accepted practice to just omit absence whenever possible. That means that we leave the absent value as blank/zero, insert that blank value into the database, and return it as null when fetched. To make the last thing happen, we can use struct tags.
For instance, we can have a name struct with optional middle and last names as follows.
type Name struct {
First string `json:"first"`
Middle string `json:"middle,omitempty"`
Last string `json:"last,omitempty"`
}
If we create a Name of John Doe (no middle name), we can return a json with no middle name as follows.
package main
import (
"encoding/json"
"fmt"
)
type Name struct {
First string `json:"first"`
Middle string `json:"middle,omitempty"`
Last string `json:"last,omitempty"`
}
func main() {
a := Name{
First: "John",
Last: "Doe",
}
out, _ := json.Marshal(&a)
fmt.Println(string(out))
}
This program returns {"first":"John","last":"Doe"}, omitting the middle name column. That means we can return no absent data to users although it is not explicitly defined as absent in our codebase.
In a bigger struct, we may want to not expose all the attributes to reduce complexity. Therefore, we can implement an WithOptions pattern to make our constructor simple yet customizable.
Comments
Use a GitHub account to create a comment. This page can be used to edit or delete comments.