From e1379278d5df6594f2c0a0c3690894faeb2cf70f Mon Sep 17 00:00:00 2001 From: Levi Kline Date: Mon, 16 Sep 2024 12:44:28 -0700 Subject: [PATCH] feat: v1 --- go.mod | 11 ++ go.sum | 10 + option.go | 236 +++++++++++++++++++++++ option_test.go | 505 +++++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 288 ++++++++++++++++++++++++++++ 5 files changed, 1050 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 option.go create mode 100644 option_test.go create mode 100644 readme.md diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..953e2d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/tapp-ai/go-optional-v2 + +go 1.22.3 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60ce688 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/option.go b/option.go new file mode 100644 index 0000000..764f731 --- /dev/null +++ b/option.go @@ -0,0 +1,236 @@ +package optionalv2 + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" +) + +var ( + // ErrNoneValueTaken represents the error that is raised when None value is taken. + ErrNoneValueTaken = errors.New("none value taken") + // NullBytes is a byte slice representation of the string "null" + NullBytes = []byte("null") +) + +// Option is a data type that must be Some (i.e. having a value) or None (i.e. doesn't have a value). +type Option[T any] map[bool]T + +// --- Private --- + +// Null is a function to make an Option type value that has an explicit null value. +func null[T any]() Option[T] { + var defaultVal T + return Option[T]{ + false: defaultVal, + } +} + +// IsNull returns whether the Option has an explicit null value or not. +func (o Option[T]) isNull() bool { + if len(o) == 0 { + return false + } + _, ok := o[false] + return ok +} + +// --- Public --- + +// Some is a function to make an Option type value with the actual value. +func Some[T any](v T) Option[T] { + // Check if the value is the zero value of its type + if reflect.ValueOf(v).IsZero() { + return null[T]() + } + + return Option[T]{ + true: v, + } +} + +// None is a function to make an Option type value that doesn't have a value. +func None[T any]() Option[T] { + return map[bool]T{} +} + +// IsSome returns whether the Option has a value or not. +func (o Option[T]) IsSome() bool { + return len(o) != 0 +} + +// IsNone returns whether the Option doesn't have a value or not. +func (o Option[T]) IsNone() bool { + return len(o) == 0 +} + +// Unwrap returns the value regardless of Some/None status. +// If the Option value is Some, this method returns the actual value. +// On the other hand, if the Option value is None, this method returns the *default* value according to the type. +func (o Option[T]) Unwrap() T { + if o.IsNone() || o.isNull() { + var defaultValue T + return defaultValue + } + + return o[true] +} + +// UnwrapAsPtr returns the contained value in receiver Option as a pointer. +// This is similar to `Unwrap()` method but the difference is this method returns a pointer value instead of the actual value. +// If the receiver Option value is None, this method returns nil. +func (o Option[T]) UnwrapAsPtr() *T { + if o.IsNone() { + return nil + } + + if o.isNull() { + var defaultValue T + return &defaultValue + } + + var v = o[true] + return &v +} + +// Take takes the contained value in Option. +// If Option value is Some, this returns the value. +// If Option value is None, this returns an ErrNoneValueTaken as the second return value. +func (o Option[T]) Take() (T, error) { + if o.IsNone() { + var defaultValue T + return defaultValue, ErrNoneValueTaken + } + + return o.Unwrap(), nil +} + +// TakeOr returns the actual value if the Option has a value (Some). +// Otherwise, it returns the provided fallback value. +func (o Option[T]) TakeOr(fallbackValue T) T { + if o.IsNone() { + return fallbackValue + } + + return o.Unwrap() +} + +// TakeOrElse returns the actual value if the Option has a value (Some). +// Otherwise, it executes the fallback function and returns the result. +func (o Option[T]) TakeOrElse(fallbackFunc func() T) T { + if o.IsNone() { + return fallbackFunc() + } + + return o.Unwrap() +} + +// Or returns the current Option if it has a value (Some). +// If the current Option is None, it returns the fallback Option. +func (o Option[T]) Or(fallbackOptionValue Option[T]) Option[T] { + if o.IsNone() { + return fallbackOptionValue + } + + return o +} + +// Filter returns the current Option if it has a value and the value matches the predicate. +// If the current Option is None or the value doesn't match the predicate, it returns None. +func (o Option[T]) Filter(predicate func(v T) bool) Option[T] { + if o.IsNone() || !predicate(o.Unwrap()) { + return None[T]() + } + + return o +} + +// IfSome calls the provided function with the value of Option if it is Some. +func (o Option[T]) IfSome(f func(v T)) { + if o.IsNone() { + return + } + + f(o.Unwrap()) +} + +// IfSomeWithError calls the provided function with the value of Option if it is Some. +// This propagates the error from the provided function. +func (o Option[T]) IfSomeWithError(f func(v T) error) error { + if o.IsNone() { + return nil + } + + return f(o.Unwrap()) +} + +// IfNone calls the provided function if the Option is None. +func (o Option[T]) IfNone(f func()) { + if !o.IsNone() { + return + } + + f() +} + +// IfNoneWithError calls the provided function if the Option is None. +// This propagates the error from the provided function. +func (o Option[T]) IfNoneWithError(f func() error) error { + if !o.IsNone() { + return nil + } + + return f() +} + +// String returns a string representation of the Option. +// It includes the unwrapped value for Some, and if the value implements fmt.Stringer, it uses its custom string representation. +func (o Option[T]) String() string { + if o.IsNone() { + return "None[]" + } + + // Unwrap the value for both Some and Null + v := o.Unwrap() + + // Check if the value implements fmt.Stringer for custom string formatting + if stringer, ok := interface{}(v).(fmt.Stringer); ok { + return fmt.Sprintf("Some[%s]", stringer.String()) + } + + // Default formatting when fmt.Stringer is not implemented + return fmt.Sprintf("Some[%v]", v) +} + +// MarshalJSON implements the json.Marshaler interface for Option. +func (o Option[T]) MarshalJSON() ([]byte, error) { + // if field was specified, and `null`, marshal it + if o.isNull() { + return NullBytes, nil + } + + // if field was unspecified, and `omitempty` is set on the field's tags, `json.Marshal` will omit this field + + // otherwise: we have a value, so marshal it + return json.Marshal(o[true]) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for Option. +func (o *Option[T]) UnmarshalJSON(data []byte) error { + // if field is unspecified, UnmarshalJSON won't be called + + // if field is specified, and `null` + if bytes.Equal(data, NullBytes) { + *o = null[T]() + return nil + } + // otherwise, we have an actual value, so parse it + var v T + if err := json.Unmarshal(data, &v); err != nil { + return err + } + *o = Some(v) + return nil +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..eb2bb80 --- /dev/null +++ b/option_test.go @@ -0,0 +1,505 @@ +package optionalv2_test + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tapp-ai/go-optional-v2" +) + +// Custom type for testing fmt.Stringer interface +type CustomType struct { + ID int + Name string +} + +func (c CustomType) String() string { + return fmt.Sprintf("CustomType(ID=%d, Name=%s)", c.ID, c.Name) +} + +func TestOption(t *testing.T) { + // Test Some and None creation + t.Run("Creation", func(t *testing.T) { + optSome := optionalv2.Some(42) + assert.True(t, optSome.IsSome()) + assert.False(t, optSome.IsNone()) + assert.Equal(t, 42, optSome.Unwrap()) + + optNone := optionalv2.None[int]() + assert.False(t, optNone.IsSome()) + assert.True(t, optNone.IsNone()) + }) + + // Test Unwrap and UnwrapAsPtr methods + t.Run("UnwrapMethods", func(t *testing.T) { + optSome := optionalv2.Some("Hello") + value := optSome.Unwrap() + assert.Equal(t, "Hello", value) + + ptr := optSome.UnwrapAsPtr() + assert.NotNil(t, ptr) + assert.Equal(t, "Hello", *ptr) + + optNone := optionalv2.None[string]() + value = optNone.Unwrap() + assert.Equal(t, "", value) // Zero value for string + + ptr = optNone.UnwrapAsPtr() + assert.Nil(t, ptr) + }) + + // Test Take and TakeOr methods + t.Run("TakeMethods", func(t *testing.T) { + optSome := optionalv2.Some(100) + value, err := optSome.Take() + assert.NoError(t, err) + assert.Equal(t, 100, value) + + optNone := optionalv2.None[int]() + value, err = optNone.Take() + assert.Error(t, err) + assert.Equal(t, optionalv2.ErrNoneValueTaken, err) + assert.Equal(t, 0, value) // Zero value for int + + value = optNone.TakeOr(200) + assert.Equal(t, 200, value) + + value = optSome.TakeOr(300) + assert.Equal(t, 100, value) // Original value, since optSome is Some + + value = optNone.TakeOrElse(func() int { + return 400 + }) + assert.Equal(t, 400, value) + + value = optSome.TakeOrElse(func() int { + return 500 + }) + assert.Equal(t, 100, value) // Original value, since optSome is Some + }) + + // Test Or method + t.Run("OrMethod", func(t *testing.T) { + optNone := optionalv2.None[int]() + optSome := optionalv2.Some(10) + fallback := optionalv2.Some(20) + + result := optNone.Or(fallback) + assert.True(t, result.IsSome()) + assert.Equal(t, 20, result.Unwrap()) + + result = optSome.Or(fallback) + assert.True(t, result.IsSome()) + assert.Equal(t, 10, result.Unwrap()) + }) + + // Test Filter method + t.Run("FilterMethod", func(t *testing.T) { + opt := optionalv2.Some(15) + result := opt.Filter(func(v int) bool { + return v > 10 + }) + assert.True(t, result.IsSome()) + assert.Equal(t, 15, result.Unwrap()) + + result = opt.Filter(func(v int) bool { + return v < 10 + }) + assert.True(t, result.IsNone()) + }) + + // Test IfSome and IfNone methods + t.Run("IfSomeAndIfNone", func(t *testing.T) { + optSome := optionalv2.Some("test") + var called bool + + optSome.IfSome(func(v string) { + called = true + assert.Equal(t, "test", v) + }) + assert.True(t, called) + + called = false + optSome.IfNone(func() { + called = true + }) + assert.False(t, called) + + optNone := optionalv2.None[string]() + called = false + optNone.IfSome(func(v string) { + called = true + }) + assert.False(t, called) + + called = false + optNone.IfNone(func() { + called = true + }) + assert.True(t, called) + }) + + // Test IfSomeWithError and IfNoneWithError + t.Run("IfSomeWithErrorAndIfNoneWithError", func(t *testing.T) { + optSome := optionalv2.Some(5) + err := optSome.IfSomeWithError(func(v int) error { + if v < 10 { + return errors.New("value is less than 10") + } + return nil + }) + assert.Error(t, err) + assert.Equal(t, "value is less than 10", err.Error()) + + optNone := optionalv2.None[int]() + err = optNone.IfSomeWithError(func(v int) error { + return errors.New("should not be called") + }) + assert.NoError(t, err) + + err = optNone.IfNoneWithError(func() error { + return errors.New("no value present") + }) + assert.Error(t, err) + assert.Equal(t, "no value present", err.Error()) + + optSome = optionalv2.Some(20) + err = optSome.IfNoneWithError(func() error { + return errors.New("should not be called") + }) + assert.NoError(t, err) + }) + + // Test String method + t.Run("StringMethod", func(t *testing.T) { + optSome := optionalv2.Some(42) + assert.Equal(t, "Some[42]", optSome.String()) + + optNone := optionalv2.None[int]() + assert.Equal(t, "None[]", optNone.String()) + + optCustom := optionalv2.Some(CustomType{ID: 1, Name: "Test"}) + assert.Equal(t, "Some[CustomType(ID=1, Name=Test)]", optCustom.String()) + + optCustomNone := optionalv2.None[CustomType]() + assert.Equal(t, "None[]", optCustomNone.String()) + }) + + // Test JSON marshalling and unmarshalling + t.Run("JSONMarshalling", func(t *testing.T) { + type TestStruct struct { + Value optionalv2.Option[int] `json:"value,omitempty"` + } + + // Test marshalling Some with non-zero value + s := TestStruct{ + Value: optionalv2.Some(10), + } + data, err := json.Marshal(s) + assert.NoError(t, err) + assert.JSONEq(t, `{"value":10}`, string(data)) + + // Test marshalling Some with zero value (should be null) + s.Value = optionalv2.Some(0) + data, err = json.Marshal(s) + assert.NoError(t, err) + assert.JSONEq(t, `{"value":null}`, string(data)) + + // Test marshalling None (should be omitted) + s.Value = optionalv2.None[int]() + data, err = json.Marshal(s) + assert.NoError(t, err) + assert.JSONEq(t, `{}`, string(data)) + + // Test unmarshalling with value + jsonStr := `{"value": 20}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.Value.IsSome()) + assert.Equal(t, 20, s.Value.Unwrap()) + + // Test unmarshalling with null + jsonStr = `{"value": null}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.Value.IsSome()) + assert.Equal(t, 0, s.Value.Unwrap()) + + // Test unmarshalling with missing field + s = TestStruct{} + jsonStr = `{}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.Value.IsNone()) + }) + + // Test JSON marshalling and unmarshalling with time.Time + t.Run("JSONTimeMarshalling", func(t *testing.T) { + type TestStruct struct { + TimeValue optionalv2.Option[time.Time] `json:"timeValue,omitempty"` + } + + // Test with value + s := TestStruct{ + TimeValue: optionalv2.Some(time.Date(2024, 9, 13, 0, 0, 0, 0, time.UTC)), + } + data, err := json.Marshal(s) + assert.NoError(t, err) + assert.JSONEq(t, `{"timeValue":"2024-09-13T00:00:00Z"}`, string(data)) + + // Test with null value + s.TimeValue = optionalv2.Some(time.Time{}) + data, err = json.Marshal(s) + assert.NoError(t, err) + assert.JSONEq(t, `{"timeValue":null}`, string(data)) + + // Test with missing field + s = TestStruct{} + jsonStr := `{}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.TimeValue.IsNone()) + + // Test unmarshalling with value + jsonStr = `{"timeValue": "2024-09-13T00:00:00Z"}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.TimeValue.IsSome()) + assert.Equal(t, time.Date(2024, 9, 13, 0, 0, 0, 0, time.UTC), s.TimeValue.Unwrap()) + + // Test unmarshalling with null + jsonStr = `{"timeValue": null}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.TimeValue.IsSome()) + assert.Equal(t, time.Time{}, s.TimeValue.Unwrap()) + }) + + // Test edge cases with zero values + t.Run("ZeroValues", func(t *testing.T) { + opt := optionalv2.Some(0) + assert.True(t, opt.IsSome()) + data, err := json.Marshal(opt) + assert.NoError(t, err) + assert.Equal(t, "null", string(data)) + + var optUnmarshalled optionalv2.Option[int] + err = json.Unmarshal([]byte("null"), &optUnmarshalled) + assert.NoError(t, err) + assert.True(t, optUnmarshalled.IsSome()) + assert.Equal(t, 0, optUnmarshalled.Unwrap()) + }) + + // Test with time.Time type + t.Run("TimeType", func(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + optTime := optionalv2.Some(now) + assert.True(t, optTime.IsSome()) + assert.Equal(t, now, optTime.Unwrap()) + + data, err := json.Marshal(optTime) + assert.NoError(t, err) + expectedJSON, _ := json.Marshal(now) + assert.Equal(t, string(expectedJSON), string(data)) + + var optTimeUnmarshalled optionalv2.Option[time.Time] + err = json.Unmarshal(data, &optTimeUnmarshalled) + assert.NoError(t, err) + assert.True(t, optTimeUnmarshalled.IsSome()) + assert.Equal(t, now, optTimeUnmarshalled.Unwrap()) + }) + + // Test with pointer types + t.Run("PointerTypes", func(t *testing.T) { + value := 10 + opt := optionalv2.Some(&value) + assert.True(t, opt.IsSome()) + assert.Equal(t, &value, opt.Unwrap()) + + data, err := json.Marshal(opt) + assert.NoError(t, err) + expectedJSON, _ := json.Marshal(&value) + assert.Equal(t, string(expectedJSON), string(data)) + + var optUnmarshalled optionalv2.Option[*int] + err = json.Unmarshal(data, &optUnmarshalled) + assert.NoError(t, err) + assert.True(t, optUnmarshalled.IsSome()) + assert.Equal(t, &value, optUnmarshalled.Unwrap()) + }) + + // Test nil pointer + t.Run("NilPointer", func(t *testing.T) { + var ptr *int = nil + opt := optionalv2.Some(ptr) + assert.True(t, opt.IsSome()) + assert.Nil(t, opt.Unwrap()) + + data, err := json.Marshal(opt) + assert.NoError(t, err) + assert.Equal(t, "null", string(data)) + + var optUnmarshalled optionalv2.Option[*int] + err = json.Unmarshal([]byte("null"), &optUnmarshalled) + assert.NoError(t, err) + assert.True(t, optUnmarshalled.IsSome()) + assert.Nil(t, optUnmarshalled.Unwrap()) + }) + + // Test with complex struct + t.Run("ComplexStruct", func(t *testing.T) { + type NestedStruct struct { + ID int + Name string + } + + type TestStruct struct { + Data optionalv2.Option[NestedStruct] `json:"data,omitempty"` + } + + // Test with value + s := TestStruct{ + Data: optionalv2.Some(NestedStruct{ID: 1, Name: "Nested"}), + } + data, err := json.Marshal(s) + assert.NoError(t, err) + assert.JSONEq(t, `{"data": {"ID":1, "Name":"Nested"}}`, string(data)) + + // Test unmarshalling + jsonStr := `{"data": {"ID":2, "Name":"Unmarshalled"}}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.Data.IsSome()) + assert.Equal(t, NestedStruct{ID: 2, Name: "Unmarshalled"}, s.Data.Unwrap()) + + // Test with null value + jsonStr = `{"data": null}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.Data.IsSome()) + assert.Equal(t, NestedStruct{}, s.Data.Unwrap()) + + // Test with missing field + s = TestStruct{} + jsonStr = `{}` + err = json.Unmarshal([]byte(jsonStr), &s) + assert.NoError(t, err) + assert.True(t, s.Data.IsNone()) + }) + + // Test isNull method (private method, but behavior can be tested) + t.Run("IsNullBehavior", func(t *testing.T) { + // Since isNull is a private method, we test its effect via JSON marshalling + opt := optionalv2.Some(0) + data, err := json.Marshal(opt) + assert.NoError(t, err) + assert.Equal(t, "null", string(data)) // Zero value treated as null + + opt = optionalv2.Some(1) + data, err = json.Marshal(opt) + assert.NoError(t, err) + assert.Equal(t, "1", string(data)) + + // Note: This is considered undefined behavior, as the `omitempty` tag is not used + optNone := optionalv2.None[int]() + data, err = json.Marshal(optNone) + assert.NoError(t, err) + assert.Equal(t, "0", string(data)) // None is marshalled as the zero value when not in a struct + + // Unmarshalling null into Option[int] + var optUnmarshalled optionalv2.Option[int] + err = json.Unmarshal([]byte("null"), &optUnmarshalled) + assert.NoError(t, err) + assert.True(t, optUnmarshalled.IsSome()) + assert.Equal(t, 0, optUnmarshalled.Unwrap()) + }) + + // Test behavior with slices + t.Run("Slices", func(t *testing.T) { + opt := optionalv2.Some([]int{1, 2, 3}) + assert.True(t, opt.IsSome()) + assert.Equal(t, []int{1, 2, 3}, opt.Unwrap()) + + data, err := json.Marshal(opt) + assert.NoError(t, err) + assert.JSONEq(t, `[1,2,3]`, string(data)) + + var optUnmarshalled optionalv2.Option[[]int] + err = json.Unmarshal(data, &optUnmarshalled) + assert.NoError(t, err) + assert.True(t, optUnmarshalled.IsSome()) + assert.Equal(t, []int{1, 2, 3}, optUnmarshalled.Unwrap()) + }) + + // Test behavior with maps + t.Run("Maps", func(t *testing.T) { + opt := optionalv2.Some(map[string]int{"one": 1, "two": 2}) + assert.True(t, opt.IsSome()) + assert.Equal(t, map[string]int{"one": 1, "two": 2}, opt.Unwrap()) + + data, err := json.Marshal(opt) + assert.NoError(t, err) + expectedJSON, _ := json.Marshal(map[string]int{"one": 1, "two": 2}) + assert.JSONEq(t, string(expectedJSON), string(data)) + + var optUnmarshalled optionalv2.Option[map[string]int] + err = json.Unmarshal(data, &optUnmarshalled) + assert.NoError(t, err) + assert.True(t, optUnmarshalled.IsSome()) + assert.Equal(t, map[string]int{"one": 1, "two": 2}, optUnmarshalled.Unwrap()) + }) + + // Test behavior with interface types + t.Run("InterfaceTypes", func(t *testing.T) { + var i interface{} = "string value" + opt := optionalv2.Some(i) + assert.True(t, opt.IsSome()) + assert.Equal(t, "string value", opt.Unwrap()) + + data, err := json.Marshal(opt) + assert.NoError(t, err) + assert.JSONEq(t, `"string value"`, string(data)) + + var optUnmarshalled optionalv2.Option[interface{}] + err = json.Unmarshal(data, &optUnmarshalled) + assert.NoError(t, err) + assert.True(t, optUnmarshalled.IsSome()) + assert.Equal(t, "string value", optUnmarshalled.Unwrap()) + }) + + // Test methods with custom types implementing fmt.Stringer + t.Run("CustomStringer", func(t *testing.T) { + customValue := CustomType{ID: 2, Name: "Custom"} + opt := optionalv2.Some(customValue) + assert.Equal(t, "Some[CustomType(ID=2, Name=Custom)]", opt.String()) + + optNone := optionalv2.None[CustomType]() + assert.Equal(t, "None[]", optNone.String()) + }) + + // Test TakeOrElse with side effects + t.Run("TakeOrElseSideEffects", func(t *testing.T) { + opt := optionalv2.None[int]() + var sideEffect int + + value := opt.TakeOrElse(func() int { + sideEffect = 1 + return 42 + }) + assert.Equal(t, 42, value) + assert.Equal(t, 1, sideEffect) + + opt = optionalv2.Some(10) + sideEffect = 0 + value = opt.TakeOrElse(func() int { + sideEffect = 1 + return 100 + }) + assert.Equal(t, 10, value) + assert.Equal(t, 0, sideEffect) + }) +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b5b6597 --- /dev/null +++ b/readme.md @@ -0,0 +1,288 @@ +# optionalv2 + +`optionalv2` is a Go package that provides a generic `Option[T any]` type, inspired by the Option type in languages like Rust. It allows you to represent an optional value: every `Option` is either `Some(value)` or `None`. This can be particularly useful when you need to distinguish between a missing value and a zero value in your applications, especially when dealing with JSON serialization/deserialization. + +## Features + +- **Generic Option Type**: Supports any type `T`, thanks to Go's generics. +- **Explicit Null Values**: Ability to represent explicit `null` values when serializing to JSON. +- **JSON Marshalling/Unmarshalling**: Seamless integration with Go's `encoding/json` package. +- **Convenient Methods**: Provides a set of methods for working with optional values, such as `Unwrap()`, `IsSome()`, `IsNone()`, `TakeOr()`, etc. + +## Installation + +```bash +go get github.com/tapp-ai/go-optional-v2 +``` + +## Usage + +### Importing the Package + +```go +import "github.com/tapp-ai/go-optional-v2" +``` + +### Creating Option Values + +#### Some + +To create an `Option[T]` with a value: + +```go +opt := optionalv2.Some(42) +``` + +**Note**: If you pass the zero value of the type `T` to `Some`, it will be treated as an explicit `null` when marshalled to JSON. See the [Edge Cases and Special Behaviors](#edge-cases-and-special-behaviors) section for more details. + +#### None + +To create an `Option[T]` without a value: + +```go +opt := optionalv2.None[int]() +``` + +### Checking if an Option Has a Value + +```go +if opt.IsSome() { + // Option has a value +} else if opt.IsNone() { + // Option is None (no value) +} +``` + +### Unwrapping the Value + +#### Unwrap + +Retrieves the value inside the `Option`. If the `Option` is `None`, it returns the zero value of type `T`. Otherwise, it returns the value. + +```go +value := opt.Unwrap() +``` + +#### UnwrapAsPtr + +Retrieves the value as a pointer. If the `Option` is `None`, it returns `nil`. Otherwise, it returns a pointer to the value. + +```go +valuePtr := opt.UnwrapAsPtr() +``` + +### Taking the Value with Error Handling + +```go +value, err := opt.Take() +if err != nil { + // Handle the error (e.g., Option is None) +} +``` + +### Fallback Values + +#### TakeOr + +Returns the value if present, otherwise returns the provided fallback value. + +```go +value := opt.TakeOr(100) // Returns 100 if opt is None +``` + +#### TakeOrElse + +Returns the value if present, otherwise executes the provided function and returns its result. + +```go +value := opt.TakeOrElse(func() int { + // Compute fallback value + return 200 +}) +``` + +### Chaining Options + +#### Or + +Returns the current `Option` if it has a value, otherwise returns the provided fallback `Option`. + +```go +opt := opt.Or(optionalv2.Some(300)) +``` + +### Filtering Options + +#### Filter + +Returns the `Option` if it satisfies the predicate, otherwise returns `None`. + +```go +opt = opt.Filter(func(v int) bool { + return v > 10 +}) +``` + +### Conditional Execution + +#### IfSome + +Executes a function if the `Option` is `Some`. + +```go +opt.IfSome(func(v int) { + fmt.Println("Value is:", v) +}) +``` + +#### IfNone + +Executes a function if the `Option` is `None`. + +```go +opt.IfNone(func() { + fmt.Println("No value") +}) +``` + +### String Representation + +```go +fmt.Println(opt.String()) // Outputs: Some[42] or None[] +``` + +## JSON Marshalling/Unmarshalling + +The `Option` type implements `json.Marshaler` and `json.Unmarshaler`, allowing it to be seamlessly serialized and deserialized using the standard `encoding/json` package. + +**Important**: The `Option` type should always be used with an `omitempty` tag in struct fields to ensure correct behavior when marshalling to JSON. Marshalling an `Option` without `omitempty` may result in unexpected behavior. + +TLDR: In this package, the JSON `null` is treated as the GoLang zero value (and vice versa). JSON absent fields are treated as `None`. + +### Marshalling Behavior + +- If the `Option` is `Some` and contains a non-zero value, it is marshalled as the value. +- If the `Option` is `Some` and contains the zero value of type `T`, it is marshalled as `null`. +- If the `Option` is `None`, it is omitted when marshalling (assuming `omitempty` is set in struct tags). + +### Unmarshalling Behavior + +- If the JSON field is absent, `UnmarshalJSON` is not called, and the `Option` remains `None`. +- If the JSON field is present and `null`, the `Option` becomes `Some` with the zero value of type `T` (representing an explicit `null`). +- If the JSON field has a value, the `Option` becomes `Some` with that value. + +### Example + +```go +type MyStruct struct { + Name optionalv2.Option[string] `json:"name,omitempty"` + Age optionalv2.Option[int] `json:"age,omitempty"` + Birthday optionalv2.Option[time.Time] `json:"birthday,omitempty"` +} +``` + +#### Marshalling + +```go +s := MyStruct{ + Name: optionalv2.Some("Alice"), + Age: optionalv2.None[int](), + Birthday: optionalv2.Some(time.Time{}), // Zero value, will be marshalled as null +} + +data, err := json.Marshal(s) +// data will be: { "name": "Alice", "birthday": null } +``` + +#### Unmarshalling + +```go +jsonData := []byte(`{ "name": "Bob", "age": null }`) + +var s MyStruct +err := json.Unmarshal(jsonData, &s) + +// s.Name is Some("Bob") +// s.Age is Some(0) (explicit null) +// s.Birthday is None (field absent) +``` + +## Edge Cases and Special Behaviors + +- **Zero Values**: When you pass the zero value of type `T` to `Some`, it is treated as an explicit `null` when marshalling to JSON. This allows you to distinguish between an absent field (`None`) and a field explicitly set to `null`. +- **Omitted Fields**: If an `Option` field in a struct is `None` and has the `omitempty` tag, it will be omitted from the JSON output. + +## Examples + +### Basic Usage + +```go +package main + +import ( + "fmt" + "github.com/tapp-ai/go-optional-v2" +) + +func main() { + opt := optionalv2.Some(42) + + if opt.IsSome() { + fmt.Println("Value is:", opt.Unwrap()) + } else { + fmt.Println("No value") + } + + optNone := optionalv2.None[int]() + fmt.Println("Has value?", optNone.IsSome()) +} +``` + +### Working with JSON + +```go +package main + +import ( + "encoding/json" + "fmt" + "github.com/tapp-ai/go-optional-v2" +) + +type User struct { + Name optionalv2.Option[string] `json:"name,omitempty"` + Age optionalv2.Option[int] `json:"age,omitempty"` +} + +func main() { + user := User{ + Name: optionalv2.Some("Charlie"), + Age: optionalv2.Some(0), // Explicit null when marshalled + } + + data, _ := json.Marshal(user) + fmt.Println(string(data)) // Output: { "name": "Charlie", "age": null } +} +``` + +### Conditional Execution + +```go +opt := optionalv2.Some(10) + +opt.IfSome(func(v int) { + fmt.Println("Value is:", v) +}) + +opt.IfNone(func() { + fmt.Println("Option is None") +}) +``` + +## Maintainers + +This package is maintained by the engineering team @ [StyleAI](https://usestyle.ai/). + +## License + +[MIT License](LICENSE)