Option Values
The option package models values that can be absent — a typed alternative to
raw pointers and to the single field types in database/sql.
option.Option[T]
option.Option[T] wraps any type T with a validity flag and
interoperates with JSON, database/sql, and database/sql/driver:
type Option[T any] struct {
// has unexported fields
}The wrapped value is private, so an Option is built and read through the
package’s functions and methods rather than through its fields.
The zero value is already an invalid Option, so a struct field needs no
initialization to mean “absent”. Build a valid one with
Some(v), or an explicitly empty
one with None[T]():
avatar := option.Some("https://example.com/a.png")
missing := option.None[string]()
// equivalent to
missing := option.Option[string]{}Some marks the value present even when what it is given is the zero value of
its own type: option.Some(0) and option.Some("") are both valid, not
absent. Only the zero value of Option itself — or a JSON null on the way
in — means absent.
Read the wrapped value with OrElse(fallback), which returns the
value when it is valid and the fallback when it is not:
name := option.Some("Salusa").OrElse("unknown") // "Salusa"
age := option.Option[int]{}.OrElse(0) // 0Valid() reports
whether the value is present, which is what you branch on when the valid and
absent cases need different code:
if avatar.Valid() {
// ...
}Ok() returns the
wrapped value and a bool in one step, for when you need both without a branch:
avatar, ok := user.AvatarURL.Ok()
if !ok {
return errors.New("avatar is required")
}
saveAvatar(user, avatar)JSON
An invalid Option marshals to null, and a JSON null unmarshals to an
invalid instance holding the zero value of T. Anything else marshals and
unmarshals as the wrapped type, so time values, slices, pointers, and structs
all round-trip without a custom MarshalJSON:
type User struct {
ID int64 `db:"id"`
AvatarURL option.Option[string] `db:"avatar_url"`
}// option.None[string]() marshals to: null
// option.Some("a.png") marshals to: "a.png"A JSON decoding error leaves the value valid and the wrapped value partially
decoded, so an Option reused across decodes should be reset to its zero
value first.
SQL
Through database/sql, Scan(nil) clears the value and marks it invalid, and
Value() produces nil for an invalid instance. Nullable columns therefore
round-trip through a model with no special handling on either side.
Option[T] implements the interfaces on both the value and the pointer,
matching what the standard library does for sql.Null[T]:
var _ json.Marshaler = Option[int]{} // value
var _ json.Unmarshaler = (*Option[int])(nil)
var _ sql.Scanner = (*Option[int])(nil) // pointer: database/sql takes the address of a field
var _ driver.Valuer = Option[int]{} // value: usable directly as a query argumentBecause Value() is on the value type, an Option can be handed to
database/sql as an argument with no pointer and no unwrapping — an invalid one
becomes SQL NULL:
db.ExecContext(ctx, "UPDATE users SET avatar_url = $1 WHERE id = $2", avatar, id)
// avatar == option.Some("a.png") -> stores 'a.png'
// avatar == option.None[string]() -> stores NULLText
MarshalText and
UnmarshalText implement
encoding.TextMarshaler and
encoding.TextUnmarshaler. An invalid
Option marshals to empty text, and empty text unmarshals to an invalid
Option rather than to the zero value of T.
That is what makes an Option bind cleanly from a missing or blank query
parameter, which is how request decodes query, path, and form fields:
type ListUsersRequest struct {
AvatarURL option.Option[string] `query:"avatar_url"`
Age option.Option[int] `query:"age"`
}
// GET /users?age=21 -> Age == option.Some(21)
// GET /users -> Age == option.None[int]()
// GET /users?age= -> Age == option.None[int]()Values that implement encoding.TextMarshaler use their own text encoding, a
wrapped string is used verbatim, and anything else falls back to its JSON
encoding — so a wrapped time.Time is written as 2026-09-27T12:00:00Z while a
wrapped struct is written as JSON, and must be read back as JSON.
Migrations
Migration generation recognises an Option[T] field through
Unwrap, which returns the
wrapped type for a reflect.Type and reports whether the type is an
Option:
wrapped, ok := option.Unwrap(reflect.TypeFor[option.Option[string]]())
// wrapped == reflect.TypeFor[string](), ok == trueThe column is then generated as a nullable T, so no nullable tag is needed
alongside the field type:
type User struct {
Nickname string `db:"nickname"`
AvatarURL option.Option[string] `db:"avatar_url"`
}The avatar_url column above generates as a nullable column, the same output as
an explicit nullable tag:
table.String("avatar_url").Nullable()Mapping
Map[U](fn) applies fn to the
wrapped value and returns the result as a valid Option[U]. An invalid
Option comes back invalid and fn is never called, so the function does not
have to handle the zero value of T:
age := option.Some(21).Map(func(age int) string {
return fmt.Sprintf("%d years old", age)
})
// age == option.Some("21 years old")
missing := option.Option[int]{}.Map(func(int) string {
return "never mapped"
})
// missing == option.None[string]()Because the wrapped type can change, Map is also the way to convert an
Option to a different type in one step:
created := option.Some(user.CreatedAt).Map(func(t time.Time) string {
return t.Format("2006-01-02")
})FlatMap[U](fn) is
Map for a function that itself returns an Option, so the result is not
wrapped a second time. An invalid Option comes back invalid and fn is never
called:
nickname := option.Some(userID).FlatMap(func(id int64) option.Option[string] {
return lookupNickname(id)
})
// nickname is the Option returned by lookupNickname, not Option[Option[string]]Lazy fallbacks
Or(fn) resolves a
fallback that is only built when the value is absent. A valid Option is
returned unchanged and fn is never called, so an expensive default costs
nothing on the common path:
nickname := user.Nickname.Or(func() option.Option[string] {
return option.Some(displayNameFor(user))
})This is the Option[T]-shaped counterpart of OrElse: OrElse takes an
eagerly built value, Or takes a function.
Iteration
All() returns an
iter.Seq[T] over the wrapped value — one value
for a valid Option, nothing for an invalid one:
for name := range user.Nickname.All() {
fmt.Println(name)
}Because it is a plain iterator sequence, it composes with range-over-func
and with the stream package
described in Streams:
parts := stream.New(user.Nickname.All()).Slice()
// parts is a zero- or one-element sliceImport
import "gosalusa.com/option"
type User struct {
AvatarURL option.Option[string] `db:"avatar_url"`
}