aboutsummaryrefslogtreecommitdiff
path: root/option
diff options
context:
space:
mode:
Diffstat (limited to 'option')
-rw-r--r--option/option.go362
-rw-r--r--option/option_test.go174
2 files changed, 536 insertions, 0 deletions
diff --git a/option/option.go b/option/option.go
new file mode 100644
index 0000000..1ea2e79
--- /dev/null
+++ b/option/option.go
@@ -0,0 +1,362 @@
+/*******************************************************************************
+* Copyright 2025 Stefan Majewsky <majewsky@gmx.net>
+* SPDX-License-Identifier: Apache-2.0
+* Refer to the file "LICENSE" for details.
+*******************************************************************************/
+
+// Package optional provides an Option type for Go.
+// A value of the Option type will be in one of two states: "Some" (containing a value) or "None" (containing no value).
+//
+// The purpose of the Option type is to more clearly distinguish the two situations that standard Go uses pointer types for:
+// - to provide a way to edit the inside of a value without changing the value itself
+// - to allow for a value to be either present or absent (with absence being represented as "nil")
+//
+// Using the Option type, pointers may be reserved for the first purpose.
+// For instance, a type like *int32 would clearly represent an editable value, whereas Option[int32] clearly represents an optional value.
+//
+// # Clean import guarantee
+//
+// This package is supposed to be used as a dot-import:
+//
+// import . "github.com/majewsky/go-option"
+//
+// To avoid backwards incompatibilities, we guarantee that, at least throughout the 1.x series,
+// newer versions of this package will never export any more names than it currently does ("None", "Option" and "Some").
+//
+// # Marshalling considerations
+//
+// Marshaling into and from YAML using https://github.com/go-yaml/yaml is supported.
+// The "omitempty" flag works as expected.
+//
+// Marshaling into and from JSON using encoding/json is supported, but the "omitempty" flag does not work.
+// You must use the "omitzero" flag to get the same effect, but note that this flag is only supported by Go 1.24 and newer.
+//
+// # How to replace pointer types with Option types
+//
+// This abridged example shows the most common ways to interact with pointer types that represent optional values:
+//
+// type ServerConfiguration struct {
+// CrashLogPath *string
+// ListenAddress *string
+// ThreadCount *uint64
+// }
+//
+// func RunServer(cfg ServerConfiguration) {
+// // inform about a value being absent
+// if cfg.CrashLogPath == nil {
+// log.Print("crash logging disabled because no CrashLogPath is given")
+// }
+//
+// // fill a default value if nil is given
+// listenAddress := "127.0.0.1:8080"
+// if cfg.ListenAddress != nil {
+// listenAddress = *listenAddress
+// }
+//
+// // access contained value if present, or proceed without contained value if absent
+// if cfg.ThreadCount != nil {
+// startMultiThreadedServer(listenAddress, *cfg.ThreadCount)
+// } else {
+// startSingleThreadedServer(listenAddress)
+// }
+// }
+//
+// This is how the same code snippet looks when replacing the pointer types with Option types and taking advantage of the methods on type Option:
+//
+// type ServerConfiguration struct {
+// CrashLogPath Option[string]
+// ListenAddress Option[string]
+// ThreadCount Option[uint64]
+// }
+//
+// func RunServer(cfg ServerConfiguration) {
+// // "if x == nil" becomes "if x.IsNone()"; the opposite check is called IsSome()
+// if cfg.CrashLogPath.IsNone() {
+// log.Print("crash logging disabled because no CrashLogPath is given")
+// }
+//
+// // default values can be filled with UnwrapOr()
+// listenAddress := cfg.ListenAddress.UnwrapOr("127.0.0.1:8080")
+//
+// // Unpack() provides the contained value and a success flag, similar to the double-return-value form of map indexing
+// if threadCount, ok := cfg.ThreadCount.Unpack(); ok {
+// startMultiThreadedServer(listenAddress, *cfg.ThreadCount)
+// } else {
+// startSingleThreadedServer(listenAddress)
+// }
+// }
+//
+// # Differences to Rust
+//
+// This package's API is obviously modeled after the Option type in the Rust standard library, but with some exceptions.
+//
+// - Go does not allow to introduce additional type parameters for individual methods.
+// Any methods that, in Rust, introduce the second type parameter U, cannot be represented in Go.
+// Some methods like and() or zip() could be allowed if the argument is restricted to Option[T] instead of Option[U],
+// but this restriction degrades their usefulness beyond reasonable limits.
+// - Go does not allow to introduce additional type restrictions in individual methods.
+// This makes methods like unzip() or cloned() unrepresentable in Go.
+// We might make these available as free-standing functions in the future, but if we do,
+// they will definitely not be in this package (see "Clean import guarantee" above).
+// - Mixing of struct receiver methods and pointer receiver methods on the same type is discouraged to avoid unintentional copies and data races.
+// Since most of the useful methods require only a struct receiver, we forego those that require a pointer receiver, like get_or_insert() or take().
+// The only exception to this is the methods implementing Unmarshaler interfaces, where concurrency bugs are very unlikely.
+//
+// Finally, the unwrap() function is not provided since its meaningless error message is not helpful in most contexts.
+// We only provide expect(), but under the different name UnwrapOrPanic(), since expect() reads terribly in context. Consider the following example:
+//
+// dbURL := GetDatabaseURLFromEnvironment().Expect("no DB connection found")
+// // ^ This reads like we *expect* to not find a DB connection, even though the opposite is true.
+//
+// dbURL := GetDatabaseURLFromEnvironment().UnwrapOrPanic("no DB connection found")
+// // ^ This is a much clearer phrasing.
+package option
+
+import (
+ "encoding/json"
+ "fmt"
+ "iter"
+)
+
+// Option is a type that contains either one or no instances of T.
+type Option[T any] struct {
+ value T
+ isSome bool
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// constructors
+
+// None constructs an Option instance that contains no value.
+func None[T any]() Option[T] {
+ var empty T
+ return Option[T]{empty, false}
+}
+
+// Some constructs an Option instance that contains the provided value.
+func Some[T any](value T) Option[T] {
+ return Option[T]{value, true}
+}
+
+// NOTE: Cannot use options.FromPointer() here because of import cycle.
+func fromPointer[T any](value *T) Option[T] {
+ if value == nil {
+ return None[T]()
+ } else {
+ return Some(*value)
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// core API (methods sorted by name)
+
+// AsPointer converts this Option into a pointer type.
+//
+// Usage of this function is discouraged because it breaks the clear distinction
+// between interior mutability (pointer) vs. optionality (Option).
+// It is provided for when an Option value needs to be passed to a library
+// function that requires a pointer value.
+func (o Option[T]) AsPointer() *T {
+ if o.isSome {
+ return &o.value
+ } else {
+ return nil
+ }
+}
+
+// AsSlice returns a slice with either the contained value or nothing in it.
+func (o Option[T]) AsSlice() []T {
+ if o.isSome {
+ return []T{o.value}
+ } else {
+ return nil
+ }
+}
+
+// Filter removes the contained value (if any) from the Option if it does not match the predicate.
+func (o Option[T]) Filter(predicate func(T) bool) Option[T] {
+ if o.isSome && predicate(o.value) {
+ return o
+ } else {
+ return None[T]()
+ }
+}
+
+// IsNone returns whether the Option contains no value.
+// Its inverse is IsSome().
+func (o Option[T]) IsNone() bool {
+ return !o.isSome
+}
+
+// IsSome returns whether the Option contains a value.
+// Its inverse is IsNone().
+func (o Option[T]) IsSome() bool {
+ return o.isSome
+}
+
+// IsSomeAnd returns whether the Option contains a value that matches the given predicate.
+func (o Option[T]) IsSomeAnd(predicate func(T) bool) bool {
+ return o.isSome && predicate(o.value)
+}
+
+// IsNoneOr returns whether the Option is either empty, or contains a value that matches the given predicate.
+func (o Option[T]) IsNoneOr(predicate func(T) bool) bool {
+ return !o.isSome || predicate(o.value)
+}
+
+// Iter returns an iterator that yields the contained value once (if any).
+// If the Option is empty, the iterator yields nothing.
+func (o Option[T]) Iter() iter.Seq[T] {
+ if o.isSome {
+ return func(yield func(T) bool) { yield(o.value) }
+ } else {
+ return func(yield func(T) bool) {}
+ }
+}
+
+// Or returns the option itself if it contains a value, or otherwise returns "other".
+//
+// If you are passing the result of a function call, consider using OrElse() to avoid calling the function unless necessary.
+func (o Option[T]) Or(other Option[T]) Option[T] {
+ if o.isSome {
+ return o
+ } else {
+ return other
+ }
+}
+
+// Or returns the option itself if it contains a value, or otherwise runs the provided closure to produce the return value.
+func (o Option[T]) OrElse(closure func() Option[T]) Option[T] {
+ if o.isSome {
+ return o
+ } else {
+ return closure()
+ }
+}
+
+// Unpack returns the contained value (or the zero value if None), as well as if there was a contained value.
+func (o Option[T]) Unpack() (T, bool) {
+ return o.value, o.isSome
+}
+
+// UnwrapOr returns the contained value.
+// If the Option is empty, the provided fallback value is returned instead.
+func (o Option[T]) UnwrapOr(fallback T) T {
+ if o.isSome {
+ return o.value
+ } else {
+ return fallback
+ }
+}
+
+// UnwrapOrElse returns the contained value.
+// If the Option is empty, the provided closure is used to produce the return value.
+func (o Option[T]) UnwrapOrElse(closure func() T) T {
+ if o.isSome {
+ return o.value
+ } else {
+ return closure()
+ }
+}
+
+// UnwrapOrPanic returns the contained value, or panics with the given error message if it is empty.
+func (o Option[T]) UnwrapOrPanic(msg any) T {
+ if o.isSome {
+ return o.value
+ } else {
+ panic(msg)
+ }
+}
+
+// UnwrapOrPanicf is a shorthand for UnwrapOrPanic(fmt.Sprintf(msg, args...)).
+func (o Option[T]) UnwrapOrPanicf(msg string, args ...any) T {
+ if o.isSome {
+ return o.value
+ } else {
+ panic(fmt.Sprintf(msg, args...))
+ }
+}
+
+// Xor returns an option containing a value if exactly one of the two given options contains a value, or None otherwise.
+func (o Option[T]) Xor(other Option[T]) Option[T] {
+ if o.isSome == other.isSome {
+ return None[T]()
+ } else {
+ return o.Or(other)
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// formatting/marshalling support
+
+// Format implements the fmt.Formatter interface.
+//
+// If there is a contained value, it will be formatted as if it was given directly.
+// Otherwise, the string "<none>" will be formatted according to the specified width and flags.
+func (o Option[T]) Format(f fmt.State, verb rune) {
+ if o.isSome {
+ fmt.Fprintf(f, fmt.FormatString(f, verb), o.value)
+ } else {
+ fmt.Fprintf(f, fmt.FormatString(f, 's'), "<none>")
+ }
+}
+
+// IsZero implements the IsZeroer interface as understood by encoding/json and github.com/go-yaml/yaml.
+// It is an alias of IsNone().
+func (o Option[T]) IsZero() bool {
+ return !o.isSome
+}
+
+type yamlMarshaler interface {
+ MarshalYAML() (any, error)
+}
+
+// MarshalJSON implements the json.Marshaler interface.
+func (o Option[T]) MarshalJSON() ([]byte, error) {
+ if o.isSome {
+ return json.Marshal(o.value)
+ } else {
+ return []byte("null"), nil
+ }
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface.
+func (o *Option[T]) UnmarshalJSON(buf []byte) error {
+ var data *T
+ err := json.Unmarshal(buf, &data)
+ if err != nil {
+ return err
+ }
+ *o = fromPointer(data)
+ return nil
+}
+
+// MarshalYAML implements the yaml.Marshaler interface.
+func (o Option[T]) MarshalYAML() (any, error) {
+ if o.isSome {
+ // If we just return o.value directly here, MarshalYAML will not be called
+ // on the value even if it exists. For this one specific case, we have to
+ // take care ourselves.
+ if m, ok := any(o.value).(yamlMarshaler); ok {
+ return m.MarshalYAML()
+ } else {
+ return o.value, nil
+ }
+ } else {
+ return nil, nil
+ }
+}
+
+// UnmarshalYAML implements the yaml.Unmarshaler interface.
+//
+// This function signature is compatible with both v2 and v3 of github.com/go-yaml/yaml,
+// so we intentionally do not use the v3-only signature that refers to the yaml.Node type.
+func (o *Option[T]) UnmarshalYAML(unmarshal func(any) error) error {
+ var data *T
+ err := unmarshal(&data)
+ if err != nil {
+ return err
+ }
+ *o = fromPointer(data)
+ return nil
+}
diff --git a/option/option_test.go b/option/option_test.go
new file mode 100644
index 0000000..2bac4f5
--- /dev/null
+++ b/option/option_test.go
@@ -0,0 +1,174 @@
+/*******************************************************************************
+* Copyright 2025 Stefan Majewsky <majewsky@gmx.net>
+* SPDX-License-Identifier: Apache-2.0
+* Refer to the file "LICENSE" for details.
+*******************************************************************************/
+
+package option
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+ "testing"
+
+ . "github.com/majewsky/gg/internal/test"
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// core API (methods sorted by name)
+
+func TestAsPointer(t *testing.T) {
+ AssertEqual(t, None[int]().AsPointer(), nil)
+ AssertEqual(t, Some(42).AsPointer(), PointerTo(42))
+}
+
+func TestAsSlice(t *testing.T) {
+ AssertEqual(t, None[int]().AsSlice(), []int(nil))
+ AssertEqual(t, Some(42).AsSlice(), []int{42})
+}
+
+func TestFilter(t *testing.T) {
+ isEven := func(x int) bool {
+ return x%2 == 0
+ }
+
+ AssertEqual(t, None[int]().Filter(isEven), None[int]())
+ AssertEqual(t, Some(41).Filter(isEven), None[int]())
+ AssertEqual(t, Some(42).Filter(isEven), Some(42))
+}
+
+func TestIsNone(t *testing.T) {
+ AssertEqual(t, None[int]().IsNone(), true)
+ AssertEqual(t, Some(42).IsNone(), false)
+}
+
+func TestIsSome(t *testing.T) {
+ AssertEqual(t, None[int]().IsSome(), false)
+ AssertEqual(t, Some(42).IsSome(), true)
+}
+
+func TestIsZero(t *testing.T) {
+ AssertEqual(t, None[int]().IsZero(), true)
+ AssertEqual(t, Some(42).IsZero(), false)
+}
+
+func TestIter(t *testing.T) {
+ AssertEqual(t, slices.Collect(None[int]().Iter()), []int(nil))
+ AssertEqual(t, slices.Collect(Some(42).Iter()), []int{42})
+}
+
+func TestOr(t *testing.T) {
+ none := None[int]()
+ AssertEqual(t, none.Or(none), None[int]())
+ AssertEqual(t, Some(42).Or(none), Some(42))
+ AssertEqual(t, none.Or(Some(43)), Some(43))
+ AssertEqual(t, Some(42).Or(Some(43)), Some(42))
+}
+
+func TestOrElse(t *testing.T) {
+ callCount := 0
+ makeNone := func() Option[int] {
+ callCount++
+ return None[int]()
+ }
+ makeSome := func() Option[int] {
+ callCount++
+ return Some(43)
+ }
+
+ AssertEqual(t, None[int]().OrElse(makeNone), None[int]())
+ AssertEqual(t, callCount, 1)
+ AssertEqual(t, Some(42).OrElse(makeNone), Some(42))
+ AssertEqual(t, callCount, 1)
+ AssertEqual(t, None[int]().OrElse(makeSome), Some(43))
+ AssertEqual(t, callCount, 2)
+ AssertEqual(t, Some(42).OrElse(makeSome), Some(42))
+ AssertEqual(t, callCount, 2)
+}
+
+func TestUnpack(t *testing.T) {
+ val, ok := None[int]().Unpack()
+ AssertEqual(t, val, 0)
+ AssertEqual(t, ok, false)
+
+ val, ok = Some(42).Unpack()
+ AssertEqual(t, val, 42)
+ AssertEqual(t, ok, true)
+}
+
+func TestUnwrapOr(t *testing.T) {
+ AssertEqual(t, None[int]().UnwrapOr(5), 5)
+ AssertEqual(t, Some(42).UnwrapOr(5), 42)
+}
+
+func TestUnwrapOrElse(t *testing.T) {
+ callCount := 0
+ five := func() int {
+ callCount++
+ return 5
+ }
+
+ AssertEqual(t, None[int]().UnwrapOrElse(five), 5)
+ AssertEqual(t, callCount, 1)
+ AssertEqual(t, Some(42).UnwrapOrElse(five), 42)
+ AssertEqual(t, callCount, 1)
+}
+
+func TestXor(t *testing.T) {
+ none := None[int]()
+ AssertEqual(t, none.Xor(none), None[int]())
+ AssertEqual(t, Some(42).Xor(none), Some(42))
+ AssertEqual(t, none.Xor(Some(43)), Some(43))
+ AssertEqual(t, Some(42).Xor(Some(43)), None[int]())
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// formatting/marshalling support
+
+func TestFormat(t *testing.T) {
+ none := None[int]()
+ some := Some(42)
+
+ AssertEqual(t, fmt.Sprintf("value is %d", none), "value is <none>")
+ AssertEqual(t, fmt.Sprintf("value is %d", some), "value is 42")
+ AssertEqual(t, fmt.Sprintf("value is %010d", none), "value is 0000<none>")
+ AssertEqual(t, fmt.Sprintf("value is %010d", some), "value is 0000000042")
+
+ noneList := None[[]int]()
+ someList := Some([]int{4, 2})
+ AssertEqual(t, fmt.Sprintf("value is %v", noneList), "value is <none>")
+ AssertEqual(t, fmt.Sprintf("value is %v", someList), "value is [4 2]")
+ AssertEqual(t, fmt.Sprintf("value is %#v", noneList), "value is <none>")
+ AssertEqual(t, fmt.Sprintf("value is %#v", someList), "value is []int{4, 2}")
+
+ listOfOptions := []Option[int]{none, some}
+ AssertEqual(t, fmt.Sprintf("value is %#v", listOfOptions), "value is []option.Option[int]{<none>, 42}")
+}
+
+func TestMarshalAndUnmarshalJSON(t *testing.T) {
+ type payload struct {
+ N1 Option[int] `json:"n1"`
+ N2 Option[int] `json:"n2,omitempty"`
+ N3 Option[int] `json:"n3,omitzero"`
+ S1 Option[int] `json:"s1"`
+ S2 Option[int] `json:"s2,omitempty"`
+ S3 Option[int] `json:"s3,omitzero"`
+ }
+ original := payload{
+ N1: None[int](),
+ N2: None[int](),
+ N3: None[int](),
+ S1: Some(1),
+ S2: Some(2),
+ S3: Some(3),
+ }
+ buf, err := json.Marshal(original)
+ AssertEqual(t, err, nil)
+ AssertEqual(t, string(buf), `{"n1":null,"n2":null,"s1":1,"s2":2,"s3":3}`)
+
+ var decoded payload
+ err = json.Unmarshal(buf, &decoded)
+ AssertEqual(t, err, nil)
+ AssertEqual(t, decoded, original)
+}