diff --git a/fn/result_opt.go b/fn/result_opt.go new file mode 100644 index 000000000..bb2fa0d14 --- /dev/null +++ b/fn/result_opt.go @@ -0,0 +1,64 @@ +package fn + +// ResultOpt represents an operation that may either fail (with an error) or +// succeed with an optional final value. +type ResultOpt[T any] struct { + Result[Option[T]] +} + +// OkOpt constructs a successful ResultOpt with a present value. +func OkOpt[T any](val T) ResultOpt[T] { + return ResultOpt[T]{Ok(Some(val))} +} + +// NoneOpt constructs a successful ResultOpt with no final value. +func NoneOpt[T any]() ResultOpt[T] { + return ResultOpt[T]{Ok(None[T]())} +} + +// ErrOpt constructs a failed ResultOpt with the provided error. +func ErrOpt[T any](err error) ResultOpt[T] { + return ResultOpt[T]{Err[Option[T]](err)} +} + +// MapResultOpt applies a function to the final value of a successful operation. +func MapResultOpt[T, U any](ro ResultOpt[T], f func(T) U) ResultOpt[U] { + if ro.IsErr() { + return ErrOpt[U](ro.Err()) + } + opt, _ := ro.Unpack() + return ResultOpt[U]{Ok(MapOption(f)(opt))} +} + +// AndThenResultOpt applies a function to the final value of a successful +// operation. +func AndThenResultOpt[T, U any](ro ResultOpt[T], + f func(T) ResultOpt[U]) ResultOpt[U] { + + if ro.IsErr() { + return ErrOpt[U](ro.Err()) + } + opt, _ := ro.Unpack() + if opt.IsNone() { + return NoneOpt[U]() + } + return f(opt.some) +} + +// IsSome returns true if the operation succeeded and contains a final value. +func (ro ResultOpt[T]) IsSome() bool { + if ro.IsErr() { + return false + } + opt, _ := ro.Unpack() + return opt.IsSome() +} + +// IsNone returns true if the operation succeeded but no final value is present. +func (ro ResultOpt[T]) IsNone() bool { + if ro.IsErr() { + return false + } + opt, _ := ro.Unpack() + return opt.IsNone() +} diff --git a/fn/result_opt_test.go b/fn/result_opt_test.go new file mode 100644 index 000000000..2c17fe3b2 --- /dev/null +++ b/fn/result_opt_test.go @@ -0,0 +1,136 @@ +package fn + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOkOpt(t *testing.T) { + value := 42 + resOpt := OkOpt(value) + opt, err := resOpt.Unpack() + require.NoError(t, err) + require.True(t, opt.IsSome(), "expected Option to be Some") + require.Equal(t, value, opt.UnsafeFromSome()) + require.True(t, resOpt.IsSome()) + require.False(t, resOpt.IsNone()) +} + +func TestNoneOpt(t *testing.T) { + resOpt := NoneOpt[int]() + opt, err := resOpt.Unpack() + require.NoError(t, err) + require.True(t, opt.IsNone(), "expected Option to be None") + require.True(t, resOpt.IsNone()) + require.False(t, resOpt.IsSome()) +} + +func TestErrOpt(t *testing.T) { + errMsg := "some error" + resOpt := ErrOpt[int](errors.New(errMsg)) + _, err := resOpt.Unpack() + require.Error(t, err) + require.EqualError(t, err, errMsg) + require.False(t, resOpt.IsSome()) + require.False(t, resOpt.IsNone()) +} + +func TestMapResultOptOk(t *testing.T) { + value := 10 + resOpt := OkOpt(value) + mapped := MapResultOpt(resOpt, func(i int) int { + return i * 3 + }) + opt, err := mapped.Unpack() + require.NoError(t, err) + require.True(t, opt.IsSome(), "expected mapped Option to be Some") + require.Equal(t, 30, opt.UnsafeFromSome()) +} + +func TestMapResultOptNone(t *testing.T) { + resOpt := NoneOpt[int]() + mapped := MapResultOpt(resOpt, func(i int) int { + return i * 3 + }) + opt, err := mapped.Unpack() + require.NoError(t, err) + require.True(t, opt.IsNone(), "expected mapped Option to remain None") +} + +func TestMapResultOptErr(t *testing.T) { + errMsg := "error mapping" + resOpt := ErrOpt[int](errors.New(errMsg)) + mapped := MapResultOpt(resOpt, func(i int) int { + return i * 3 + }) + _, err := mapped.Unpack() + require.Error(t, err) + require.EqualError(t, err, errMsg) +} + +func incrementOpt(x int) ResultOpt[int] { + return OkOpt(x + 1) +} + +func TestAndThenResultOptOk(t *testing.T) { + resOpt := OkOpt(5) + chained := AndThenResultOpt(resOpt, incrementOpt) + opt, err := chained.Unpack() + require.NoError(t, err) + require.True(t, opt.IsSome(), "expected chained Option to be Some") + require.Equal(t, 6, opt.UnsafeFromSome()) +} + +func TestAndThenResultOptNone(t *testing.T) { + resOpt := NoneOpt[int]() + chained := AndThenResultOpt(resOpt, incrementOpt) + opt, err := chained.Unpack() + require.NoError(t, err) + require.True(t, opt.IsNone(), "expected chained result to remain None") +} + +func TestAndThenResultOptErr(t *testing.T) { + errMsg := "error in initial result" + resOpt := ErrOpt[int](errors.New(errMsg)) + chained := AndThenResultOpt(resOpt, incrementOpt) + _, err := chained.Unpack() + require.Error(t, err) + require.EqualError(t, err, errMsg) +} + +func maybeEvenOpt(x int) ResultOpt[int] { + if x%2 == 0 { + return OkOpt(x / 2) + } + return NoneOpt[int]() +} + +func TestAndThenResultOptProducesNone(t *testing.T) { + // Given an odd number, maybeEvenOpt returns None. + resOpt := OkOpt(5) + chained := AndThenResultOpt(resOpt, maybeEvenOpt) + opt, err := chained.Unpack() + require.NoError(t, err) + require.True(t, opt.IsNone(), "expected chained result to be None") +} + +func TestMapAndThenIntegration(t *testing.T) { + resOpt := OkOpt(2) + chained := MapResultOpt( + AndThenResultOpt(resOpt, func(x int) ResultOpt[int] { + return OkOpt(x + 3) + }), + func(y int) int { + return y * 2 + }, + ) + opt, err := chained.Unpack() + require.NoError(t, err) + require.True( + t, opt.IsSome(), "expected integrated mapping and "+ + "chaining to produce Some", + ) + require.Equal(t, 10, opt.UnsafeFromSome()) +}