mirror of
https://github.com/IBM/fp-go.git
synced 2026-02-06 11:37:45 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1472fa5a50 | ||
|
|
49deb57d24 | ||
|
|
abb55ddbd0 |
@@ -188,6 +188,81 @@ func MonadChain[E, A, B any](fa Either[E, A], f Kleisli[E, A, B]) Either[E, B] {
|
||||
return f(fa.r)
|
||||
}
|
||||
|
||||
// MonadChainLeft sequences a computation on the Left (error) value, allowing error recovery or transformation.
|
||||
// If the Either is Left, applies the provided function to the error value, which returns a new Either.
|
||||
// If the Either is Right, returns the Right value unchanged with the new error type.
|
||||
//
|
||||
// This is the dual of [MonadChain] - while MonadChain operates on Right values (success),
|
||||
// MonadChainLeft operates on Left values (errors). It's useful for error recovery, error transformation,
|
||||
// or chaining alternative computations when an error occurs.
|
||||
//
|
||||
// Note: MonadChainLeft is identical to [OrElse] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// The error type can be transformed from EA to EB, allowing flexible error type conversions.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Error recovery: convert specific errors to success
|
||||
// result := either.MonadChainLeft(
|
||||
// either.Left[int](errors.New("not found")),
|
||||
// func(err error) either.Either[string, int] {
|
||||
// if err.Error() == "not found" {
|
||||
// return either.Right[string](0) // default value
|
||||
// }
|
||||
// return either.Left[int](err.Error()) // transform error
|
||||
// },
|
||||
// ) // Right(0)
|
||||
//
|
||||
// // Error transformation: change error type
|
||||
// result := either.MonadChainLeft(
|
||||
// either.Left[int](404),
|
||||
// func(code int) either.Either[string, int] {
|
||||
// return either.Left[int](fmt.Sprintf("Error code: %d", code))
|
||||
// },
|
||||
// ) // Left("Error code: 404")
|
||||
//
|
||||
// // Right values pass through unchanged
|
||||
// result := either.MonadChainLeft(
|
||||
// either.Right[error](42),
|
||||
// func(err error) either.Either[string, int] {
|
||||
// return either.Left[int]("error")
|
||||
// },
|
||||
// ) // Right(42)
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainLeft[EA, EB, A any](fa Either[EA, A], f Kleisli[EB, EA, A]) Either[EB, A] {
|
||||
return MonadFold(fa, f, Of[EB])
|
||||
}
|
||||
|
||||
// ChainLeft is the curried version of [MonadChainLeft].
|
||||
// Returns a function that sequences a computation on the Left (error) value.
|
||||
//
|
||||
// Note: ChainLeft is identical to [OrElse] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This is useful for creating reusable error handlers or transformers that can be
|
||||
// composed with other Either operations using pipes or function composition.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Create a reusable error handler
|
||||
// handleNotFound := either.ChainLeft[error, string](func(err error) either.Either[string, int] {
|
||||
// if err.Error() == "not found" {
|
||||
// return either.Right[string](0)
|
||||
// }
|
||||
// return either.Left[int](err.Error())
|
||||
// })
|
||||
//
|
||||
// // Use in a pipeline
|
||||
// result := F.Pipe1(
|
||||
// either.Left[int](errors.New("not found")),
|
||||
// handleNotFound,
|
||||
// ) // Right(0)
|
||||
//
|
||||
//go:inline
|
||||
func ChainLeft[EA, EB, A any](f Kleisli[EB, EA, A]) Kleisli[EB, Either[EA, A], A] {
|
||||
return Fold(f, Of[EB])
|
||||
}
|
||||
|
||||
// MonadChainFirst executes a side-effect computation but returns the original value.
|
||||
// Useful for performing actions (like logging) without changing the value.
|
||||
//
|
||||
@@ -471,6 +546,8 @@ func Alt[E, A any](that Lazy[Either[E, A]]) Operator[E, A, A] {
|
||||
// If the Either is Left, it applies the provided function to the error value,
|
||||
// which returns a new Either that replaces the original.
|
||||
//
|
||||
// Note: OrElse is identical to [ChainLeft] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This is useful for error recovery, fallback logic, or chaining alternative computations.
|
||||
// The error type can be widened from E1 to E2, allowing transformation of error types.
|
||||
//
|
||||
|
||||
@@ -124,79 +124,52 @@ func TestStringer(t *testing.T) {
|
||||
func TestZeroWithIntegers(t *testing.T) {
|
||||
e := Zero[error, int]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
assert.False(t, IsLeft(e), "Zero should not create a Left value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Equal(t, 0, value, "Right value should be zero for int")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
assert.Equal(t, Of[error](0), e, "Zero should create a Right value with zero for int")
|
||||
}
|
||||
|
||||
// TestZeroWithStrings tests Zero function with string types
|
||||
func TestZeroWithStrings(t *testing.T) {
|
||||
e := Zero[error, string]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
assert.False(t, IsLeft(e), "Zero should not create a Left value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Equal(t, "", value, "Right value should be empty string")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
assert.Equal(t, Of[error](""), e, "Zero should create a Right value with empty string")
|
||||
}
|
||||
|
||||
// TestZeroWithBooleans tests Zero function with boolean types
|
||||
func TestZeroWithBooleans(t *testing.T) {
|
||||
e := Zero[error, bool]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Equal(t, false, value, "Right value should be false for bool")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
assert.Equal(t, Of[error](false), e, "Zero should create a Right value with false for bool")
|
||||
}
|
||||
|
||||
// TestZeroWithFloats tests Zero function with float types
|
||||
func TestZeroWithFloats(t *testing.T) {
|
||||
e := Zero[error, float64]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Equal(t, 0.0, value, "Right value should be 0.0 for float64")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
assert.Equal(t, Of[error](0.0), e, "Zero should create a Right value with 0.0 for float64")
|
||||
}
|
||||
|
||||
// TestZeroWithPointers tests Zero function with pointer types
|
||||
func TestZeroWithPointers(t *testing.T) {
|
||||
e := Zero[error, *int]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Nil(t, value, "Right value should be nil for pointer type")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
var nilPtr *int
|
||||
assert.Equal(t, Of[error](nilPtr), e, "Zero should create a Right value with nil pointer")
|
||||
}
|
||||
|
||||
// TestZeroWithSlices tests Zero function with slice types
|
||||
func TestZeroWithSlices(t *testing.T) {
|
||||
e := Zero[error, []int]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Nil(t, value, "Right value should be nil for slice type")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
var nilSlice []int
|
||||
assert.Equal(t, Of[error](nilSlice), e, "Zero should create a Right value with nil slice")
|
||||
}
|
||||
|
||||
// TestZeroWithMaps tests Zero function with map types
|
||||
func TestZeroWithMaps(t *testing.T) {
|
||||
e := Zero[error, map[string]int]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Nil(t, value, "Right value should be nil for map type")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
var nilMap map[string]int
|
||||
assert.Equal(t, Of[error](nilMap), e, "Zero should create a Right value with nil map")
|
||||
}
|
||||
|
||||
// TestZeroWithStructs tests Zero function with struct types
|
||||
@@ -208,23 +181,16 @@ func TestZeroWithStructs(t *testing.T) {
|
||||
|
||||
e := Zero[error, TestStruct]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
expected := TestStruct{Field1: 0, Field2: ""}
|
||||
assert.Equal(t, expected, value, "Right value should be zero value for struct")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
assert.Equal(t, Of[error](expected), e, "Zero should create a Right value with zero value for struct")
|
||||
}
|
||||
|
||||
// TestZeroWithInterfaces tests Zero function with interface types
|
||||
func TestZeroWithInterfaces(t *testing.T) {
|
||||
e := Zero[error, interface{}]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Nil(t, value, "Right value should be nil for interface type")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
var nilInterface interface{}
|
||||
assert.Equal(t, Of[error](nilInterface), e, "Zero should create a Right value with nil interface")
|
||||
}
|
||||
|
||||
// TestZeroWithCustomErrorType tests Zero function with custom error types
|
||||
@@ -236,12 +202,7 @@ func TestZeroWithCustomErrorType(t *testing.T) {
|
||||
|
||||
e := Zero[CustomError, string]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
assert.False(t, IsLeft(e), "Zero should not create a Left value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.Equal(t, "", value, "Right value should be empty string")
|
||||
assert.Equal(t, CustomError{Code: 0, Message: ""}, err, "Error should be zero value for CustomError")
|
||||
assert.Equal(t, Of[CustomError](""), e, "Zero should create a Right value with empty string")
|
||||
}
|
||||
|
||||
// TestZeroCanBeUsedWithOtherFunctions tests that Zero Eithers work with other either functions
|
||||
@@ -252,17 +213,13 @@ func TestZeroCanBeUsedWithOtherFunctions(t *testing.T) {
|
||||
mapped := MonadMap(e, func(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
})
|
||||
assert.True(t, IsRight(mapped), "Mapped Zero should still be Right")
|
||||
value, _ := Unwrap(mapped)
|
||||
assert.Equal(t, "0", value, "Mapped value should be '0'")
|
||||
assert.Equal(t, Of[error]("0"), mapped, "Mapped Zero should be Right with '0'")
|
||||
|
||||
// Test with Chain
|
||||
chained := MonadChain(e, func(n int) Either[error, string] {
|
||||
return Right[error](fmt.Sprintf("value: %d", n))
|
||||
})
|
||||
assert.True(t, IsRight(chained), "Chained Zero should still be Right")
|
||||
chainedValue, _ := Unwrap(chained)
|
||||
assert.Equal(t, "value: 0", chainedValue, "Chained value should be 'value: 0'")
|
||||
assert.Equal(t, Of[error]("value: 0"), chained, "Chained Zero should be Right with 'value: 0'")
|
||||
|
||||
// Test with Fold
|
||||
folded := MonadFold(e,
|
||||
@@ -295,23 +252,15 @@ func TestZeroWithComplexTypes(t *testing.T) {
|
||||
|
||||
e := Zero[error, ComplexType]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
expected := ComplexType{Nested: nil, Ptr: nil}
|
||||
assert.Equal(t, expected, value, "Right value should be zero value for complex struct")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
assert.Equal(t, Of[error](expected), e, "Zero should create a Right value with zero value for complex struct")
|
||||
}
|
||||
|
||||
// TestZeroWithOption tests Zero with Option type
|
||||
func TestZeroWithOption(t *testing.T) {
|
||||
e := Zero[error, O.Option[int]]()
|
||||
|
||||
assert.True(t, IsRight(e), "Zero should create a Right value")
|
||||
|
||||
value, err := Unwrap(e)
|
||||
assert.True(t, O.IsNone(value), "Right value should be None for Option type")
|
||||
assert.Nil(t, err, "Error should be nil for Right value")
|
||||
assert.Equal(t, Of[error](O.None[int]()), e, "Zero should create a Right value with None option")
|
||||
}
|
||||
|
||||
// TestZeroIsNotLeft tests that Zero never creates a Left value
|
||||
@@ -343,3 +292,211 @@ func TestZeroEqualsDefaultInitialization(t *testing.T) {
|
||||
assert.Equal(t, IsRight(defaultInit), IsRight(zero), "Both should be Right")
|
||||
assert.Equal(t, IsLeft(defaultInit), IsLeft(zero), "Both should not be Left")
|
||||
}
|
||||
|
||||
// TestMonadChainLeft tests the MonadChainLeft function with various scenarios
|
||||
func TestMonadChainLeft(t *testing.T) {
|
||||
t.Run("Left value is transformed by function", func(t *testing.T) {
|
||||
// Transform error to success
|
||||
result := MonadChainLeft(
|
||||
Left[int](errors.New("not found")),
|
||||
func(err error) Either[string, int] {
|
||||
if err.Error() == "not found" {
|
||||
return Right[string](0) // default value
|
||||
}
|
||||
return Left[int](err.Error())
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Of[string](0), result)
|
||||
})
|
||||
|
||||
t.Run("Left value error type is transformed", func(t *testing.T) {
|
||||
// Transform error type from int to string
|
||||
result := MonadChainLeft(
|
||||
Left[int](404),
|
||||
func(code int) Either[string, int] {
|
||||
return Left[int](fmt.Sprintf("Error code: %d", code))
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Left[int]("Error code: 404"), result)
|
||||
})
|
||||
|
||||
t.Run("Right value passes through unchanged", func(t *testing.T) {
|
||||
// Right value should not be affected
|
||||
result := MonadChainLeft(
|
||||
Right[error](42),
|
||||
func(err error) Either[string, int] {
|
||||
return Left[int]("should not be called")
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Of[string](42), result)
|
||||
})
|
||||
|
||||
t.Run("Chain multiple error transformations", func(t *testing.T) {
|
||||
// First transformation
|
||||
step1 := MonadChainLeft(
|
||||
Left[int](errors.New("error1")),
|
||||
func(err error) Either[error, int] {
|
||||
return Left[int](errors.New("error2"))
|
||||
},
|
||||
)
|
||||
// Second transformation
|
||||
step2 := MonadChainLeft(
|
||||
step1,
|
||||
func(err error) Either[string, int] {
|
||||
return Left[int](err.Error())
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Left[int]("error2"), step2)
|
||||
})
|
||||
|
||||
t.Run("Error recovery with fallback", func(t *testing.T) {
|
||||
// Recover from specific errors
|
||||
result := MonadChainLeft(
|
||||
Left[int](errors.New("timeout")),
|
||||
func(err error) Either[error, int] {
|
||||
if err.Error() == "timeout" {
|
||||
return Right[error](999) // fallback value
|
||||
}
|
||||
return Left[int](err)
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Of[error](999), result)
|
||||
})
|
||||
|
||||
t.Run("Transform error to different Left", func(t *testing.T) {
|
||||
// Transform one error to another
|
||||
result := MonadChainLeft(
|
||||
Left[string]("original error"),
|
||||
func(s string) Either[int, string] {
|
||||
return Left[string](len(s))
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Left[string](14), result) // length of "original error"
|
||||
})
|
||||
}
|
||||
|
||||
// TestChainLeft tests the curried ChainLeft function
|
||||
func TestChainLeft(t *testing.T) {
|
||||
t.Run("Curried function transforms Left value", func(t *testing.T) {
|
||||
// Create a reusable error handler
|
||||
handleNotFound := ChainLeft[error, string](func(err error) Either[string, int] {
|
||||
if err.Error() == "not found" {
|
||||
return Right[string](0)
|
||||
}
|
||||
return Left[int](err.Error())
|
||||
})
|
||||
|
||||
result := handleNotFound(Left[int](errors.New("not found")))
|
||||
assert.Equal(t, Of[string](0), result)
|
||||
})
|
||||
|
||||
t.Run("Curried function with Right value", func(t *testing.T) {
|
||||
handler := ChainLeft[error, string](func(err error) Either[string, int] {
|
||||
return Left[int]("should not be called")
|
||||
})
|
||||
|
||||
result := handler(Right[error](42))
|
||||
assert.Equal(t, Of[string](42), result)
|
||||
})
|
||||
|
||||
t.Run("Use in pipeline with Pipe", func(t *testing.T) {
|
||||
// Create error transformer
|
||||
toStringError := ChainLeft[int, string](func(code int) Either[string, string] {
|
||||
return Left[string](fmt.Sprintf("Error: %d", code))
|
||||
})
|
||||
|
||||
result := F.Pipe1(
|
||||
Left[string](404),
|
||||
toStringError,
|
||||
)
|
||||
assert.Equal(t, Left[string]("Error: 404"), result)
|
||||
})
|
||||
|
||||
t.Run("Compose multiple ChainLeft operations", func(t *testing.T) {
|
||||
// First handler: convert error to string
|
||||
handler1 := ChainLeft[error, string](func(err error) Either[string, int] {
|
||||
return Left[int](err.Error())
|
||||
})
|
||||
|
||||
// Second handler: add prefix to string error
|
||||
handler2 := ChainLeft[string, string](func(s string) Either[string, int] {
|
||||
return Left[int]("Handled: " + s)
|
||||
})
|
||||
|
||||
result := F.Pipe2(
|
||||
Left[int](errors.New("original")),
|
||||
handler1,
|
||||
handler2,
|
||||
)
|
||||
assert.Equal(t, Left[int]("Handled: original"), result)
|
||||
})
|
||||
|
||||
t.Run("Error recovery in pipeline", func(t *testing.T) {
|
||||
// Handler that recovers from specific errors
|
||||
recoverFromTimeout := ChainLeft(func(err error) Either[error, int] {
|
||||
if err.Error() == "timeout" {
|
||||
return Right[error](0) // recovered value
|
||||
}
|
||||
return Left[int](err) // propagate other errors
|
||||
})
|
||||
|
||||
// Test with timeout error
|
||||
result1 := F.Pipe1(
|
||||
Left[int](errors.New("timeout")),
|
||||
recoverFromTimeout,
|
||||
)
|
||||
assert.Equal(t, Of[error](0), result1)
|
||||
|
||||
// Test with other error
|
||||
result2 := F.Pipe1(
|
||||
Left[int](errors.New("other error")),
|
||||
recoverFromTimeout,
|
||||
)
|
||||
assert.True(t, IsLeft(result2))
|
||||
})
|
||||
|
||||
t.Run("Transform error type in pipeline", func(t *testing.T) {
|
||||
// Convert numeric error codes to descriptive strings
|
||||
codeToMessage := ChainLeft(func(code int) Either[string, string] {
|
||||
messages := map[int]string{
|
||||
404: "Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
if msg, ok := messages[code]; ok {
|
||||
return Left[string](msg)
|
||||
}
|
||||
return Left[string](fmt.Sprintf("Unknown error: %d", code))
|
||||
})
|
||||
|
||||
result := F.Pipe1(
|
||||
Left[string](404),
|
||||
codeToMessage,
|
||||
)
|
||||
assert.Equal(t, Left[string]("Not Found"), result)
|
||||
})
|
||||
|
||||
t.Run("ChainLeft with Map combination", func(t *testing.T) {
|
||||
// Combine ChainLeft with Map to handle both channels
|
||||
errorHandler := ChainLeft(func(err error) Either[string, int] {
|
||||
return Left[int]("Error: " + err.Error())
|
||||
})
|
||||
|
||||
valueMapper := Map[string](S.Format[int]("Value: %d"))
|
||||
|
||||
// Test with Left
|
||||
result1 := F.Pipe2(
|
||||
Left[int](errors.New("fail")),
|
||||
errorHandler,
|
||||
valueMapper,
|
||||
)
|
||||
assert.Equal(t, Left[string]("Error: fail"), result1)
|
||||
|
||||
// Test with Right
|
||||
result2 := F.Pipe2(
|
||||
Right[error](42),
|
||||
errorHandler,
|
||||
valueMapper,
|
||||
)
|
||||
assert.Equal(t, Of[string]("Value: 42"), result2)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@ func After[E, A any](timestamp time.Time) Operator[E, A, A] {
|
||||
// If the input is a Left value, it applies the function f to transform the error and potentially
|
||||
// change the error type from EA to EB. If the input is a Right value, it passes through unchanged.
|
||||
//
|
||||
// Note: MonadChainLeft is identical to [OrElse] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This is useful for error recovery or error transformation scenarios where you want to handle
|
||||
// errors by performing another computation that may also fail.
|
||||
//
|
||||
@@ -523,6 +525,8 @@ func MonadChainLeft[EA, EB, A any](fa IOEither[EA, A], f Kleisli[EB, EA, A]) IOE
|
||||
// ChainLeft is the curried version of [MonadChainLeft].
|
||||
// It returns a function that chains a computation on the left (error) side of an [IOEither].
|
||||
//
|
||||
// Note: ChainLeft is identical to [OrElse] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This is particularly useful in functional composition pipelines where you want to handle
|
||||
// errors by performing another computation that may also fail.
|
||||
//
|
||||
@@ -644,6 +648,8 @@ func TapLeft[A, EA, EB, B any](f Kleisli[EB, EA, B]) Operator[EA, A, A] {
|
||||
// If the IOEither is Left, it applies the provided function to the error value,
|
||||
// which returns a new IOEither that replaces the original.
|
||||
//
|
||||
// Note: OrElse is identical to [ChainLeft] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This is useful for error recovery, fallback logic, or chaining alternative IO computations.
|
||||
// The error type can be widened from E1 to E2, allowing transformation of error types.
|
||||
//
|
||||
|
||||
@@ -490,3 +490,148 @@ func TestOrElseW(t *testing.T) {
|
||||
preserved := preserveRecover(preservedRight)()
|
||||
assert.Equal(t, E.Right[AppError](42), preserved)
|
||||
}
|
||||
|
||||
// TestChainLeftIdenticalToOrElse proves that ChainLeft and OrElse are identical functions.
|
||||
// This test verifies that both functions produce the same results for all scenarios:
|
||||
// - Left values with error recovery
|
||||
// - Left values with error transformation
|
||||
// - Right values passing through unchanged
|
||||
func TestChainLeftIdenticalToOrElse(t *testing.T) {
|
||||
// Test 1: Left value with error recovery - both should recover to Right
|
||||
t.Run("Left value recovery - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
recoveryFn := func(e string) IOEither[string, int] {
|
||||
if e == "recoverable" {
|
||||
return Right[string](42)
|
||||
}
|
||||
return Left[int](e)
|
||||
}
|
||||
|
||||
input := Left[int]("recoverable")
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(recoveryFn)(input)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(recoveryFn)(input)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](42), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 2: Left value with error transformation - both should transform error
|
||||
t.Run("Left value transformation - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
transformFn := func(e string) IOEither[string, int] {
|
||||
return Left[int]("transformed: " + e)
|
||||
}
|
||||
|
||||
input := Left[int]("original error")
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(transformFn)(input)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(transformFn)(input)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Left[int]("transformed: original error"), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 3: Right value - both should pass through unchanged
|
||||
t.Run("Right value passthrough - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
handlerFn := func(e string) IOEither[string, int] {
|
||||
return Left[int]("should not be called")
|
||||
}
|
||||
|
||||
input := Right[string](100)
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(handlerFn)(input)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(handlerFn)(input)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](100), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 4: Error type widening - both should handle type transformation
|
||||
t.Run("Error type widening - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
widenFn := func(e string) IOEither[int, int] {
|
||||
return Left[int](404)
|
||||
}
|
||||
|
||||
input := Left[int]("not found")
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(widenFn)(input)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(widenFn)(input)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Left[int](404), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 5: Composition in pipeline - both should work identically in F.Pipe
|
||||
t.Run("Pipeline composition - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
recoveryFn := func(e string) IOEither[string, int] {
|
||||
if e == "network error" {
|
||||
return Right[string](0)
|
||||
}
|
||||
return Left[int](e)
|
||||
}
|
||||
|
||||
input := Left[int]("network error")
|
||||
|
||||
// Using ChainLeft in pipeline
|
||||
resultChainLeft := F.Pipe1(input, ChainLeft(recoveryFn))()
|
||||
|
||||
// Using OrElse in pipeline
|
||||
resultOrElse := F.Pipe1(input, OrElse(recoveryFn))()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](0), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 6: Multiple chained operations - both should behave identically
|
||||
t.Run("Multiple operations - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
handler1 := func(e string) IOEither[string, int] {
|
||||
if e == "error1" {
|
||||
return Right[string](1)
|
||||
}
|
||||
return Left[int](e)
|
||||
}
|
||||
|
||||
handler2 := func(e string) IOEither[string, int] {
|
||||
if e == "error2" {
|
||||
return Right[string](2)
|
||||
}
|
||||
return Left[int](e)
|
||||
}
|
||||
|
||||
input := Left[int]("error2")
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := F.Pipe2(
|
||||
input,
|
||||
ChainLeft(handler1),
|
||||
ChainLeft(handler2),
|
||||
)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := F.Pipe2(
|
||||
input,
|
||||
OrElse(handler1),
|
||||
OrElse(handler2),
|
||||
)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](2), resultChainLeft)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -710,6 +710,146 @@ func TestTranscodeEither(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTranscodeEitherValidation(t *testing.T) {
|
||||
t.Run("validates Left value with context", func(t *testing.T) {
|
||||
eitherCodec := TranscodeEither(String(), Int())
|
||||
result := eitherCodec.Decode(either.Left[any, any](123)) // Invalid: should be string
|
||||
|
||||
assert.True(t, either.IsLeft(result))
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[validation.Errors],
|
||||
func(either.Either[string, int]) validation.Errors { return nil },
|
||||
)
|
||||
assert.NotEmpty(t, errors)
|
||||
// Verify error contains type information
|
||||
assert.Contains(t, fmt.Sprintf("%v", errors[0]), "string")
|
||||
})
|
||||
|
||||
t.Run("validates Right value with context", func(t *testing.T) {
|
||||
eitherCodec := TranscodeEither(String(), Int())
|
||||
result := eitherCodec.Decode(either.Right[any, any]("not a number"))
|
||||
|
||||
assert.True(t, either.IsLeft(result))
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[validation.Errors],
|
||||
func(either.Either[string, int]) validation.Errors { return nil },
|
||||
)
|
||||
assert.NotEmpty(t, errors)
|
||||
// Verify error contains type information
|
||||
assert.Contains(t, fmt.Sprintf("%v", errors[0]), "int")
|
||||
})
|
||||
|
||||
t.Run("preserves Either structure on validation failure", func(t *testing.T) {
|
||||
eitherCodec := TranscodeEither(String(), Int())
|
||||
|
||||
// Left with wrong type
|
||||
leftResult := eitherCodec.Decode(either.Left[any, any]([]int{1, 2, 3}))
|
||||
assert.True(t, either.IsLeft(leftResult))
|
||||
|
||||
// Right with wrong type
|
||||
rightResult := eitherCodec.Decode(either.Right[any, any](true))
|
||||
assert.True(t, either.IsLeft(rightResult))
|
||||
})
|
||||
|
||||
t.Run("validates with custom codec that can fail", func(t *testing.T) {
|
||||
// Create a codec that only accepts positive integers
|
||||
positiveInt := MakeType(
|
||||
"PositiveInt",
|
||||
func(u any) either.Either[error, int] {
|
||||
i, ok := u.(int)
|
||||
if !ok || i <= 0 {
|
||||
return either.Left[int](fmt.Errorf("not a positive integer"))
|
||||
}
|
||||
return either.Of[error](i)
|
||||
},
|
||||
func(i int) Decode[Context, int] {
|
||||
return func(c Context) Validation[int] {
|
||||
if i <= 0 {
|
||||
return validation.FailureWithMessage[int](i, "must be positive")(c)
|
||||
}
|
||||
return validation.Success(i)
|
||||
}
|
||||
},
|
||||
F.Identity[int],
|
||||
)
|
||||
|
||||
eitherCodec := TranscodeEither(String(), positiveInt)
|
||||
|
||||
// Valid positive integer
|
||||
validResult := eitherCodec.Decode(either.Right[any](42))
|
||||
assert.True(t, either.IsRight(validResult))
|
||||
|
||||
// Invalid: zero
|
||||
zeroResult := eitherCodec.Decode(either.Right[any](0))
|
||||
assert.True(t, either.IsLeft(zeroResult))
|
||||
|
||||
// Invalid: negative
|
||||
negativeResult := eitherCodec.Decode(either.Right[any](-5))
|
||||
assert.True(t, either.IsLeft(negativeResult))
|
||||
})
|
||||
|
||||
t.Run("validates both branches independently", func(t *testing.T) {
|
||||
// Create codecs with specific validation rules
|
||||
nonEmptyString := MakeType(
|
||||
"NonEmptyString",
|
||||
func(u any) either.Either[error, string] {
|
||||
s, ok := u.(string)
|
||||
if !ok || len(s) == 0 {
|
||||
return either.Left[string](fmt.Errorf("not a non-empty string"))
|
||||
}
|
||||
return either.Of[error](s)
|
||||
},
|
||||
func(s string) Decode[Context, string] {
|
||||
return func(c Context) Validation[string] {
|
||||
if len(s) == 0 {
|
||||
return validation.FailureWithMessage[string](s, "must not be empty")(c)
|
||||
}
|
||||
return validation.Success(s)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
|
||||
evenInt := MakeType(
|
||||
"EvenInt",
|
||||
func(u any) either.Either[error, int] {
|
||||
i, ok := u.(int)
|
||||
if !ok || i%2 != 0 {
|
||||
return either.Left[int](fmt.Errorf("not an even integer"))
|
||||
}
|
||||
return either.Of[error](i)
|
||||
},
|
||||
func(i int) Decode[Context, int] {
|
||||
return func(c Context) Validation[int] {
|
||||
if i%2 != 0 {
|
||||
return validation.FailureWithMessage[int](i, "must be even")(c)
|
||||
}
|
||||
return validation.Success(i)
|
||||
}
|
||||
},
|
||||
F.Identity[int],
|
||||
)
|
||||
|
||||
eitherCodec := TranscodeEither(nonEmptyString, evenInt)
|
||||
|
||||
// Valid Left: non-empty string
|
||||
validLeft := eitherCodec.Decode(either.Left[int]("hello"))
|
||||
assert.True(t, either.IsRight(validLeft))
|
||||
|
||||
// Invalid Left: empty string
|
||||
invalidLeft := eitherCodec.Decode(either.Left[int](""))
|
||||
assert.True(t, either.IsLeft(invalidLeft))
|
||||
|
||||
// Valid Right: even integer
|
||||
validRight := eitherCodec.Decode(either.Right[string](42))
|
||||
assert.True(t, either.IsRight(validRight))
|
||||
|
||||
// Invalid Right: odd integer
|
||||
invalidRight := eitherCodec.Decode(either.Right[string](43))
|
||||
assert.True(t, either.IsLeft(invalidRight))
|
||||
})
|
||||
}
|
||||
|
||||
func TestTranscodeEitherWithTransformation(t *testing.T) {
|
||||
// Create a codec that transforms strings to their lengths
|
||||
stringToLength := MakeType(
|
||||
|
||||
321
v2/optics/codec/decode/OrElse_ChainLeft_explanation.md
Normal file
321
v2/optics/codec/decode/OrElse_ChainLeft_explanation.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# ChainLeft and OrElse in the Decode Package
|
||||
|
||||
## Overview
|
||||
|
||||
In [`optics/codec/decode/monad.go`](monad.go:53-62), the [`ChainLeft`](monad.go:53) and [`OrElse`](monad.go:60) functions work with decoders that may fail during decoding operations.
|
||||
|
||||
```go
|
||||
func ChainLeft[I, A any](f Kleisli[I, Errors, A]) Operator[I, A, A] {
|
||||
return readert.Chain[Decode[I, A]](
|
||||
validation.ChainLeft,
|
||||
f,
|
||||
)
|
||||
}
|
||||
|
||||
func OrElse[I, A any](f Kleisli[I, Errors, A]) Operator[I, A, A] {
|
||||
return ChainLeft(f)
|
||||
}
|
||||
```
|
||||
|
||||
## Key Insight: OrElse is ChainLeft
|
||||
|
||||
**`OrElse` is exactly the same as `ChainLeft`** - they are aliases with identical implementations and behavior. The choice between them is purely about **code readability and semantic intent**.
|
||||
|
||||
## Understanding the Types
|
||||
|
||||
### Decode[I, A]
|
||||
A decoder that takes input of type `I` and produces a `Validation[A]`:
|
||||
```go
|
||||
type Decode[I, A any] = func(I) Validation[A]
|
||||
```
|
||||
|
||||
### Kleisli[I, Errors, A]
|
||||
A function that takes `Errors` and produces a `Decode[I, A]`:
|
||||
```go
|
||||
type Kleisli[I, Errors, A] = func(Errors) Decode[I, A]
|
||||
```
|
||||
|
||||
This allows error handlers to:
|
||||
1. Access the validation errors that occurred
|
||||
2. Access the original input (via the returned Decode function)
|
||||
3. Either recover with a success value or produce new errors
|
||||
|
||||
### Operator[I, A, A]
|
||||
A function that transforms one decoder into another:
|
||||
```go
|
||||
type Operator[I, A, A] = func(Decode[I, A]) Decode[I, A]
|
||||
```
|
||||
|
||||
## Core Behavior
|
||||
|
||||
Both [`ChainLeft`](monad.go:53) and [`OrElse`](monad.go:60) delegate to [`validation.ChainLeft`](../validation/monad.go:304), which provides:
|
||||
|
||||
### 1. Error Aggregation
|
||||
When the transformation function returns a failure, **both the original errors AND the new errors are combined** using the Errors monoid:
|
||||
|
||||
```go
|
||||
failingDecoder := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "original error"},
|
||||
})
|
||||
}
|
||||
|
||||
handler := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "additional error"},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
decoder := handler(failingDecoder)
|
||||
result := decoder("input")
|
||||
// Result contains BOTH errors: ["original error", "additional error"]
|
||||
```
|
||||
|
||||
### 2. Success Pass-Through
|
||||
Success values pass through unchanged - the handler is never called:
|
||||
|
||||
```go
|
||||
successDecoder := Of[string](42)
|
||||
|
||||
handler := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "never called"},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
decoder := handler(successDecoder)
|
||||
result := decoder("input")
|
||||
// Result: Success(42) - unchanged
|
||||
```
|
||||
|
||||
### 3. Error Recovery
|
||||
The handler can recover from failures by returning a successful decoder:
|
||||
|
||||
```go
|
||||
failingDecoder := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "not found"},
|
||||
})
|
||||
}
|
||||
|
||||
recoverFromNotFound := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
for _, err := range errs {
|
||||
if err.Messsage == "not found" {
|
||||
return Of[string](0) // recover with default
|
||||
}
|
||||
}
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](errs)
|
||||
}
|
||||
})
|
||||
|
||||
decoder := recoverFromNotFound(failingDecoder)
|
||||
result := decoder("input")
|
||||
// Result: Success(0) - recovered from failure
|
||||
```
|
||||
|
||||
### 4. Access to Original Input
|
||||
The handler returns a `Decode[I, A]` function, giving it access to the original input:
|
||||
|
||||
```go
|
||||
handler := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
// Can access both errs and input here
|
||||
if input == "special" {
|
||||
return validation.Of(999)
|
||||
}
|
||||
return either.Left[int](errs)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Fallback Decoding (OrElse reads better)
|
||||
|
||||
```go
|
||||
// Primary decoder that may fail
|
||||
primaryDecoder := func(input string) Validation[int] {
|
||||
n, err := strconv.Atoi(input)
|
||||
if err != nil {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "not a valid integer"},
|
||||
})
|
||||
}
|
||||
return validation.Of(n)
|
||||
}
|
||||
|
||||
// Use OrElse for semantic clarity - "try primary, or else use default"
|
||||
withDefault := OrElse(func(errs Errors) Decode[string, int] {
|
||||
return Of[string](0) // default to 0 if decoding fails
|
||||
})
|
||||
|
||||
decoder := withDefault(primaryDecoder)
|
||||
|
||||
result1 := decoder("42") // Success(42)
|
||||
result2 := decoder("abc") // Success(0) - fallback
|
||||
```
|
||||
|
||||
### 2. Error Context Addition (ChainLeft reads better)
|
||||
|
||||
```go
|
||||
decodeUserAge := func(data map[string]any) Validation[int] {
|
||||
age, ok := data["age"].(int)
|
||||
if !ok {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: data["age"], Messsage: "invalid type"},
|
||||
})
|
||||
}
|
||||
return validation.Of(age)
|
||||
}
|
||||
|
||||
// Use ChainLeft when emphasizing error transformation
|
||||
addContext := ChainLeft(func(errs Errors) Decode[map[string]any, int] {
|
||||
return func(data map[string]any) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{
|
||||
Context: validation.Context{{Key: "user", Type: "User"}, {Key: "age", Type: "int"}},
|
||||
Messsage: "failed to decode user age",
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
decoder := addContext(decodeUserAge)
|
||||
// Errors will include both original error and context
|
||||
```
|
||||
|
||||
### 3. Conditional Recovery Based on Input
|
||||
|
||||
```go
|
||||
decodePort := func(input string) Validation[int] {
|
||||
port, err := strconv.Atoi(input)
|
||||
if err != nil {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "invalid port"},
|
||||
})
|
||||
}
|
||||
return validation.Of(port)
|
||||
}
|
||||
|
||||
// Recover with different defaults based on input
|
||||
smartDefault := OrElse(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
// Check input to determine appropriate default
|
||||
if strings.Contains(input, "http") {
|
||||
return validation.Of(80)
|
||||
}
|
||||
if strings.Contains(input, "https") {
|
||||
return validation.Of(443)
|
||||
}
|
||||
return validation.Of(8080)
|
||||
}
|
||||
})
|
||||
|
||||
decoder := smartDefault(decodePort)
|
||||
result1 := decoder("http-server") // Success(80)
|
||||
result2 := decoder("https-server") // Success(443)
|
||||
result3 := decoder("other") // Success(8080)
|
||||
```
|
||||
|
||||
### 4. Pipeline Composition
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
}
|
||||
|
||||
decodeConfig := func(data map[string]any) Validation[Config] {
|
||||
url, ok := data["db_url"].(string)
|
||||
if !ok {
|
||||
return either.Left[Config](validation.Errors{
|
||||
{Messsage: "missing db_url"},
|
||||
})
|
||||
}
|
||||
return validation.Of(Config{DatabaseURL: url})
|
||||
}
|
||||
|
||||
// Build a pipeline with multiple error handlers
|
||||
decoder := F.Pipe2(
|
||||
decodeConfig,
|
||||
OrElse(func(errs Errors) Decode[map[string]any, Config] {
|
||||
// Try environment variable as fallback
|
||||
return func(data map[string]any) Validation[Config] {
|
||||
if url := os.Getenv("DATABASE_URL"); url != "" {
|
||||
return validation.Of(Config{DatabaseURL: url})
|
||||
}
|
||||
return either.Left[Config](errs)
|
||||
}
|
||||
}),
|
||||
OrElse(func(errs Errors) Decode[map[string]any, Config] {
|
||||
// Final fallback to default
|
||||
return Of[map[string]any](Config{
|
||||
DatabaseURL: "localhost:5432",
|
||||
})
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
## Comparison with validation.ChainLeft
|
||||
|
||||
The decode package's [`ChainLeft`](monad.go:53) wraps [`validation.ChainLeft`](../validation/monad.go:304) using the Reader transformer pattern:
|
||||
|
||||
| Aspect | validation.ChainLeft | decode.ChainLeft |
|
||||
|--------|---------------------|------------------|
|
||||
| **Input** | `Validation[A]` | `Decode[I, A]` (function) |
|
||||
| **Handler** | `func(Errors) Validation[A]` | `func(Errors) Decode[I, A]` |
|
||||
| **Output** | `Validation[A]` | `Decode[I, A]` (function) |
|
||||
| **Context** | No input access | Access to original input `I` |
|
||||
| **Use Case** | Pure validation logic | Decoding with input-dependent recovery |
|
||||
|
||||
The key difference is that decode's version gives handlers access to the original input through the returned `Decode[I, A]` function.
|
||||
|
||||
## When to Use Which Name
|
||||
|
||||
### Use **OrElse** when:
|
||||
- Emphasizing fallback/alternative decoding logic
|
||||
- Providing default values on decode failure
|
||||
- The intent is "try this, or else try that"
|
||||
- Code reads more naturally with "or else"
|
||||
|
||||
### Use **ChainLeft** when:
|
||||
- Emphasizing technical error channel transformation
|
||||
- Adding context or enriching error information
|
||||
- The focus is on error handling mechanics
|
||||
- Working with other functional programming concepts
|
||||
|
||||
## Verification
|
||||
|
||||
The test suite in [`monad_test.go`](monad_test.go:385) includes comprehensive tests proving that `OrElse` and `ChainLeft` are equivalent:
|
||||
|
||||
- ✅ Identical behavior for Success values
|
||||
- ✅ Identical behavior for error recovery
|
||||
- ✅ Identical behavior for error aggregation
|
||||
- ✅ Identical behavior in pipeline composition
|
||||
- ✅ Identical behavior for multiple error scenarios
|
||||
- ✅ Both provide access to original input
|
||||
|
||||
Run the tests:
|
||||
```bash
|
||||
go test -v -run "TestChainLeft|TestOrElse" ./optics/codec/decode
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
**`OrElse` is exactly the same as `ChainLeft`** in the decode package - they are aliases with identical implementations and behavior. Both:
|
||||
|
||||
1. **Delegate to validation.ChainLeft** for error handling logic
|
||||
2. **Aggregate errors** when transformations fail
|
||||
3. **Preserve successes** unchanged
|
||||
4. **Enable recovery** from decode failures
|
||||
5. **Provide access** to the original input
|
||||
|
||||
The choice between them is purely about **code readability and semantic intent**:
|
||||
- Use **`OrElse`** when emphasizing fallback/alternative decoding
|
||||
- Use **`ChainLeft`** when emphasizing error transformation
|
||||
|
||||
Both maintain the critical property of **error aggregation**, ensuring all validation failures are preserved and reported together.
|
||||
@@ -50,6 +50,133 @@ func Chain[I, A, B any](f Kleisli[I, A, B]) Operator[I, A, B] {
|
||||
)
|
||||
}
|
||||
|
||||
// ChainLeft transforms the error channel of a decoder, enabling error recovery and context addition.
|
||||
// This is the left-biased monadic chain operation that operates on validation failures.
|
||||
//
|
||||
// **Key behaviors**:
|
||||
// - Success values pass through unchanged - the handler is never called
|
||||
// - On failure, the handler receives the errors and can recover or add context
|
||||
// - When the handler also fails, **both original and new errors are aggregated**
|
||||
// - The handler returns a Decode[I, A], giving it access to the original input
|
||||
//
|
||||
// **Error Aggregation**: Unlike standard Either operations, when the transformation function
|
||||
// returns a failure, both the original errors AND the new errors are combined using the
|
||||
// Errors monoid. This ensures no validation errors are lost.
|
||||
//
|
||||
// Use cases:
|
||||
// - Adding contextual information to validation errors
|
||||
// - Recovering from specific error conditions
|
||||
// - Transforming error messages while preserving original errors
|
||||
// - Implementing conditional recovery based on error types
|
||||
//
|
||||
// Example - Error recovery:
|
||||
//
|
||||
// failingDecoder := func(input string) Validation[int] {
|
||||
// return either.Left[int](validation.Errors{
|
||||
// {Value: input, Messsage: "not found"},
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// recoverFromNotFound := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
// for _, err := range errs {
|
||||
// if err.Messsage == "not found" {
|
||||
// return Of[string](0) // recover with default
|
||||
// }
|
||||
// }
|
||||
// return func(input string) Validation[int] {
|
||||
// return either.Left[int](errs)
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// decoder := recoverFromNotFound(failingDecoder)
|
||||
// result := decoder("input") // Success(0) - recovered from failure
|
||||
//
|
||||
// Example - Adding context:
|
||||
//
|
||||
// addContext := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
// return func(input string) Validation[int] {
|
||||
// return either.Left[int](validation.Errors{
|
||||
// {
|
||||
// Context: validation.Context{{Key: "user", Type: "User"}, {Key: "age", Type: "int"}},
|
||||
// Messsage: "failed to decode user age",
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// // Result will contain BOTH original error and context error
|
||||
func ChainLeft[I, A any](f Kleisli[I, Errors, A]) Operator[I, A, A] {
|
||||
return readert.Chain[Decode[I, A]](
|
||||
validation.ChainLeft,
|
||||
f,
|
||||
)
|
||||
}
|
||||
|
||||
// OrElse provides fallback decoding logic when the primary decoder fails.
|
||||
// This is an alias for ChainLeft with a more semantic name for fallback scenarios.
|
||||
//
|
||||
// **OrElse is exactly the same as ChainLeft** - they are aliases with identical implementations
|
||||
// and behavior. The choice between them is purely about code readability and semantic intent:
|
||||
// - Use **OrElse** when emphasizing fallback/alternative decoding logic
|
||||
// - Use **ChainLeft** when emphasizing technical error channel transformation
|
||||
//
|
||||
// **Key behaviors** (identical to ChainLeft):
|
||||
// - Success values pass through unchanged - the handler is never called
|
||||
// - On failure, the handler receives the errors and can provide an alternative
|
||||
// - When the handler also fails, **both original and new errors are aggregated**
|
||||
// - The handler returns a Decode[I, A], giving it access to the original input
|
||||
//
|
||||
// The name "OrElse" reads naturally in code: "try this decoder, or else try this alternative."
|
||||
// This makes it ideal for expressing fallback logic and default values.
|
||||
//
|
||||
// Use cases:
|
||||
// - Providing default values when decoding fails
|
||||
// - Trying alternative decoding strategies
|
||||
// - Implementing fallback chains with multiple alternatives
|
||||
// - Input-dependent recovery (using access to original input)
|
||||
//
|
||||
// Example - Simple fallback:
|
||||
//
|
||||
// primaryDecoder := func(input string) Validation[int] {
|
||||
// n, err := strconv.Atoi(input)
|
||||
// if err != nil {
|
||||
// return either.Left[int](validation.Errors{
|
||||
// {Value: input, Messsage: "not a valid integer"},
|
||||
// })
|
||||
// }
|
||||
// return validation.Of(n)
|
||||
// }
|
||||
//
|
||||
// withDefault := OrElse(func(errs Errors) Decode[string, int] {
|
||||
// return Of[string](0) // default to 0 if decoding fails
|
||||
// })
|
||||
//
|
||||
// decoder := withDefault(primaryDecoder)
|
||||
// result1 := decoder("42") // Success(42)
|
||||
// result2 := decoder("abc") // Success(0) - fallback
|
||||
//
|
||||
// Example - Input-dependent fallback:
|
||||
//
|
||||
// smartDefault := OrElse(func(errs Errors) Decode[string, int] {
|
||||
// return func(input string) Validation[int] {
|
||||
// // Access original input to determine appropriate default
|
||||
// if strings.Contains(input, "http") {
|
||||
// return validation.Of(80)
|
||||
// }
|
||||
// if strings.Contains(input, "https") {
|
||||
// return validation.Of(443)
|
||||
// }
|
||||
// return validation.Of(8080)
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// decoder := smartDefault(decodePort)
|
||||
// result1 := decoder("http-server") // Success(80)
|
||||
// result2 := decoder("https-server") // Success(443)
|
||||
// result3 := decoder("other") // Success(8080)
|
||||
func OrElse[I, A any](f Kleisli[I, Errors, A]) Operator[I, A, A] {
|
||||
return ChainLeft(f)
|
||||
}
|
||||
|
||||
// MonadMap transforms the decoded value using the provided function.
|
||||
// This is the functor map operation that applies a transformation to successful decode results.
|
||||
//
|
||||
|
||||
@@ -382,3 +382,400 @@ func TestFunctorLaws(t *testing.T) {
|
||||
assert.Equal(t, right(input), left(input))
|
||||
})
|
||||
}
|
||||
|
||||
// TestChainLeft tests the ChainLeft function
|
||||
func TestChainLeft(t *testing.T) {
|
||||
t.Run("transforms failures while preserving successes", func(t *testing.T) {
|
||||
// Create a failing decoder
|
||||
failingDecoder := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "decode failed"},
|
||||
})
|
||||
}
|
||||
|
||||
// Handler that recovers from specific errors
|
||||
handler := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
for _, err := range errs {
|
||||
if err.Messsage == "decode failed" {
|
||||
return Of[string](0) // recover with default
|
||||
}
|
||||
}
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](errs)
|
||||
}
|
||||
})
|
||||
|
||||
decoder := handler(failingDecoder)
|
||||
res := decoder("input")
|
||||
|
||||
assert.Equal(t, validation.Of(0), res, "Should recover from failure")
|
||||
})
|
||||
|
||||
t.Run("preserves success values unchanged", func(t *testing.T) {
|
||||
successDecoder := Of[string](42)
|
||||
|
||||
handler := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "should not be called"},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
decoder := handler(successDecoder)
|
||||
res := decoder("input")
|
||||
|
||||
assert.Equal(t, validation.Of(42), res, "Success should pass through unchanged")
|
||||
})
|
||||
|
||||
t.Run("aggregates errors when transformation also fails", func(t *testing.T) {
|
||||
failingDecoder := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "original error"},
|
||||
})
|
||||
}
|
||||
|
||||
handler := ChainLeft(func(errs Errors) Decode[string, string] {
|
||||
return func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Messsage: "additional error"},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
decoder := handler(failingDecoder)
|
||||
res := decoder("input")
|
||||
|
||||
assert.True(t, either.IsLeft(res))
|
||||
errors := either.MonadFold(res,
|
||||
func(e Errors) Errors { return e },
|
||||
func(string) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 2, "Should aggregate both errors")
|
||||
|
||||
messages := make([]string, len(errors))
|
||||
for i, err := range errors {
|
||||
messages[i] = err.Messsage
|
||||
}
|
||||
assert.Contains(t, messages, "original error")
|
||||
assert.Contains(t, messages, "additional error")
|
||||
})
|
||||
|
||||
t.Run("adds context to errors", func(t *testing.T) {
|
||||
failingDecoder := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "invalid format"},
|
||||
})
|
||||
}
|
||||
|
||||
addContext := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{
|
||||
Context: validation.Context{{Key: "user", Type: "User"}, {Key: "age", Type: "int"}},
|
||||
Messsage: "failed to decode user age",
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
decoder := addContext(failingDecoder)
|
||||
res := decoder("abc")
|
||||
|
||||
assert.True(t, either.IsLeft(res))
|
||||
errors := either.MonadFold(res,
|
||||
func(e Errors) Errors { return e },
|
||||
func(int) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 2, "Should have both original and context errors")
|
||||
})
|
||||
|
||||
t.Run("can be composed in pipeline", func(t *testing.T) {
|
||||
failingDecoder := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "error1"},
|
||||
})
|
||||
}
|
||||
|
||||
handler1 := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "error2"},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
handler2 := ChainLeft(func(errs Errors) Decode[string, int] {
|
||||
// Check if we can recover
|
||||
for _, err := range errs {
|
||||
if err.Messsage == "error1" {
|
||||
return Of[string](100) // recover
|
||||
}
|
||||
}
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](errs)
|
||||
}
|
||||
})
|
||||
|
||||
// Compose handlers
|
||||
decoder := handler2(handler1(failingDecoder))
|
||||
res := decoder("input")
|
||||
|
||||
// Should recover because error1 is present
|
||||
assert.Equal(t, validation.Of(100), res)
|
||||
})
|
||||
|
||||
t.Run("works with different input types", func(t *testing.T) {
|
||||
type Config struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
failingDecoder := func(cfg Config) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: cfg.Port, Messsage: "invalid port"},
|
||||
})
|
||||
}
|
||||
|
||||
handler := ChainLeft(func(errs Errors) Decode[Config, string] {
|
||||
return Of[Config]("default-value")
|
||||
})
|
||||
|
||||
decoder := handler(failingDecoder)
|
||||
res := decoder(Config{Port: 9999})
|
||||
|
||||
assert.Equal(t, validation.Of("default-value"), res)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrElse tests the OrElse function
|
||||
func TestOrElse(t *testing.T) {
|
||||
t.Run("OrElse is equivalent to ChainLeft - Success case", func(t *testing.T) {
|
||||
successDecoder := Of[string](42)
|
||||
|
||||
handler := func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "should not be called"},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test with OrElse
|
||||
resultOrElse := OrElse(handler)(successDecoder)("input")
|
||||
// Test with ChainLeft
|
||||
resultChainLeft := ChainLeft(handler)(successDecoder)("input")
|
||||
|
||||
assert.Equal(t, resultChainLeft, resultOrElse, "OrElse and ChainLeft should produce identical results for Success")
|
||||
assert.Equal(t, validation.Of(42), resultOrElse)
|
||||
})
|
||||
|
||||
t.Run("OrElse is equivalent to ChainLeft - Failure recovery", func(t *testing.T) {
|
||||
failingDecoder := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "not found"},
|
||||
})
|
||||
}
|
||||
|
||||
handler := func(errs Errors) Decode[string, int] {
|
||||
for _, err := range errs {
|
||||
if err.Messsage == "not found" {
|
||||
return Of[string](0) // recover with default
|
||||
}
|
||||
}
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](errs)
|
||||
}
|
||||
}
|
||||
|
||||
// Test with OrElse
|
||||
resultOrElse := OrElse(handler)(failingDecoder)("input")
|
||||
// Test with ChainLeft
|
||||
resultChainLeft := ChainLeft(handler)(failingDecoder)("input")
|
||||
|
||||
assert.Equal(t, resultChainLeft, resultOrElse, "OrElse and ChainLeft should produce identical results for recovery")
|
||||
assert.Equal(t, validation.Of(0), resultOrElse)
|
||||
})
|
||||
|
||||
t.Run("OrElse is equivalent to ChainLeft - Error aggregation", func(t *testing.T) {
|
||||
failingDecoder := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "original error"},
|
||||
})
|
||||
}
|
||||
|
||||
handler := func(errs Errors) Decode[string, string] {
|
||||
return func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Messsage: "additional error"},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test with OrElse
|
||||
resultOrElse := OrElse(handler)(failingDecoder)("input")
|
||||
// Test with ChainLeft
|
||||
resultChainLeft := ChainLeft(handler)(failingDecoder)("input")
|
||||
|
||||
assert.Equal(t, resultChainLeft, resultOrElse, "OrElse and ChainLeft should produce identical results for error aggregation")
|
||||
|
||||
// Verify both aggregate errors
|
||||
assert.True(t, either.IsLeft(resultOrElse))
|
||||
errors := either.MonadFold(resultOrElse,
|
||||
func(e Errors) Errors { return e },
|
||||
func(string) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 2, "Should aggregate both errors")
|
||||
messages := make([]string, len(errors))
|
||||
for i, err := range errors {
|
||||
messages[i] = err.Messsage
|
||||
}
|
||||
assert.Contains(t, messages, "original error")
|
||||
assert.Contains(t, messages, "additional error")
|
||||
})
|
||||
|
||||
t.Run("OrElse semantic meaning - fallback decoder", func(t *testing.T) {
|
||||
// OrElse provides a semantic name for fallback/alternative decoding
|
||||
// It reads naturally: "try this decoder, or else try this alternative"
|
||||
|
||||
primaryDecoder := func(input string) Validation[int] {
|
||||
if input == "valid" {
|
||||
return validation.Of(42)
|
||||
}
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "primary decode failed"},
|
||||
})
|
||||
}
|
||||
|
||||
// Use OrElse to provide a fallback: if decoding fails, use default value
|
||||
withDefault := OrElse(func(errs Errors) Decode[string, int] {
|
||||
return Of[string](0) // default to 0 if decoding fails
|
||||
})
|
||||
|
||||
decoder := withDefault(primaryDecoder)
|
||||
|
||||
// Test success case
|
||||
resSuccess := decoder("valid")
|
||||
assert.Equal(t, validation.Of(42), resSuccess, "Should use primary decoder on success")
|
||||
|
||||
// Test fallback case
|
||||
resFallback := decoder("invalid")
|
||||
assert.Equal(t, validation.Of(0), resFallback, "OrElse provides fallback value")
|
||||
})
|
||||
|
||||
t.Run("OrElse in pipeline composition", func(t *testing.T) {
|
||||
failingDecoder := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "database error"},
|
||||
})
|
||||
}
|
||||
|
||||
addContext := OrElse(func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "context added"},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
recoverFromNotFound := OrElse(func(errs Errors) Decode[string, int] {
|
||||
for _, err := range errs {
|
||||
if err.Messsage == "not found" {
|
||||
return Of[string](0)
|
||||
}
|
||||
}
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](errs)
|
||||
}
|
||||
})
|
||||
|
||||
// Test error aggregation in pipeline
|
||||
decoder1 := recoverFromNotFound(addContext(failingDecoder))
|
||||
res1 := decoder1("input")
|
||||
|
||||
assert.True(t, either.IsLeft(res1))
|
||||
errors := either.MonadFold(res1,
|
||||
func(e Errors) Errors { return e },
|
||||
func(int) Errors { return nil },
|
||||
)
|
||||
// Errors accumulate through the pipeline
|
||||
assert.Greater(t, len(errors), 1, "Should aggregate errors from pipeline")
|
||||
|
||||
// Test recovery in pipeline
|
||||
failingDecoder2 := func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "not found"},
|
||||
})
|
||||
}
|
||||
|
||||
decoder2 := recoverFromNotFound(addContext(failingDecoder2))
|
||||
res2 := decoder2("input")
|
||||
|
||||
assert.Equal(t, validation.Of(0), res2, "Should recover from 'not found' error")
|
||||
})
|
||||
|
||||
t.Run("OrElse vs ChainLeft - identical behavior verification", func(t *testing.T) {
|
||||
// Create various test scenarios
|
||||
scenarios := []struct {
|
||||
name string
|
||||
decoder Decode[string, int]
|
||||
handler func(Errors) Decode[string, int]
|
||||
}{
|
||||
{
|
||||
name: "Success value",
|
||||
decoder: Of[string](42),
|
||||
handler: func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{{Messsage: "error"}})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Failure with recovery",
|
||||
decoder: func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{{Messsage: "error"}})
|
||||
},
|
||||
handler: func(errs Errors) Decode[string, int] {
|
||||
return Of[string](0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Failure with error transformation",
|
||||
decoder: func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{{Messsage: "error1"}})
|
||||
},
|
||||
handler: func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{{Messsage: "error2"}})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple errors aggregation",
|
||||
decoder: func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "error1"},
|
||||
{Messsage: "error2"},
|
||||
})
|
||||
},
|
||||
handler: func(errs Errors) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Messsage: "error3"},
|
||||
{Messsage: "error4"},
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
resultOrElse := OrElse(scenario.handler)(scenario.decoder)("test-input")
|
||||
resultChainLeft := ChainLeft(scenario.handler)(scenario.decoder)("test-input")
|
||||
|
||||
assert.Equal(t, resultChainLeft, resultOrElse,
|
||||
"OrElse and ChainLeft must produce identical results for: %s", scenario.name)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
96
v2/optics/codec/decode/monoid.go
Normal file
96
v2/optics/codec/decode/monoid.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package decode
|
||||
|
||||
import "github.com/IBM/fp-go/v2/monoid"
|
||||
|
||||
// ApplicativeMonoid creates a Monoid instance for Decode[I, A] given a Monoid for A.
|
||||
// This allows combining decoders where both the decoded values and validation errors
|
||||
// are combined according to their respective monoid operations.
|
||||
//
|
||||
// The resulting monoid enables:
|
||||
// - Combining multiple decoders that produce monoidal values
|
||||
// - Accumulating validation errors when any decoder fails
|
||||
// - Building complex decoders from simpler ones through composition
|
||||
//
|
||||
// **Behavior**:
|
||||
// - Empty: Returns a decoder that always succeeds with the empty value from the inner monoid
|
||||
// - Concat: Combines two decoders:
|
||||
// - Both succeed: Combines decoded values using the inner monoid
|
||||
// - Any fails: Accumulates all validation errors using the Errors monoid
|
||||
//
|
||||
// This is particularly useful for:
|
||||
// - Aggregating results from multiple independent decoders
|
||||
// - Building decoders that combine partial results
|
||||
// - Validating and combining configuration from multiple sources
|
||||
// - Parallel validation with result accumulation
|
||||
//
|
||||
// Example - Combining string decoders:
|
||||
//
|
||||
// import S "github.com/IBM/fp-go/v2/string"
|
||||
//
|
||||
// // Create a monoid for decoders that produce strings
|
||||
// m := ApplicativeMonoid[map[string]any](S.Monoid)
|
||||
//
|
||||
// decoder1 := func(data map[string]any) Validation[string] {
|
||||
// if name, ok := data["firstName"].(string); ok {
|
||||
// return validation.Of(name)
|
||||
// }
|
||||
// return either.Left[string](validation.Errors{
|
||||
// {Messsage: "missing firstName"},
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// decoder2 := func(data map[string]any) Validation[string] {
|
||||
// if name, ok := data["lastName"].(string); ok {
|
||||
// return validation.Of(" " + name)
|
||||
// }
|
||||
// return either.Left[string](validation.Errors{
|
||||
// {Messsage: "missing lastName"},
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// // Combine decoders - will concatenate strings if both succeed
|
||||
// combined := m.Concat(decoder1, decoder2)
|
||||
// result := combined(map[string]any{
|
||||
// "firstName": "John",
|
||||
// "lastName": "Doe",
|
||||
// }) // Success("John Doe")
|
||||
//
|
||||
// Example - Error accumulation:
|
||||
//
|
||||
// // If any decoder fails, errors are accumulated
|
||||
// result := combined(map[string]any{}) // Failures with both error messages
|
||||
//
|
||||
// Example - Numeric aggregation:
|
||||
//
|
||||
// import N "github.com/IBM/fp-go/v2/number"
|
||||
//
|
||||
// intMonoid := monoid.MakeMonoid(N.Add[int], 0)
|
||||
// m := ApplicativeMonoid[string](intMonoid)
|
||||
//
|
||||
// decoder1 := func(input string) Validation[int] {
|
||||
// return validation.Of(10)
|
||||
// }
|
||||
// decoder2 := func(input string) Validation[int] {
|
||||
// return validation.Of(32)
|
||||
// }
|
||||
//
|
||||
// combined := m.Concat(decoder1, decoder2)
|
||||
// result := combined("input") // Success(42) - values are added
|
||||
func ApplicativeMonoid[I, A any](m Monoid[A]) Monoid[Decode[I, A]] {
|
||||
return monoid.ApplicativeMonoid(
|
||||
Of[I, A],
|
||||
MonadMap[I, A, Endomorphism[A]],
|
||||
MonadAp[A, I, A],
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
// func AlternativeMonoid[I, A any](m Monoid[A]) Monoid[Decode[I, A]] {
|
||||
// return monoid.AlternativeMonoid(
|
||||
// Of[I, A],
|
||||
// MonadMap[I, A, func(A) A],
|
||||
// MonadAp[A, I, A],
|
||||
// MonadAlt[I, A],
|
||||
// m,
|
||||
// )
|
||||
// }
|
||||
474
v2/optics/codec/decode/monoid_test.go
Normal file
474
v2/optics/codec/decode/monoid_test.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package decode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
MO "github.com/IBM/fp-go/v2/monoid"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validation"
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplicativeMonoid(t *testing.T) {
|
||||
t.Run("with string monoid", func(t *testing.T) {
|
||||
m := ApplicativeMonoid[string](S.Monoid)
|
||||
|
||||
t.Run("empty returns decoder that succeeds with empty string", func(t *testing.T) {
|
||||
empty := m.Empty()
|
||||
result := empty("any input")
|
||||
|
||||
assert.Equal(t, validation.Of(""), result)
|
||||
})
|
||||
|
||||
t.Run("concat combines successful decoders", func(t *testing.T) {
|
||||
decoder1 := Of[string]("Hello")
|
||||
decoder2 := Of[string](" World")
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
result := combined("input")
|
||||
|
||||
assert.Equal(t, validation.Of("Hello World"), result)
|
||||
})
|
||||
|
||||
t.Run("concat with failure returns failure", func(t *testing.T) {
|
||||
decoder1 := Of[string]("Hello")
|
||||
decoder2 := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "decode failed"},
|
||||
})
|
||||
}
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
result := combined("input")
|
||||
|
||||
assert.True(t, either.IsLeft(result))
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[Errors],
|
||||
func(string) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 1)
|
||||
assert.Equal(t, "decode failed", errors[0].Messsage)
|
||||
})
|
||||
|
||||
t.Run("concat accumulates all errors from both failures", func(t *testing.T) {
|
||||
decoder1 := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "error 1"},
|
||||
})
|
||||
}
|
||||
decoder2 := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "error 2"},
|
||||
})
|
||||
}
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
result := combined("input")
|
||||
|
||||
assert.True(t, either.IsLeft(result))
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[Errors],
|
||||
func(string) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 2)
|
||||
messages := []string{errors[0].Messsage, errors[1].Messsage}
|
||||
assert.Contains(t, messages, "error 1")
|
||||
assert.Contains(t, messages, "error 2")
|
||||
})
|
||||
|
||||
t.Run("concat with empty preserves decoder", func(t *testing.T) {
|
||||
decoder := Of[string]("test")
|
||||
empty := m.Empty()
|
||||
|
||||
result1 := m.Concat(decoder, empty)("input")
|
||||
result2 := m.Concat(empty, decoder)("input")
|
||||
|
||||
val1 := either.MonadFold(result1,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
val2 := either.MonadFold(result2,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
|
||||
assert.Equal(t, "test", val1)
|
||||
assert.Equal(t, "test", val2)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with int addition monoid", func(t *testing.T) {
|
||||
intMonoid := MO.MakeMonoid(
|
||||
func(a, b int) int { return a + b },
|
||||
0,
|
||||
)
|
||||
m := ApplicativeMonoid[string](intMonoid)
|
||||
|
||||
t.Run("empty returns decoder with zero", func(t *testing.T) {
|
||||
empty := m.Empty()
|
||||
result := empty("input")
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return -1 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 0, value)
|
||||
})
|
||||
|
||||
t.Run("concat adds decoded values", func(t *testing.T) {
|
||||
decoder1 := Of[string](10)
|
||||
decoder2 := Of[string](32)
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
result := combined("input")
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 42, value)
|
||||
})
|
||||
|
||||
t.Run("multiple concat operations", func(t *testing.T) {
|
||||
decoder1 := Of[string](1)
|
||||
decoder2 := Of[string](2)
|
||||
decoder3 := Of[string](3)
|
||||
decoder4 := Of[string](4)
|
||||
|
||||
combined := m.Concat(m.Concat(m.Concat(decoder1, decoder2), decoder3), decoder4)
|
||||
result := combined("input")
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 10, value)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with map input type", func(t *testing.T) {
|
||||
m := ApplicativeMonoid[map[string]any](S.Monoid)
|
||||
|
||||
t.Run("combines decoders with different inputs", func(t *testing.T) {
|
||||
decoder1 := func(data map[string]any) Validation[string] {
|
||||
if name, ok := data["firstName"].(string); ok {
|
||||
return validation.Of(name)
|
||||
}
|
||||
return either.Left[string](validation.Errors{
|
||||
{Messsage: "missing firstName"},
|
||||
})
|
||||
}
|
||||
|
||||
decoder2 := func(data map[string]any) Validation[string] {
|
||||
if name, ok := data["lastName"].(string); ok {
|
||||
return validation.Of(" " + name)
|
||||
}
|
||||
return either.Left[string](validation.Errors{
|
||||
{Messsage: "missing lastName"},
|
||||
})
|
||||
}
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
|
||||
// Test success case
|
||||
result1 := combined(map[string]any{
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
})
|
||||
value1 := either.MonadFold(result1,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "John Doe", value1)
|
||||
|
||||
// Test failure case - both fields missing
|
||||
result2 := combined(map[string]any{})
|
||||
assert.True(t, either.IsLeft(result2))
|
||||
errors := either.MonadFold(result2,
|
||||
F.Identity[Errors],
|
||||
func(string) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 2)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestMonoidLaws(t *testing.T) {
|
||||
t.Run("ApplicativeMonoid satisfies monoid laws", func(t *testing.T) {
|
||||
m := ApplicativeMonoid[string](S.Monoid)
|
||||
|
||||
decoder1 := Of[string]("a")
|
||||
decoder2 := Of[string]("b")
|
||||
|
||||
t.Run("left identity", func(t *testing.T) {
|
||||
// empty + a = a
|
||||
result := m.Concat(m.Empty(), decoder1)("input")
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "a", value)
|
||||
})
|
||||
|
||||
t.Run("right identity", func(t *testing.T) {
|
||||
// a + empty = a
|
||||
result := m.Concat(decoder1, m.Empty())("input")
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "a", value)
|
||||
})
|
||||
|
||||
t.Run("associativity", func(t *testing.T) {
|
||||
decoder3 := Of[string]("c")
|
||||
// (a + b) + c = a + (b + c)
|
||||
left := m.Concat(m.Concat(decoder1, decoder2), decoder3)("input")
|
||||
right := m.Concat(decoder1, m.Concat(decoder2, decoder3))("input")
|
||||
|
||||
leftVal := either.MonadFold(left,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
rightVal := either.MonadFold(right,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
|
||||
assert.Equal(t, "abc", leftVal)
|
||||
assert.Equal(t, "abc", rightVal)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplicativeMonoidWithFailures(t *testing.T) {
|
||||
m := ApplicativeMonoid[string](S.Monoid)
|
||||
|
||||
t.Run("failure propagates through concat", func(t *testing.T) {
|
||||
decoder1 := Of[string]("a")
|
||||
decoder2 := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "error"},
|
||||
})
|
||||
}
|
||||
decoder3 := Of[string]("c")
|
||||
|
||||
combined := m.Concat(m.Concat(decoder1, decoder2), decoder3)
|
||||
result := combined("input")
|
||||
|
||||
assert.True(t, either.IsLeft(result))
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[Errors],
|
||||
func(string) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 1)
|
||||
})
|
||||
|
||||
t.Run("multiple failures accumulate", func(t *testing.T) {
|
||||
decoder1 := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "error 1"},
|
||||
})
|
||||
}
|
||||
decoder2 := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "error 2"},
|
||||
})
|
||||
}
|
||||
decoder3 := func(input string) Validation[string] {
|
||||
return either.Left[string](validation.Errors{
|
||||
{Value: input, Messsage: "error 3"},
|
||||
})
|
||||
}
|
||||
|
||||
combined := m.Concat(m.Concat(decoder1, decoder2), decoder3)
|
||||
result := combined("input")
|
||||
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[Errors],
|
||||
func(string) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 3)
|
||||
messages := make([]string, len(errors))
|
||||
for i, err := range errors {
|
||||
messages[i] = err.Messsage
|
||||
}
|
||||
assert.Contains(t, messages, "error 1")
|
||||
assert.Contains(t, messages, "error 2")
|
||||
assert.Contains(t, messages, "error 3")
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplicativeMonoidEdgeCases(t *testing.T) {
|
||||
t.Run("with custom struct monoid", func(t *testing.T) {
|
||||
type Counter struct{ Count int }
|
||||
|
||||
counterMonoid := MO.MakeMonoid(
|
||||
func(a, b Counter) Counter { return Counter{Count: a.Count + b.Count} },
|
||||
Counter{Count: 0},
|
||||
)
|
||||
|
||||
m := ApplicativeMonoid[string](counterMonoid)
|
||||
|
||||
decoder1 := Of[string](Counter{Count: 5})
|
||||
decoder2 := Of[string](Counter{Count: 10})
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
result := combined("input")
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Counter { return Counter{} },
|
||||
F.Identity[Counter],
|
||||
)
|
||||
assert.Equal(t, 15, value.Count)
|
||||
})
|
||||
|
||||
t.Run("empty concat empty", func(t *testing.T) {
|
||||
m := ApplicativeMonoid[string](S.Monoid)
|
||||
|
||||
combined := m.Concat(m.Empty(), m.Empty())
|
||||
result := combined("input")
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) string { return "ERROR" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "", value)
|
||||
})
|
||||
|
||||
t.Run("with different input types", func(t *testing.T) {
|
||||
intMonoid := MO.MakeMonoid(
|
||||
func(a, b int) int { return a + b },
|
||||
0,
|
||||
)
|
||||
m := ApplicativeMonoid[int](intMonoid)
|
||||
|
||||
decoder1 := func(input int) Validation[int] {
|
||||
return validation.Of(input * 2)
|
||||
}
|
||||
decoder2 := func(input int) Validation[int] {
|
||||
return validation.Of(input + 10)
|
||||
}
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
result := combined(5)
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
// (5 * 2) + (5 + 10) = 10 + 15 = 25
|
||||
assert.Equal(t, 25, value)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplicativeMonoidRealWorldScenarios(t *testing.T) {
|
||||
t.Run("combining configuration from multiple sources", func(t *testing.T) {
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
// Monoid that combines configs (last non-empty wins for strings, sum for ints)
|
||||
configMonoid := MO.MakeMonoid(
|
||||
func(a, b Config) Config {
|
||||
host := a.Host
|
||||
if b.Host != "" {
|
||||
host = b.Host
|
||||
}
|
||||
return Config{
|
||||
Host: host,
|
||||
Port: a.Port + b.Port,
|
||||
}
|
||||
},
|
||||
Config{Host: "", Port: 0},
|
||||
)
|
||||
|
||||
m := ApplicativeMonoid[map[string]any](configMonoid)
|
||||
|
||||
decoder1 := func(data map[string]any) Validation[Config] {
|
||||
if host, ok := data["host"].(string); ok {
|
||||
return validation.Of(Config{Host: host, Port: 0})
|
||||
}
|
||||
return either.Left[Config](validation.Errors{
|
||||
{Messsage: "missing host"},
|
||||
})
|
||||
}
|
||||
|
||||
decoder2 := func(data map[string]any) Validation[Config] {
|
||||
if port, ok := data["port"].(int); ok {
|
||||
return validation.Of(Config{Host: "", Port: port})
|
||||
}
|
||||
return either.Left[Config](validation.Errors{
|
||||
{Messsage: "missing port"},
|
||||
})
|
||||
}
|
||||
|
||||
combined := m.Concat(decoder1, decoder2)
|
||||
|
||||
// Success case
|
||||
result := combined(map[string]any{
|
||||
"host": "localhost",
|
||||
"port": 8080,
|
||||
})
|
||||
|
||||
config := either.MonadFold(result,
|
||||
func(Errors) Config { return Config{} },
|
||||
F.Identity[Config],
|
||||
)
|
||||
assert.Equal(t, "localhost", config.Host)
|
||||
assert.Equal(t, 8080, config.Port)
|
||||
})
|
||||
|
||||
t.Run("aggregating validation results", func(t *testing.T) {
|
||||
intMonoid := MO.MakeMonoid(
|
||||
func(a, b int) int { return a + b },
|
||||
0,
|
||||
)
|
||||
m := ApplicativeMonoid[string](intMonoid)
|
||||
|
||||
// Decoder that extracts and validates a number
|
||||
makeDecoder := func(value int, shouldFail bool) Decode[string, int] {
|
||||
return func(input string) Validation[int] {
|
||||
if shouldFail {
|
||||
return either.Left[int](validation.Errors{
|
||||
{Value: input, Messsage: "validation failed"},
|
||||
})
|
||||
}
|
||||
return validation.Of(value)
|
||||
}
|
||||
}
|
||||
|
||||
// All succeed - values are summed
|
||||
decoder1 := makeDecoder(10, false)
|
||||
decoder2 := makeDecoder(20, false)
|
||||
decoder3 := makeDecoder(12, false)
|
||||
|
||||
combined := m.Concat(m.Concat(decoder1, decoder2), decoder3)
|
||||
result := combined("input")
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 42, value)
|
||||
|
||||
// Some fail - errors are accumulated
|
||||
decoder4 := makeDecoder(10, true)
|
||||
decoder5 := makeDecoder(20, true)
|
||||
|
||||
combinedFail := m.Concat(decoder4, decoder5)
|
||||
resultFail := combinedFail("input")
|
||||
|
||||
assert.True(t, either.IsLeft(resultFail))
|
||||
errors := either.MonadFold(resultFail,
|
||||
F.Identity[Errors],
|
||||
func(int) Errors { return nil },
|
||||
)
|
||||
assert.Len(t, errors, 2)
|
||||
})
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
@@ -17,11 +17,15 @@ package decode
|
||||
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/lazy"
|
||||
"github.com/IBM/fp-go/v2/monoid"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validation"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
)
|
||||
|
||||
type (
|
||||
Errors = validation.Errors
|
||||
|
||||
// Validation represents the result of a validation operation that may contain
|
||||
// validation errors or a successfully validated value of type A.
|
||||
// This is an alias for validation.Validation[A], which is Either[Errors, A].
|
||||
@@ -217,4 +221,8 @@ type (
|
||||
// LetL(nameLens, normalize),
|
||||
// )
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
|
||||
Monoid[A any] = monoid.Monoid[A]
|
||||
|
||||
Lazy[A any] = lazy.Lazy[A]
|
||||
)
|
||||
|
||||
282
v2/optics/codec/either.go
Normal file
282
v2/optics/codec/either.go
Normal file
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package codec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/IBM/fp-go/v2/array"
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validate"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validation"
|
||||
)
|
||||
|
||||
// encodeEither creates an encoder for Either[A, B] values.
|
||||
//
|
||||
// This function produces an encoder that handles both Left and Right cases of an Either value.
|
||||
// It uses the provided codecs to encode the Left (A) and Right (B) values respectively.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - A: The type of the Left value
|
||||
// - B: The type of the Right value
|
||||
// - O: The output type after encoding
|
||||
// - I: The input type for validation (not used in encoding)
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - leftItem: The codec for encoding Left values of type A
|
||||
// - rightItem: The codec for encoding Right values of type B
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// An Encode function that takes an Either[A, B] and returns O by encoding
|
||||
// either the Left or Right value using the appropriate codec.
|
||||
//
|
||||
// # Example
|
||||
//
|
||||
// stringCodec := String()
|
||||
// intCodec := Int()
|
||||
// encoder := encodeEither(stringCodec, intCodec)
|
||||
//
|
||||
// // Encode a Left value
|
||||
// leftResult := encoder(either.Left[int]("error"))
|
||||
// // leftResult contains the encoded string "error"
|
||||
//
|
||||
// // Encode a Right value
|
||||
// rightResult := encoder(either.Right[string](42))
|
||||
// // rightResult contains the encoded int 42
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - Uses either.Fold to pattern match on the Either value
|
||||
// - Left values are encoded using leftItem.Encode
|
||||
// - Right values are encoded using rightItem.Encode
|
||||
func encodeEither[A, B, O, I any](
|
||||
leftItem Type[A, O, I],
|
||||
rightItem Type[B, O, I],
|
||||
) Encode[either.Either[A, B], O] {
|
||||
return either.Fold(
|
||||
leftItem.Encode,
|
||||
rightItem.Encode,
|
||||
)
|
||||
}
|
||||
|
||||
// validateEither creates a validator for Either[A, B] values.
|
||||
//
|
||||
// This function produces a validator that attempts to validate the input as both
|
||||
// a Left (A) and Right (B) value. The validation strategy is:
|
||||
// 1. First, try to validate as a Right value (B)
|
||||
// 2. If Right validation succeeds, return Either.Right[A](B)
|
||||
// 3. If Right validation fails, try to validate as a Left value (A)
|
||||
// 4. If Left validation succeeds, return Either.Left[B](A)
|
||||
// 5. If both validations fail, concatenate all errors from both attempts
|
||||
//
|
||||
// This approach ensures that the validator tries both branches and provides
|
||||
// comprehensive error information when both fail.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - A: The type of the Left value
|
||||
// - B: The type of the Right value
|
||||
// - O: The output type after encoding (not used in validation)
|
||||
// - I: The input type to validate
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - leftItem: The codec for validating Left values of type A
|
||||
// - rightItem: The codec for validating Right values of type B
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// A Validate function that takes an input I and returns a Decode function.
|
||||
// The Decode function takes a Context and returns a Validation[Either[A, B]].
|
||||
//
|
||||
// # Validation Logic
|
||||
//
|
||||
// The validator follows this decision tree:
|
||||
//
|
||||
// Input I
|
||||
// |
|
||||
// +--> Validate as Right (B)
|
||||
// |
|
||||
// +-- Success --> Return Either.Right[A](B)
|
||||
// |
|
||||
// +-- Failure --> Validate as Left (A)
|
||||
// |
|
||||
// +-- Success --> Return Either.Left[B](A)
|
||||
// |
|
||||
// +-- Failure --> Return all errors (Left + Right)
|
||||
//
|
||||
// # Example
|
||||
//
|
||||
// stringCodec := String()
|
||||
// intCodec := Int()
|
||||
// validator := validateEither(stringCodec, intCodec)
|
||||
//
|
||||
// // Validate a string (will succeed as Left)
|
||||
// result1 := validator("hello")(validation.Context{})
|
||||
// // result1 is Success(Either.Left[int]("hello"))
|
||||
//
|
||||
// // Validate an int (will succeed as Right)
|
||||
// result2 := validator(42)(validation.Context{})
|
||||
// // result2 is Success(Either.Right[string](42))
|
||||
//
|
||||
// // Validate something that's neither (will fail with both errors)
|
||||
// result3 := validator([]int{1, 2, 3})(validation.Context{})
|
||||
// // result3 is Failure with errors from both string and int validation
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - Prioritizes Right validation over Left validation
|
||||
// - Accumulates errors from both branches when both fail
|
||||
// - Uses the validation context to provide detailed error messages
|
||||
// - The validator is lazy: it only evaluates Left if Right fails
|
||||
func validateEither[A, B, O, I any](
|
||||
leftItem Type[A, O, I],
|
||||
rightItem Type[B, O, I],
|
||||
) Validate[I, either.Either[A, B]] {
|
||||
|
||||
// F.Pipe1(
|
||||
// leftItem.Decode,
|
||||
// decode.OrElse()
|
||||
// )
|
||||
|
||||
return func(i I) Decode[Context, either.Either[A, B]] {
|
||||
valRight := rightItem.Validate(i)
|
||||
valLeft := leftItem.Validate(i)
|
||||
|
||||
return func(ctx Context) Validation[either.Either[A, B]] {
|
||||
|
||||
resRight := valRight(ctx)
|
||||
|
||||
return either.Fold(
|
||||
func(rightErrors validate.Errors) Validation[either.Either[A, B]] {
|
||||
resLeft := valLeft(ctx)
|
||||
return either.Fold(
|
||||
func(leftErrors validate.Errors) Validation[either.Either[A, B]] {
|
||||
return validation.Failures[either.Either[A, B]](array.Concat(leftErrors)(rightErrors))
|
||||
},
|
||||
F.Flow2(either.Left[B, A], validation.Of),
|
||||
)(resLeft)
|
||||
},
|
||||
F.Flow2(either.Right[A, B], validation.Of),
|
||||
)(resRight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Either creates a codec for Either[A, B] values.
|
||||
//
|
||||
// This function constructs a complete codec that can encode, decode, and validate
|
||||
// Either values. An Either represents a value that can be one of two types: Left (A)
|
||||
// or Right (B). This is commonly used for error handling, where Left represents an
|
||||
// error and Right represents a success value.
|
||||
//
|
||||
// The codec handles both branches of the Either type using the provided codecs for
|
||||
// each branch. During validation, it attempts to validate the input as both types
|
||||
// and succeeds if either validation passes.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - A: The type of the Left value
|
||||
// - B: The type of the Right value
|
||||
// - O: The output type after encoding
|
||||
// - I: The input type for validation
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - leftItem: The codec for handling Left values of type A
|
||||
// - rightItem: The codec for handling Right values of type B
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// A Type[either.Either[A, B], O, I] that can encode, decode, and validate Either values.
|
||||
//
|
||||
// # Codec Behavior
|
||||
//
|
||||
// Encoding:
|
||||
// - Left values are encoded using leftItem.Encode
|
||||
// - Right values are encoded using rightItem.Encode
|
||||
//
|
||||
// Validation:
|
||||
// - First attempts to validate as Right (B)
|
||||
// - If Right fails, attempts to validate as Left (A)
|
||||
// - If both fail, returns all accumulated errors
|
||||
// - If either succeeds, returns the corresponding Either value
|
||||
//
|
||||
// Type Checking:
|
||||
// - Uses Is[either.Either[A, B]]() to verify the value is an Either
|
||||
//
|
||||
// Naming:
|
||||
// - The codec name is "Either[<leftName>, <rightName>]"
|
||||
// - Example: "Either[string, int]"
|
||||
//
|
||||
// # Example
|
||||
//
|
||||
// // Create a codec for Either[string, int]
|
||||
// stringCodec := String()
|
||||
// intCodec := Int()
|
||||
// eitherCodec := Either(stringCodec, intCodec)
|
||||
//
|
||||
// // Encode a Left value
|
||||
// leftEncoded := eitherCodec.Encode(either.Left[int]("error"))
|
||||
// // leftEncoded contains the encoded string
|
||||
//
|
||||
// // Encode a Right value
|
||||
// rightEncoded := eitherCodec.Encode(either.Right[string](42))
|
||||
// // rightEncoded contains the encoded int
|
||||
//
|
||||
// // Decode/validate an input
|
||||
// result := eitherCodec.Decode("hello")
|
||||
// // result is Success(Either.Left[int]("hello"))
|
||||
//
|
||||
// result2 := eitherCodec.Decode(42)
|
||||
// // result2 is Success(Either.Right[string](42))
|
||||
//
|
||||
// // Get the codec name
|
||||
// name := eitherCodec.Name()
|
||||
// // name is "Either[string, int]"
|
||||
//
|
||||
// # Use Cases
|
||||
//
|
||||
// - Error handling: Either[Error, Value]
|
||||
// - Alternative values: Either[DefaultValue, CustomValue]
|
||||
// - Union types: Either[TypeA, TypeB]
|
||||
// - Validation results: Either[ValidationError, ValidatedValue]
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - The codec prioritizes Right validation over Left validation
|
||||
// - Both branches must have compatible encoding output types (O)
|
||||
// - Both branches must have compatible validation input types (I)
|
||||
// - The codec name includes the names of both branch codecs
|
||||
// - This is a building block for more complex sum types
|
||||
func Either[A, B, O, I any](
|
||||
leftItem Type[A, O, I],
|
||||
rightItem Type[B, O, I],
|
||||
) Type[either.Either[A, B], O, I] {
|
||||
name := fmt.Sprintf("Either[%s, %s]", leftItem.Name(), rightItem.Name())
|
||||
isEither := Is[either.Either[A, B]]()
|
||||
|
||||
return MakeType(
|
||||
name,
|
||||
isEither,
|
||||
validateEither(leftItem, rightItem),
|
||||
encodeEither(leftItem, rightItem),
|
||||
)
|
||||
}
|
||||
347
v2/optics/codec/either_test.go
Normal file
347
v2/optics/codec/either_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package codec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validation"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestEitherWithIdentityCodecs tests the Either function with identity codecs
|
||||
// where both branches have the same output and input types
|
||||
func TestEitherWithIdentityCodecs(t *testing.T) {
|
||||
t.Run("creates codec with correct name", func(t *testing.T) {
|
||||
// The Either function is designed for cases where both branches encode to the same type
|
||||
// For example, both encode to string or both encode to JSON
|
||||
|
||||
// Create codecs that both encode to string
|
||||
stringToString := Id[string]()
|
||||
intToString := IntFromString()
|
||||
|
||||
eitherCodec := Either(stringToString, intToString)
|
||||
|
||||
assert.Equal(t, "Either[string, IntFromString]", eitherCodec.Name())
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherEncode tests encoding of Either values
|
||||
func TestEitherEncode(t *testing.T) {
|
||||
// Create codecs that both encode to string
|
||||
stringToString := Id[string]()
|
||||
intToString := IntFromString()
|
||||
|
||||
eitherCodec := Either(stringToString, intToString)
|
||||
|
||||
t.Run("encodes Left value", func(t *testing.T) {
|
||||
leftValue := either.Left[int]("hello")
|
||||
encoded := eitherCodec.Encode(leftValue)
|
||||
|
||||
assert.Equal(t, "hello", encoded)
|
||||
})
|
||||
|
||||
t.Run("encodes Right value", func(t *testing.T) {
|
||||
rightValue := either.Right[string](42)
|
||||
encoded := eitherCodec.Encode(rightValue)
|
||||
|
||||
assert.Equal(t, "42", encoded)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherDecode tests decoding/validation of Either values
|
||||
func TestEitherDecode(t *testing.T) {
|
||||
getOrElseNull := either.GetOrElse(reader.Of[validation.Errors, either.Either[string, int]](either.Left[int]("")))
|
||||
|
||||
// Create codecs that both work with string input
|
||||
stringCodec := Id[string]()
|
||||
intFromString := IntFromString()
|
||||
|
||||
eitherCodec := Either(stringCodec, intFromString)
|
||||
|
||||
t.Run("decodes integer string as Right", func(t *testing.T) {
|
||||
result := eitherCodec.Decode("42")
|
||||
|
||||
assert.True(t, either.IsRight(result), "should successfully decode integer string")
|
||||
|
||||
value := getOrElseNull(result)
|
||||
assert.True(t, either.IsRight(value), "should be Right")
|
||||
|
||||
rightValue := either.MonadFold(value,
|
||||
func(string) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 42, rightValue)
|
||||
})
|
||||
|
||||
t.Run("decodes non-integer string as Left", func(t *testing.T) {
|
||||
result := eitherCodec.Decode("hello")
|
||||
|
||||
assert.True(t, either.IsRight(result), "should successfully decode string")
|
||||
|
||||
value := getOrElseNull(result)
|
||||
assert.True(t, either.IsLeft(value), "should be Left")
|
||||
|
||||
leftValue := either.MonadFold(value,
|
||||
F.Identity[string],
|
||||
func(int) string { return "" },
|
||||
)
|
||||
assert.Equal(t, "hello", leftValue)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherValidation tests validation behavior
|
||||
func TestEitherValidation(t *testing.T) {
|
||||
t.Run("validates with custom codecs", func(t *testing.T) {
|
||||
// Create a codec that only accepts non-empty strings
|
||||
nonEmptyString := MakeType(
|
||||
"NonEmptyString",
|
||||
func(u any) either.Either[error, string] {
|
||||
s, ok := u.(string)
|
||||
if !ok || len(s) == 0 {
|
||||
return either.Left[string](fmt.Errorf("not a non-empty string"))
|
||||
}
|
||||
return either.Of[error](s)
|
||||
},
|
||||
func(s string) Decode[Context, string] {
|
||||
return func(c Context) Validation[string] {
|
||||
if len(s) == 0 {
|
||||
return validation.FailureWithMessage[string](s, "must not be empty")(c)
|
||||
}
|
||||
return validation.Success(s)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
|
||||
// Create a codec that only accepts positive integers from strings
|
||||
positiveIntFromString := MakeType(
|
||||
"PositiveInt",
|
||||
func(u any) either.Either[error, int] {
|
||||
i, ok := u.(int)
|
||||
if !ok || i <= 0 {
|
||||
return either.Left[int](fmt.Errorf("not a positive integer"))
|
||||
}
|
||||
return either.Of[error](i)
|
||||
},
|
||||
func(s string) Decode[Context, int] {
|
||||
return func(c Context) Validation[int] {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
if err != nil {
|
||||
return validation.FailureWithError[int](s, "expected integer string")(err)(c)
|
||||
}
|
||||
if n <= 0 {
|
||||
return validation.FailureWithMessage[int](n, "must be positive")(c)
|
||||
}
|
||||
return validation.Success(n)
|
||||
}
|
||||
},
|
||||
func(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
},
|
||||
)
|
||||
|
||||
eitherCodec := Either(nonEmptyString, positiveIntFromString)
|
||||
|
||||
// Valid non-empty string
|
||||
validLeft := eitherCodec.Decode("hello")
|
||||
assert.True(t, either.IsRight(validLeft))
|
||||
|
||||
// Valid positive integer
|
||||
validRight := eitherCodec.Decode("42")
|
||||
assert.True(t, either.IsRight(validRight))
|
||||
|
||||
// Invalid empty string - should fail both validations
|
||||
invalidEmpty := eitherCodec.Decode("")
|
||||
assert.True(t, either.IsLeft(invalidEmpty))
|
||||
|
||||
// Invalid zero - should fail Right validation, succeed as Left
|
||||
zeroResult := eitherCodec.Decode("0")
|
||||
// "0" is a valid non-empty string, so it should succeed as Left
|
||||
assert.True(t, either.IsRight(zeroResult))
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherRoundTrip tests encoding and decoding round trips
|
||||
func TestEitherRoundTrip(t *testing.T) {
|
||||
stringCodec := Id[string]()
|
||||
intFromString := IntFromString()
|
||||
|
||||
eitherCodec := Either(stringCodec, intFromString)
|
||||
|
||||
t.Run("round-trip Left value", func(t *testing.T) {
|
||||
original := "hello"
|
||||
|
||||
// Decode
|
||||
decodeResult := eitherCodec.Decode(original)
|
||||
require.True(t, either.IsRight(decodeResult))
|
||||
|
||||
decoded := either.MonadFold(decodeResult,
|
||||
func(validation.Errors) either.Either[string, int] { return either.Left[int]("") },
|
||||
F.Identity[either.Either[string, int]],
|
||||
)
|
||||
|
||||
// Encode
|
||||
encoded := eitherCodec.Encode(decoded)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, original, encoded)
|
||||
})
|
||||
|
||||
t.Run("round-trip Right value", func(t *testing.T) {
|
||||
original := "42"
|
||||
|
||||
// Decode
|
||||
decodeResult := eitherCodec.Decode(original)
|
||||
require.True(t, either.IsRight(decodeResult))
|
||||
|
||||
decoded := either.MonadFold(decodeResult,
|
||||
func(validation.Errors) either.Either[string, int] { return either.Right[string](0) },
|
||||
F.Identity[either.Either[string, int]],
|
||||
)
|
||||
|
||||
// Encode
|
||||
encoded := eitherCodec.Encode(decoded)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, original, encoded)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherPrioritization tests that Right validation is prioritized over Left
|
||||
func TestEitherPrioritization(t *testing.T) {
|
||||
stringCodec := Id[string]()
|
||||
intFromString := IntFromString()
|
||||
|
||||
eitherCodec := Either(stringCodec, intFromString)
|
||||
|
||||
t.Run("prioritizes Right over Left when both could succeed", func(t *testing.T) {
|
||||
// "42" can be validated as both string (Left) and int (Right)
|
||||
// The codec should prioritize Right
|
||||
result := eitherCodec.Decode("42")
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(validation.Errors) either.Either[string, int] { return either.Left[int]("") },
|
||||
F.Identity[either.Either[string, int]],
|
||||
)
|
||||
|
||||
// Should be Right because int validation succeeds and is prioritized
|
||||
assert.True(t, either.IsRight(value))
|
||||
|
||||
rightValue := either.MonadFold(value,
|
||||
func(string) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 42, rightValue)
|
||||
})
|
||||
|
||||
t.Run("falls back to Left when Right fails", func(t *testing.T) {
|
||||
// "hello" can only be validated as string (Left), not as int (Right)
|
||||
result := eitherCodec.Decode("hello")
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(validation.Errors) either.Either[string, int] { return either.Left[int]("") },
|
||||
F.Identity[either.Either[string, int]],
|
||||
)
|
||||
|
||||
// Should be Left because int validation failed
|
||||
assert.True(t, either.IsLeft(value))
|
||||
|
||||
leftValue := either.MonadFold(value,
|
||||
F.Identity[string],
|
||||
func(int) string { return "" },
|
||||
)
|
||||
assert.Equal(t, "hello", leftValue)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherErrorAccumulation tests that errors from both branches are accumulated
|
||||
func TestEitherErrorAccumulation(t *testing.T) {
|
||||
// Create codecs with specific validation rules that will both fail
|
||||
nonEmptyString := MakeType(
|
||||
"NonEmptyString",
|
||||
func(u any) either.Either[error, string] {
|
||||
s, ok := u.(string)
|
||||
if !ok || len(s) == 0 {
|
||||
return either.Left[string](fmt.Errorf("not a non-empty string"))
|
||||
}
|
||||
return either.Of[error](s)
|
||||
},
|
||||
func(s string) Decode[Context, string] {
|
||||
return func(c Context) Validation[string] {
|
||||
if len(s) == 0 {
|
||||
return validation.FailureWithMessage[string](s, "must not be empty")(c)
|
||||
}
|
||||
return validation.Success(s)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
|
||||
positiveIntFromString := MakeType(
|
||||
"PositiveInt",
|
||||
func(u any) either.Either[error, int] {
|
||||
i, ok := u.(int)
|
||||
if !ok || i <= 0 {
|
||||
return either.Left[int](fmt.Errorf("not a positive integer"))
|
||||
}
|
||||
return either.Of[error](i)
|
||||
},
|
||||
func(s string) Decode[Context, int] {
|
||||
return func(c Context) Validation[int] {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
if err != nil {
|
||||
return validation.FailureWithError[int](s, "expected integer string")(err)(c)
|
||||
}
|
||||
if n <= 0 {
|
||||
return validation.FailureWithMessage[int](n, "must be positive")(c)
|
||||
}
|
||||
return validation.Success(n)
|
||||
}
|
||||
},
|
||||
strconv.Itoa,
|
||||
)
|
||||
|
||||
eitherCodec := Either(nonEmptyString, positiveIntFromString)
|
||||
|
||||
t.Run("accumulates errors from both branches when both fail", func(t *testing.T) {
|
||||
// Empty string will fail both validations
|
||||
result := eitherCodec.Decode("")
|
||||
|
||||
assert.True(t, either.IsLeft(result))
|
||||
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[validation.Errors],
|
||||
func(either.Either[string, int]) validation.Errors { return nil },
|
||||
)
|
||||
|
||||
require.NotNil(t, errors)
|
||||
// Should have errors from both string and int validation attempts
|
||||
assert.NotEmpty(t, errors)
|
||||
})
|
||||
}
|
||||
@@ -122,3 +122,13 @@ func ApplicativeMonoid[I, A any](m Monoid[A]) Monoid[Validate[I, A]] {
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
// func AlternativeMonoid[I, A any](m Monoid[A]) Monoid[Validate[I, A]] {
|
||||
// return monoid.AlternativeMonoid(
|
||||
// Of[I, A],
|
||||
// MonadMap[I, A, func(A) A],
|
||||
// MonadAp[A, I, A],
|
||||
// MonadAlt[I, A],
|
||||
// m,
|
||||
// )
|
||||
// }
|
||||
|
||||
@@ -17,6 +17,7 @@ package validate
|
||||
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/lazy"
|
||||
"github.com/IBM/fp-go/v2/monoid"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/decode"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validation"
|
||||
@@ -271,4 +272,6 @@ type (
|
||||
// lower := strings.ToLower // Endomorphism[string]
|
||||
// normalize := compose(trim, lower) // Endomorphism[string]
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
|
||||
Lazy[A any] = lazy.Lazy[A]
|
||||
)
|
||||
|
||||
@@ -309,6 +309,225 @@ func Chain[I, A, B any](f Kleisli[I, A, B]) Operator[I, A, B] {
|
||||
)
|
||||
}
|
||||
|
||||
// ChainLeft sequences a computation on the failure (Left) channel of a validation.
|
||||
//
|
||||
// This function operates on the error path of validation, allowing you to transform,
|
||||
// enrich, or recover from validation failures. It's the dual of Chain - while Chain
|
||||
// operates on success values, ChainLeft operates on error values.
|
||||
//
|
||||
// # Key Behavior
|
||||
//
|
||||
// **Critical difference from standard Either operations**: This validation-specific
|
||||
// implementation **aggregates errors** using the Errors monoid. When the transformation
|
||||
// function returns a failure, both the original errors AND the new errors are combined,
|
||||
// ensuring comprehensive error reporting.
|
||||
//
|
||||
// 1. **Success Pass-Through**: If validation succeeds, the handler is never called and
|
||||
// the success value passes through unchanged.
|
||||
//
|
||||
// 2. **Error Recovery**: The handler can recover from failures by returning a successful
|
||||
// validation, converting Left to Right.
|
||||
//
|
||||
// 3. **Error Aggregation**: When the handler also returns a failure, both the original
|
||||
// errors and the new errors are combined using the Errors monoid.
|
||||
//
|
||||
// 4. **Input Access**: The handler returns a Validate[I, A] function, giving it access
|
||||
// to the original input value I for context-aware error handling.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - I: The input type
|
||||
// - A: The type of the validation result
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - f: A Kleisli arrow that takes Errors and returns a Validate[I, A]. This function
|
||||
// is called only when validation fails, receiving the accumulated errors.
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// An Operator[I, A, A] that transforms validators by handling their error cases.
|
||||
//
|
||||
// # Example: Error Recovery
|
||||
//
|
||||
// // Validator that may fail
|
||||
// validatePositive := func(n int) Reader[validation.Context, validation.Validation[int]] {
|
||||
// return func(ctx validation.Context) validation.Validation[int] {
|
||||
// if n > 0 {
|
||||
// return validation.Success(n)
|
||||
// }
|
||||
// return validation.FailureWithMessage[int](n, "must be positive")(ctx)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Recover from specific errors with a default value
|
||||
// withDefault := ChainLeft(func(errs Errors) Validate[int, int] {
|
||||
// for _, err := range errs {
|
||||
// if err.Messsage == "must be positive" {
|
||||
// return Of[int](0) // recover with default
|
||||
// }
|
||||
// }
|
||||
// return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
// return func(ctx validation.Context) validation.Validation[int] {
|
||||
// return either.Left[int](errs)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// validator := withDefault(validatePositive)
|
||||
// result := validator(-5)(nil)
|
||||
// // Result: Success(0) - recovered from failure
|
||||
//
|
||||
// # Example: Error Context Addition
|
||||
//
|
||||
// // Add contextual information to errors
|
||||
// addContext := ChainLeft(func(errs Errors) Validate[string, int] {
|
||||
// return func(input string) Reader[validation.Context, validation.Validation[int]] {
|
||||
// return func(ctx validation.Context) validation.Validation[int] {
|
||||
// return either.Left[int](validation.Errors{
|
||||
// {
|
||||
// Context: validation.Context{{Key: "user", Type: "User"}, {Key: "age", Type: "int"}},
|
||||
// Messsage: "failed to validate user age",
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// validator := addContext(someValidator)
|
||||
// // Errors will include both original error and context
|
||||
//
|
||||
// # Example: Input-Dependent Recovery
|
||||
//
|
||||
// // Recover with different defaults based on input
|
||||
// smartDefault := ChainLeft(func(errs Errors) Validate[string, int] {
|
||||
// return func(input string) Reader[validation.Context, validation.Validation[int]] {
|
||||
// return func(ctx validation.Context) validation.Validation[int] {
|
||||
// // Use input to determine appropriate default
|
||||
// if strings.Contains(input, "http") {
|
||||
// return validation.Of(80)
|
||||
// }
|
||||
// if strings.Contains(input, "https") {
|
||||
// return validation.Of(443)
|
||||
// }
|
||||
// return validation.Of(8080)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - Errors are accumulated, not replaced - this ensures no validation failures are lost
|
||||
// - The handler has access to both the errors and the original input
|
||||
// - Success values bypass the handler completely
|
||||
// - This enables sophisticated error handling strategies including recovery, enrichment, and transformation
|
||||
// - Use OrElse as a semantic alias when emphasizing fallback/alternative logic
|
||||
func ChainLeft[I, A any](f Kleisli[I, Errors, A]) Operator[I, A, A] {
|
||||
return readert.Chain[Validate[I, A]](
|
||||
decode.ChainLeft,
|
||||
f,
|
||||
)
|
||||
}
|
||||
|
||||
// OrElse provides an alternative validation when the primary validation fails.
|
||||
//
|
||||
// This is a semantic alias for ChainLeft with identical behavior. The name "OrElse"
|
||||
// emphasizes the intent of providing fallback or alternative validation logic, making
|
||||
// code more readable when that's the primary use case.
|
||||
//
|
||||
// # Relationship to ChainLeft
|
||||
//
|
||||
// **OrElse and ChainLeft are functionally identical** - they produce exactly the same
|
||||
// results for all inputs. The choice between them is purely about code readability:
|
||||
//
|
||||
// - Use **OrElse** when emphasizing fallback/alternative validation logic
|
||||
// - Use **ChainLeft** when emphasizing technical error channel transformation
|
||||
//
|
||||
// Both maintain the critical property of **error aggregation**, ensuring all validation
|
||||
// failures are preserved and reported together.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - I: The input type
|
||||
// - A: The type of the validation result
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - f: A Kleisli arrow that takes Errors and returns a Validate[I, A]. This function
|
||||
// is called only when validation fails, receiving the accumulated errors.
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// An Operator[I, A, A] that transforms validators by providing alternative validation.
|
||||
//
|
||||
// # Example: Fallback Validation
|
||||
//
|
||||
// // Primary validator that may fail
|
||||
// validateFromConfig := func(key string) Reader[validation.Context, validation.Validation[string]] {
|
||||
// return func(ctx validation.Context) validation.Validation[string] {
|
||||
// // Try to get value from config
|
||||
// if value, ok := config[key]; ok {
|
||||
// return validation.Success(value)
|
||||
// }
|
||||
// return validation.FailureWithMessage[string](key, "not found in config")(ctx)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Use OrElse for semantic clarity - "try config, or else use environment"
|
||||
// withEnvFallback := OrElse(func(errs Errors) Validate[string, string] {
|
||||
// return func(key string) Reader[validation.Context, validation.Validation[string]] {
|
||||
// return func(ctx validation.Context) validation.Validation[string] {
|
||||
// if value := os.Getenv(key); value != "" {
|
||||
// return validation.Success(value)
|
||||
// }
|
||||
// return either.Left[string](errs) // propagate original errors
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// validator := withEnvFallback(validateFromConfig)
|
||||
// result := validator("DATABASE_URL")(nil)
|
||||
// // Tries config first, falls back to environment variable
|
||||
//
|
||||
// # Example: Default Value on Failure
|
||||
//
|
||||
// // Provide a default value when validation fails
|
||||
// withDefault := OrElse(func(errs Errors) Validate[int, int] {
|
||||
// return Of[int](0) // default to 0 on any failure
|
||||
// })
|
||||
//
|
||||
// validator := withDefault(someValidator)
|
||||
// result := validator(input)(nil)
|
||||
// // Always succeeds, using default value if validation fails
|
||||
//
|
||||
// # Example: Pipeline with Multiple Fallbacks
|
||||
//
|
||||
// // Build a validation pipeline with multiple fallback strategies
|
||||
// validator := F.Pipe2(
|
||||
// validateFromDatabase,
|
||||
// OrElse(func(errs Errors) Validate[string, Config] {
|
||||
// // Try cache as first fallback
|
||||
// return validateFromCache
|
||||
// }),
|
||||
// OrElse(func(errs Errors) Validate[string, Config] {
|
||||
// // Use default config as final fallback
|
||||
// return Of[string](defaultConfig)
|
||||
// }),
|
||||
// )
|
||||
// // Tries database, then cache, then default
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - Identical behavior to ChainLeft - they are aliases
|
||||
// - Errors are accumulated when transformations fail
|
||||
// - Success values pass through unchanged
|
||||
// - The handler has access to both errors and original input
|
||||
// - Choose OrElse for better readability when providing alternatives
|
||||
// - See ChainLeft documentation for detailed behavior and additional examples
|
||||
func OrElse[I, A any](f Kleisli[I, Errors, A]) Operator[I, A, A] {
|
||||
return ChainLeft(f)
|
||||
}
|
||||
|
||||
// MonadAp applies a validator containing a function to a validator containing a value.
|
||||
//
|
||||
// This is the applicative apply operation for Validate. It allows you to apply
|
||||
|
||||
@@ -849,3 +849,428 @@ func TestFunctorLaws(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestChainLeft tests the ChainLeft function
|
||||
func TestChainLeft(t *testing.T) {
|
||||
t.Run("transforms failures while preserving successes", func(t *testing.T) {
|
||||
// Create a failing validator
|
||||
failingValidator := func(n int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](n, "validation failed")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler that recovers from specific errors
|
||||
handler := ChainLeft(func(errs Errors) Validate[int, int] {
|
||||
for _, err := range errs {
|
||||
if err.Messsage == "validation failed" {
|
||||
return Of[int](0) // recover with default
|
||||
}
|
||||
}
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return E.Left[int](errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := handler(failingValidator)
|
||||
result := validator(-5)(nil)
|
||||
|
||||
assert.Equal(t, validation.Of(0), result, "Should recover from failure")
|
||||
})
|
||||
|
||||
t.Run("preserves success values unchanged", func(t *testing.T) {
|
||||
successValidator := Of[int](42)
|
||||
|
||||
handler := ChainLeft(func(errs Errors) Validate[int, int] {
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](input, "should not be called")(ctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := handler(successValidator)
|
||||
result := validator(100)(nil)
|
||||
|
||||
assert.Equal(t, validation.Of(42), result, "Success should pass through unchanged")
|
||||
})
|
||||
|
||||
t.Run("aggregates errors when transformation also fails", func(t *testing.T) {
|
||||
failingValidator := func(s string) Reader[validation.Context, validation.Validation[string]] {
|
||||
return func(ctx validation.Context) validation.Validation[string] {
|
||||
return validation.FailureWithMessage[string](s, "original error")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
handler := ChainLeft(func(errs Errors) Validate[string, string] {
|
||||
return func(input string) Reader[validation.Context, validation.Validation[string]] {
|
||||
return func(ctx validation.Context) validation.Validation[string] {
|
||||
return validation.FailureWithMessage[string](input, "additional error")(ctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := handler(failingValidator)
|
||||
result := validator("test")(nil)
|
||||
|
||||
assert.True(t, E.IsLeft(result))
|
||||
_, errors := E.Unwrap(result)
|
||||
assert.Len(t, errors, 2, "Should aggregate both errors")
|
||||
|
||||
messages := make([]string, len(errors))
|
||||
for i, err := range errors {
|
||||
messages[i] = err.Messsage
|
||||
}
|
||||
assert.Contains(t, messages, "original error")
|
||||
assert.Contains(t, messages, "additional error")
|
||||
})
|
||||
|
||||
t.Run("adds context to errors", func(t *testing.T) {
|
||||
failingValidator := func(n int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](n, "invalid value")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
addContext := ChainLeft(func(errs Errors) Validate[int, int] {
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return E.Left[int](validation.Errors{
|
||||
{
|
||||
Context: validation.Context{{Key: "user", Type: "User"}, {Key: "age", Type: "int"}},
|
||||
Messsage: "failed to validate user age",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := addContext(failingValidator)
|
||||
result := validator(150)(nil)
|
||||
|
||||
assert.True(t, E.IsLeft(result))
|
||||
_, errors := E.Unwrap(result)
|
||||
assert.Len(t, errors, 2, "Should have both original and context errors")
|
||||
})
|
||||
|
||||
t.Run("can be composed in pipeline", func(t *testing.T) {
|
||||
failingValidator := func(n int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](n, "error1")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
handler1 := ChainLeft(func(errs Errors) Validate[int, int] {
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](input, "error2")(ctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
handler2 := ChainLeft(func(errs Errors) Validate[int, int] {
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](input, "error3")(ctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := handler2(handler1(failingValidator))
|
||||
result := validator(42)(nil)
|
||||
|
||||
assert.True(t, E.IsLeft(result))
|
||||
_, errors := E.Unwrap(result)
|
||||
assert.GreaterOrEqual(t, len(errors), 2, "Should accumulate errors through pipeline")
|
||||
})
|
||||
|
||||
t.Run("provides access to original input", func(t *testing.T) {
|
||||
failingValidator := func(n int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](n, "failed")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler uses input to determine recovery strategy
|
||||
handler := ChainLeft(func(errs Errors) Validate[int, int] {
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
// Use input value to decide on recovery
|
||||
if input < 0 {
|
||||
return validation.Of(0)
|
||||
}
|
||||
if input > 100 {
|
||||
return validation.Of(100)
|
||||
}
|
||||
return E.Left[int](errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := handler(failingValidator)
|
||||
|
||||
result1 := validator(-10)(nil)
|
||||
assert.Equal(t, validation.Of(0), result1, "Should recover negative to 0")
|
||||
|
||||
result2 := validator(150)(nil)
|
||||
assert.Equal(t, validation.Of(100), result2, "Should recover large to 100")
|
||||
})
|
||||
|
||||
t.Run("works with different input and output types", func(t *testing.T) {
|
||||
// Validator that converts string to int
|
||||
parseValidator := func(s string) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](s, "parse failed")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler that provides default based on input string
|
||||
handler := ChainLeft(func(errs Errors) Validate[string, int] {
|
||||
return func(input string) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
if input == "default" {
|
||||
return validation.Of(42)
|
||||
}
|
||||
return E.Left[int](errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := handler(parseValidator)
|
||||
result := validator("default")(nil)
|
||||
|
||||
assert.Equal(t, validation.Of(42), result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrElse tests the OrElse function
|
||||
func TestOrElse(t *testing.T) {
|
||||
t.Run("provides fallback for failing validation", func(t *testing.T) {
|
||||
// Primary validator that fails
|
||||
primaryValidator := func(s string) Reader[validation.Context, validation.Validation[string]] {
|
||||
return func(ctx validation.Context) validation.Validation[string] {
|
||||
return validation.FailureWithMessage[string](s, "not found")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Use OrElse to provide fallback
|
||||
withFallback := OrElse(func(errs Errors) Validate[string, string] {
|
||||
return Of[string]("default value")
|
||||
})
|
||||
|
||||
validator := withFallback(primaryValidator)
|
||||
result := validator("missing")(nil)
|
||||
|
||||
assert.Equal(t, validation.Of("default value"), result)
|
||||
})
|
||||
|
||||
t.Run("preserves success values unchanged", func(t *testing.T) {
|
||||
successValidator := Of[string]("success")
|
||||
|
||||
withFallback := OrElse(func(errs Errors) Validate[string, string] {
|
||||
return Of[string]("fallback")
|
||||
})
|
||||
|
||||
validator := withFallback(successValidator)
|
||||
result := validator("input")(nil)
|
||||
|
||||
assert.Equal(t, validation.Of("success"), result, "Should not use fallback for success")
|
||||
})
|
||||
|
||||
t.Run("aggregates errors when fallback also fails", func(t *testing.T) {
|
||||
failingValidator := func(n int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](n, "primary failed")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
withFallback := OrElse(func(errs Errors) Validate[int, int] {
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](input, "fallback failed")(ctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := withFallback(failingValidator)
|
||||
result := validator(42)(nil)
|
||||
|
||||
assert.True(t, E.IsLeft(result))
|
||||
_, errors := E.Unwrap(result)
|
||||
assert.Len(t, errors, 2, "Should aggregate both errors")
|
||||
|
||||
messages := make([]string, len(errors))
|
||||
for i, err := range errors {
|
||||
messages[i] = err.Messsage
|
||||
}
|
||||
assert.Contains(t, messages, "primary failed")
|
||||
assert.Contains(t, messages, "fallback failed")
|
||||
})
|
||||
|
||||
t.Run("supports multiple fallback strategies", func(t *testing.T) {
|
||||
failingValidator := func(s string) Reader[validation.Context, validation.Validation[string]] {
|
||||
return func(ctx validation.Context) validation.Validation[string] {
|
||||
return validation.FailureWithMessage[string](s, "not in database")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// First fallback: try cache
|
||||
tryCache := OrElse(func(errs Errors) Validate[string, string] {
|
||||
return func(input string) Reader[validation.Context, validation.Validation[string]] {
|
||||
return func(ctx validation.Context) validation.Validation[string] {
|
||||
if input == "cached" {
|
||||
return validation.Of("from cache")
|
||||
}
|
||||
return E.Left[string](errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Second fallback: use default
|
||||
useDefault := OrElse(func(errs Errors) Validate[string, string] {
|
||||
return Of[string]("default")
|
||||
})
|
||||
|
||||
// Compose fallbacks
|
||||
validator := useDefault(tryCache(failingValidator))
|
||||
|
||||
// Test with cached value
|
||||
result1 := validator("cached")(nil)
|
||||
assert.Equal(t, validation.Of("from cache"), result1)
|
||||
|
||||
// Test with non-cached value (should use default)
|
||||
result2 := validator("other")(nil)
|
||||
assert.Equal(t, validation.Of("default"), result2)
|
||||
})
|
||||
|
||||
t.Run("provides input-dependent fallback", func(t *testing.T) {
|
||||
failingValidator := func(s string) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](s, "parse failed")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback with different defaults based on input
|
||||
smartFallback := OrElse(func(errs Errors) Validate[string, int] {
|
||||
return func(input string) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
// Provide context-aware defaults
|
||||
if input == "http" {
|
||||
return validation.Of(80)
|
||||
}
|
||||
if input == "https" {
|
||||
return validation.Of(443)
|
||||
}
|
||||
return validation.Of(8080)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
validator := smartFallback(failingValidator)
|
||||
|
||||
result1 := validator("http")(nil)
|
||||
assert.Equal(t, validation.Of(80), result1)
|
||||
|
||||
result2 := validator("https")(nil)
|
||||
assert.Equal(t, validation.Of(443), result2)
|
||||
|
||||
result3 := validator("other")(nil)
|
||||
assert.Equal(t, validation.Of(8080), result3)
|
||||
})
|
||||
|
||||
t.Run("is equivalent to ChainLeft", func(t *testing.T) {
|
||||
// Create identical handlers
|
||||
handler := func(errs Errors) Validate[int, int] {
|
||||
return func(input int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
if input < 0 {
|
||||
return validation.Of(0)
|
||||
}
|
||||
return E.Left[int](errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failingValidator := func(n int) Reader[validation.Context, validation.Validation[int]] {
|
||||
return func(ctx validation.Context) validation.Validation[int] {
|
||||
return validation.FailureWithMessage[int](n, "failed")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply with ChainLeft
|
||||
withChainLeft := ChainLeft(handler)(failingValidator)
|
||||
|
||||
// Apply with OrElse
|
||||
withOrElse := OrElse(handler)(failingValidator)
|
||||
|
||||
// Test with same inputs
|
||||
inputs := []int{-10, 0, 10, -5, 100}
|
||||
for _, input := range inputs {
|
||||
result1 := withChainLeft(input)(nil)
|
||||
result2 := withOrElse(input)(nil)
|
||||
|
||||
// Results should be identical
|
||||
assert.Equal(t, E.IsLeft(result1), E.IsLeft(result2))
|
||||
if E.IsRight(result1) {
|
||||
val1, _ := E.Unwrap(result1)
|
||||
val2, _ := E.Unwrap(result2)
|
||||
assert.Equal(t, val1, val2, "OrElse and ChainLeft should produce identical results")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("works in complex validation pipeline", func(t *testing.T) {
|
||||
type Config struct {
|
||||
Port int
|
||||
Host string
|
||||
}
|
||||
|
||||
// Validator that tries to parse config
|
||||
parseConfig := func(s string) Reader[validation.Context, validation.Validation[Config]] {
|
||||
return func(ctx validation.Context) validation.Validation[Config] {
|
||||
return validation.FailureWithMessage[Config](s, "invalid config")(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to environment variables
|
||||
tryEnv := OrElse(func(errs Errors) Validate[string, Config] {
|
||||
return func(input string) Reader[validation.Context, validation.Validation[Config]] {
|
||||
return func(ctx validation.Context) validation.Validation[Config] {
|
||||
// Simulate env var lookup
|
||||
if input == "from_env" {
|
||||
return validation.Of(Config{Port: 8080, Host: "localhost"})
|
||||
}
|
||||
return E.Left[Config](errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Final fallback to defaults
|
||||
useDefaults := OrElse(func(errs Errors) Validate[string, Config] {
|
||||
return Of[string](Config{Port: 3000, Host: "0.0.0.0"})
|
||||
})
|
||||
|
||||
// Build pipeline
|
||||
validator := useDefaults(tryEnv(parseConfig))
|
||||
|
||||
// Test with env fallback
|
||||
result1 := validator("from_env")(nil)
|
||||
assert.True(t, E.IsRight(result1))
|
||||
if E.IsRight(result1) {
|
||||
cfg, _ := E.Unwrap(result1)
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
assert.Equal(t, "localhost", cfg.Host)
|
||||
}
|
||||
|
||||
// Test with default fallback
|
||||
result2 := validator("other")(nil)
|
||||
assert.True(t, E.IsRight(result2))
|
||||
if E.IsRight(result2) {
|
||||
cfg, _ := E.Unwrap(result2)
|
||||
assert.Equal(t, 3000, cfg.Port)
|
||||
assert.Equal(t, "0.0.0.0", cfg.Host)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
162
v2/optics/codec/validation/OrElse_explanation.md
Normal file
162
v2/optics/codec/validation/OrElse_explanation.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# OrElse is Equivalent to ChainLeft
|
||||
|
||||
## Overview
|
||||
|
||||
In [`optics/codec/validation/monad.go`](monad.go:474-476), the [`OrElse`](monad.go:474) function is defined as a simple alias for [`ChainLeft`](monad.go:304):
|
||||
|
||||
```go
|
||||
//go:inline
|
||||
func OrElse[A any](f Kleisli[Errors, A]) Operator[A, A] {
|
||||
return ChainLeft(f)
|
||||
}
|
||||
```
|
||||
|
||||
This means **`OrElse` and `ChainLeft` are functionally identical** - they produce exactly the same results for all inputs.
|
||||
|
||||
## Why Have Both?
|
||||
|
||||
While they are technically the same, they serve different **semantic purposes**:
|
||||
|
||||
### ChainLeft - Technical Perspective
|
||||
[`ChainLeft`](monad.go:304-309) emphasizes the **technical operation**: it chains a computation on the Left (failure) channel of the Either/Validation monad. This name comes from category theory and functional programming terminology.
|
||||
|
||||
### OrElse - Semantic Perspective
|
||||
[`OrElse`](monad.go:474-476) emphasizes the **intent**: it provides an alternative or fallback when validation fails. The name reads naturally in code: "try this validation, **or else** try this alternative."
|
||||
|
||||
## Key Behavior
|
||||
|
||||
Both functions share the same critical behavior that distinguishes them from standard Either operations:
|
||||
|
||||
### Error Aggregation
|
||||
When the transformation function returns a failure, **both the original errors AND the new errors are combined** using the Errors monoid. This ensures no validation errors are lost.
|
||||
|
||||
```go
|
||||
// Example: Error aggregation
|
||||
result := OrElse(func(errs Errors) Validation[string] {
|
||||
return Failures[string](Errors{
|
||||
&ValidationError{Messsage: "additional error"},
|
||||
})
|
||||
})(Failures[string](Errors{
|
||||
&ValidationError{Messsage: "original error"},
|
||||
}))
|
||||
|
||||
// Result contains BOTH errors: ["original error", "additional error"]
|
||||
```
|
||||
|
||||
### Success Pass-Through
|
||||
Success values pass through unchanged - the function is never called:
|
||||
|
||||
```go
|
||||
result := OrElse(func(errs Errors) Validation[int] {
|
||||
return Failures[int](Errors{
|
||||
&ValidationError{Messsage: "never called"},
|
||||
})
|
||||
})(Success(42))
|
||||
|
||||
// Result: Success(42) - unchanged
|
||||
```
|
||||
|
||||
### Error Recovery
|
||||
The function can recover from failures by returning a Success:
|
||||
|
||||
```go
|
||||
recoverFromNotFound := OrElse(func(errs Errors) Validation[int] {
|
||||
for _, err := range errs {
|
||||
if err.Messsage == "not found" {
|
||||
return Success(0) // recover with default
|
||||
}
|
||||
}
|
||||
return Failures[int](errs)
|
||||
})
|
||||
|
||||
result := recoverFromNotFound(Failures[int](Errors{
|
||||
&ValidationError{Messsage: "not found"},
|
||||
}))
|
||||
|
||||
// Result: Success(0) - recovered from failure
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Fallback Validation (OrElse reads better)
|
||||
```go
|
||||
validatePositive := func(x int) Validation[int] {
|
||||
if x > 0 {
|
||||
return Success(x)
|
||||
}
|
||||
return Failures[int](Errors{
|
||||
&ValidationError{Messsage: "must be positive"},
|
||||
})
|
||||
}
|
||||
|
||||
// Use OrElse for semantic clarity
|
||||
withDefault := OrElse(func(errs Errors) Validation[int] {
|
||||
return Success(1) // default to 1 if validation fails
|
||||
})
|
||||
|
||||
result := F.Pipe1(validatePositive(-5), withDefault)
|
||||
// Result: Success(1)
|
||||
```
|
||||
|
||||
### 2. Error Context Addition (ChainLeft reads better)
|
||||
```go
|
||||
addContext := ChainLeft(func(errs Errors) Validation[string] {
|
||||
return Failures[string](Errors{
|
||||
&ValidationError{
|
||||
Messsage: "validation failed in user.email field",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
result := F.Pipe1(
|
||||
Failures[string](Errors{
|
||||
&ValidationError{Messsage: "invalid format"},
|
||||
}),
|
||||
addContext,
|
||||
)
|
||||
// Result contains: ["invalid format", "validation failed in user.email field"]
|
||||
```
|
||||
|
||||
### 3. Pipeline Composition
|
||||
Both can be used in pipelines, with errors accumulating at each step:
|
||||
|
||||
```go
|
||||
result := F.Pipe2(
|
||||
Failures[int](Errors{
|
||||
&ValidationError{Messsage: "database error"},
|
||||
}),
|
||||
OrElse(func(errs Errors) Validation[int] {
|
||||
return Failures[int](Errors{
|
||||
&ValidationError{Messsage: "context added"},
|
||||
})
|
||||
}),
|
||||
OrElse(func(errs Errors) Validation[int] {
|
||||
return Failures[int](errs) // propagate
|
||||
}),
|
||||
)
|
||||
// Errors accumulate at each step in the pipeline
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
The test suite in [`monad_test.go`](monad_test.go:1698) includes comprehensive tests proving that `OrElse` and `ChainLeft` are equivalent:
|
||||
|
||||
- ✅ Identical behavior for Success values
|
||||
- ✅ Identical behavior for error recovery
|
||||
- ✅ Identical behavior for error aggregation
|
||||
- ✅ Identical behavior in pipeline composition
|
||||
- ✅ Identical behavior for multiple error scenarios
|
||||
|
||||
Run the tests:
|
||||
```bash
|
||||
go test -v -run TestOrElse ./optics/codec/validation
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
**`OrElse` is exactly the same as `ChainLeft`** - they are aliases with identical implementations and behavior. The choice between them is purely about **code readability and semantic intent**:
|
||||
|
||||
- Use **`OrElse`** when emphasizing fallback/alternative validation logic
|
||||
- Use **`ChainLeft`** when emphasizing technical error channel transformation
|
||||
|
||||
Both maintain the critical validation property of **error aggregation**, ensuring all validation failures are preserved and reported together.
|
||||
@@ -17,12 +17,7 @@ func TestDo(t *testing.T) {
|
||||
}
|
||||
result := Do(State{})
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{}, value)
|
||||
assert.Equal(t, Of(State{}), result)
|
||||
})
|
||||
|
||||
t.Run("creates successful validation with initialized state", func(t *testing.T) {
|
||||
@@ -33,24 +28,19 @@ func TestDo(t *testing.T) {
|
||||
initial := State{x: 42, y: "hello"}
|
||||
result := Do(initial)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, initial, value)
|
||||
assert.Equal(t, Of(initial), result)
|
||||
})
|
||||
|
||||
t.Run("works with different types", func(t *testing.T) {
|
||||
intResult := Do(0)
|
||||
assert.True(t, either.IsRight(intResult))
|
||||
assert.Equal(t, Of(0), intResult)
|
||||
|
||||
strResult := Do("")
|
||||
assert.True(t, either.IsRight(strResult))
|
||||
assert.Equal(t, Of(""), strResult)
|
||||
|
||||
type Custom struct{ Value int }
|
||||
customResult := Do(Custom{Value: 100})
|
||||
assert.True(t, either.IsRight(customResult))
|
||||
assert.Equal(t, Of(Custom{Value: 100}), customResult)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,12 +61,7 @@ func TestBind(t *testing.T) {
|
||||
}, func(s State) Validation[int] { return Success(10) }),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{x: 42, y: 10}, value)
|
||||
assert.Equal(t, Of(State{x: 42, y: 10}), result)
|
||||
})
|
||||
|
||||
t.Run("propagates failure", func(t *testing.T) {
|
||||
@@ -115,12 +100,7 @@ func TestBind(t *testing.T) {
|
||||
}),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{x: 10, y: 20}, value)
|
||||
assert.Equal(t, Success(State{x: 10, y: 20}), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,12 +118,7 @@ func TestLet(t *testing.T) {
|
||||
}, func(s State) int { return s.x * 2 }),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{x: 5, computed: 10}, value)
|
||||
assert.Equal(t, Of(State{x: 5, computed: 10}), result)
|
||||
})
|
||||
|
||||
t.Run("preserves failure", func(t *testing.T) {
|
||||
@@ -180,12 +155,7 @@ func TestLet(t *testing.T) {
|
||||
}, func(s State) int { return s.z * 3 }),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{x: 60, y: 10, z: 20}, value)
|
||||
assert.Equal(t, Of(State{x: 60, y: 10, z: 20}), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,12 +173,7 @@ func TestLetTo(t *testing.T) {
|
||||
}, "example"),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{x: 5, name: "example"}, value)
|
||||
assert.Equal(t, Of(State{x: 5, name: "example"}), result)
|
||||
})
|
||||
|
||||
t.Run("preserves failure", func(t *testing.T) {
|
||||
@@ -239,12 +204,7 @@ func TestLetTo(t *testing.T) {
|
||||
}, true),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{name: "app", version: 2, active: true}, value)
|
||||
assert.Equal(t, Of(State{name: "app", version: 2, active: true}), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -259,12 +219,7 @@ func TestBindTo(t *testing.T) {
|
||||
BindTo(func(x int) State { return State{value: x} }),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{value: 42}, value)
|
||||
assert.Equal(t, Of(State{value: 42}), result)
|
||||
})
|
||||
|
||||
t.Run("preserves failure", func(t *testing.T) {
|
||||
@@ -289,12 +244,7 @@ func TestBindTo(t *testing.T) {
|
||||
BindTo(func(s string) StringState { return StringState{text: s} }),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) StringState { return StringState{} },
|
||||
F.Identity[StringState],
|
||||
)
|
||||
assert.Equal(t, StringState{text: "hello"}, value)
|
||||
assert.Equal(t, Of(StringState{text: "hello"}), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -312,12 +262,7 @@ func TestApS(t *testing.T) {
|
||||
}, Success(42)),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{x: 42}, value)
|
||||
assert.Equal(t, Of(State{x: 42}), result)
|
||||
})
|
||||
|
||||
t.Run("accumulates errors from both validations", func(t *testing.T) {
|
||||
@@ -350,12 +295,7 @@ func TestApS(t *testing.T) {
|
||||
}, Success(20)),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) State { return State{} },
|
||||
F.Identity[State],
|
||||
)
|
||||
assert.Equal(t, State{x: 10, y: 20}, value)
|
||||
assert.Equal(t, Of(State{x: 10, y: 20}), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -384,14 +324,11 @@ func TestApSL(t *testing.T) {
|
||||
),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Person { return Person{} },
|
||||
F.Identity[Person],
|
||||
)
|
||||
assert.Equal(t, "Alice", value.Name)
|
||||
assert.Equal(t, "Main St", value.Address.Street)
|
||||
assert.Equal(t, "NYC", value.Address.City)
|
||||
expected := Person{
|
||||
Name: "Alice",
|
||||
Address: Address{Street: "Main St", City: "NYC"},
|
||||
}
|
||||
assert.Equal(t, Of(expected), result)
|
||||
})
|
||||
|
||||
t.Run("accumulates errors", func(t *testing.T) {
|
||||
@@ -434,12 +371,7 @@ func TestBindL(t *testing.T) {
|
||||
BindL(valueLens, increment),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Counter { return Counter{} },
|
||||
F.Identity[Counter],
|
||||
)
|
||||
assert.Equal(t, Counter{Value: 43}, value)
|
||||
assert.Equal(t, Of(Counter{Value: 43}), result)
|
||||
})
|
||||
|
||||
t.Run("fails validation based on current value", func(t *testing.T) {
|
||||
@@ -494,12 +426,7 @@ func TestLetL(t *testing.T) {
|
||||
LetL(valueLens, double),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Counter { return Counter{} },
|
||||
F.Identity[Counter],
|
||||
)
|
||||
assert.Equal(t, Counter{Value: 42}, value)
|
||||
assert.Equal(t, Of(Counter{Value: 42}), result)
|
||||
})
|
||||
|
||||
t.Run("preserves failure", func(t *testing.T) {
|
||||
@@ -521,12 +448,7 @@ func TestLetL(t *testing.T) {
|
||||
LetL(valueLens, double),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Counter { return Counter{} },
|
||||
F.Identity[Counter],
|
||||
)
|
||||
assert.Equal(t, Counter{Value: 30}, value)
|
||||
assert.Equal(t, Of(Counter{Value: 30}), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,12 +469,7 @@ func TestLetToL(t *testing.T) {
|
||||
LetToL(debugLens, false),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Config { return Config{} },
|
||||
F.Identity[Config],
|
||||
)
|
||||
assert.Equal(t, Config{Debug: false, Timeout: 30}, value)
|
||||
assert.Equal(t, Of(Config{Debug: false, Timeout: 30}), result)
|
||||
})
|
||||
|
||||
t.Run("preserves failure", func(t *testing.T) {
|
||||
@@ -574,12 +491,7 @@ func TestLetToL(t *testing.T) {
|
||||
LetToL(timeoutLens, 60),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Config { return Config{} },
|
||||
F.Identity[Config],
|
||||
)
|
||||
assert.Equal(t, Config{Debug: false, Timeout: 60}, value)
|
||||
assert.Equal(t, Of(Config{Debug: false, Timeout: 60}), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -622,13 +534,7 @@ func TestBindOperationsComposition(t *testing.T) {
|
||||
}),
|
||||
)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) User { return User{} },
|
||||
F.Identity[User],
|
||||
)
|
||||
assert.Equal(t, "Alice", value.Name)
|
||||
assert.Equal(t, 25, value.Age)
|
||||
assert.Equal(t, "Alice@example.com", value.Email)
|
||||
expected := User{Name: "Alice", Age: 25, Email: "Alice@example.com"}
|
||||
assert.Equal(t, Of(expected), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/array"
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/applicative"
|
||||
)
|
||||
|
||||
var errorsMonoid = ErrorsMonoid()
|
||||
|
||||
// Of creates a successful validation result containing the given value.
|
||||
// This is the pure/return operation for the Validation monad.
|
||||
//
|
||||
@@ -28,37 +32,376 @@ func Of[A any](a A) Validation[A] {
|
||||
// return func(age int) User { return User{name, age} }
|
||||
// }))(validateName))(validateAge)
|
||||
func Ap[B, A any](fa Validation[A]) Operator[func(A) B, B] {
|
||||
return either.ApV[B, A](ErrorsMonoid())(fa)
|
||||
return either.ApV[B, A](errorsMonoid)(fa)
|
||||
}
|
||||
|
||||
// MonadAp applies a validation containing a function to a validation containing a value.
|
||||
// This is the applicative apply operation that **accumulates errors** from both validations.
|
||||
//
|
||||
// **Key behavior**: Unlike Either's MonadAp which fails fast (returns first error),
|
||||
// this validation-specific implementation **accumulates all errors** using the Errors monoid.
|
||||
// When both the function validation and value validation fail, all errors from both are combined.
|
||||
//
|
||||
// This error accumulation is the defining characteristic of the Validation applicative,
|
||||
// making it ideal for scenarios where you want to collect all validation failures at once
|
||||
// rather than stopping at the first error.
|
||||
//
|
||||
// Behavior:
|
||||
// - Both succeed: applies the function to the value → Success(result)
|
||||
// - Function fails, value succeeds: returns function's errors → Failure(func errors)
|
||||
// - Function succeeds, value fails: returns value's errors → Failure(value errors)
|
||||
// - Both fail: **combines all errors** → Failure(func errors + value errors)
|
||||
//
|
||||
// This is particularly useful for:
|
||||
// - Form validation: collect all field errors at once
|
||||
// - Configuration validation: report all invalid settings together
|
||||
// - Data validation: accumulate all constraint violations
|
||||
// - Multi-field validation: validate independent fields in parallel
|
||||
//
|
||||
// Example - Both succeed:
|
||||
//
|
||||
// double := func(x int) int { return x * 2 }
|
||||
// result := MonadAp(Of(double), Of(21))
|
||||
// // Result: Success(42)
|
||||
//
|
||||
// Example - Error accumulation (key feature):
|
||||
//
|
||||
// funcValidation := Failures[func(int) int](Errors{
|
||||
// &ValidationError{Messsage: "function error"},
|
||||
// })
|
||||
// valueValidation := Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "value error"},
|
||||
// })
|
||||
// result := MonadAp(funcValidation, valueValidation)
|
||||
// // Result: Failure with BOTH errors: ["function error", "value error"]
|
||||
//
|
||||
// Example - Validating multiple fields:
|
||||
//
|
||||
// type User struct {
|
||||
// Name string
|
||||
// Age int
|
||||
// }
|
||||
//
|
||||
// makeUser := func(name string) func(int) User {
|
||||
// return func(age int) User { return User{name, age} }
|
||||
// }
|
||||
//
|
||||
// nameValidation := validateName("ab") // Fails: too short
|
||||
// ageValidation := validateAge(16) // Fails: too young
|
||||
//
|
||||
// // First apply name
|
||||
// step1 := MonadAp(Of(makeUser), nameValidation)
|
||||
// // Then apply age
|
||||
// result := MonadAp(step1, ageValidation)
|
||||
// // Result contains ALL validation errors from both fields
|
||||
func MonadAp[B, A any](fab Validation[func(A) B], fa Validation[A]) Validation[B] {
|
||||
return either.MonadApV[B, A](ErrorsMonoid())(fab, fa)
|
||||
return either.MonadApV[B, A](errorsMonoid)(fab, fa)
|
||||
}
|
||||
|
||||
// Map transforms the value inside a successful validation using the provided function.
|
||||
// If the validation is a failure, the errors are preserved unchanged.
|
||||
// This is the functor map operation for Validation.
|
||||
//
|
||||
// Example:
|
||||
// Map is used for transforming successful values without changing the validation context.
|
||||
// It's the most basic operation for working with validated values and forms the foundation
|
||||
// for more complex validation pipelines.
|
||||
//
|
||||
// Behavior:
|
||||
// - Success: applies function to value → Success(f(value))
|
||||
// - Failure: preserves errors unchanged → Failure(same errors)
|
||||
//
|
||||
// This is useful for:
|
||||
// - Type transformations: converting validated values to different types
|
||||
// - Value transformations: normalizing, formatting, or computing derived values
|
||||
// - Pipeline composition: chaining multiple transformations
|
||||
// - Preserving validation context: errors pass through unchanged
|
||||
//
|
||||
// Example - Transform successful value:
|
||||
//
|
||||
// doubled := Map(func(x int) int { return x * 2 })(Of(21))
|
||||
// // Result: Success(42)
|
||||
//
|
||||
// Example - Failure preserved:
|
||||
//
|
||||
// result := Map(func(x int) int { return x * 2 })(
|
||||
// Failures[int](Errors{&ValidationError{Messsage: "invalid"}}),
|
||||
// )
|
||||
// // Result: Failure with same error: ["invalid"]
|
||||
//
|
||||
// Example - Type transformation:
|
||||
//
|
||||
// toString := Map(func(x int) string { return fmt.Sprintf("%d", x) })
|
||||
// result := toString(Of(42))
|
||||
// // Result: Success("42")
|
||||
//
|
||||
// Example - Chaining transformations:
|
||||
//
|
||||
// result := F.Pipe3(
|
||||
// Of(5),
|
||||
// Map(func(x int) int { return x + 10 }), // 15
|
||||
// Map(func(x int) int { return x * 2 }), // 30
|
||||
// Map(func(x int) string { return fmt.Sprintf("%d", x) }), // "30"
|
||||
// )
|
||||
// // Result: Success("30")
|
||||
func Map[A, B any](f func(A) B) Operator[A, B] {
|
||||
return either.Map[Errors](f)
|
||||
}
|
||||
|
||||
// MonadMap transforms the value inside a successful validation using the provided function.
|
||||
// If the validation is a failure, the errors are preserved unchanged.
|
||||
// This is the non-curried version of [Map].
|
||||
//
|
||||
// MonadMap is useful when you have both the validation and the transformation function
|
||||
// available at the same time, rather than needing to create a reusable operator.
|
||||
//
|
||||
// Behavior:
|
||||
// - Success: applies function to value → Success(f(value))
|
||||
// - Failure: preserves errors unchanged → Failure(same errors)
|
||||
//
|
||||
// Example - Transform successful value:
|
||||
//
|
||||
// result := MonadMap(Of(21), func(x int) int { return x * 2 })
|
||||
// // Result: Success(42)
|
||||
//
|
||||
// Example - Failure preserved:
|
||||
//
|
||||
// result := MonadMap(
|
||||
// Failures[int](Errors{&ValidationError{Messsage: "invalid"}}),
|
||||
// func(x int) int { return x * 2 },
|
||||
// )
|
||||
// // Result: Failure with same error: ["invalid"]
|
||||
//
|
||||
// Example - Type transformation:
|
||||
//
|
||||
// result := MonadMap(Of(42), func(x int) string {
|
||||
// return fmt.Sprintf("Value: %d", x)
|
||||
// })
|
||||
// // Result: Success("Value: 42")
|
||||
//
|
||||
// Example - Computing derived values:
|
||||
//
|
||||
// type User struct { FirstName, LastName string }
|
||||
// result := MonadMap(
|
||||
// Of(User{"John", "Doe"}),
|
||||
// func(u User) string { return u.FirstName + " " + u.LastName },
|
||||
// )
|
||||
// // Result: Success("John Doe")
|
||||
func MonadMap[A, B any](fa Validation[A], f func(A) B) Validation[B] {
|
||||
return either.MonadMap(fa, f)
|
||||
}
|
||||
|
||||
// Chain is the curried version of [MonadChain].
|
||||
// Sequences two validation computations where the second depends on the first.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// validatePositive := func(x int) Validation[int] {
|
||||
// if x > 0 { return Success(x) }
|
||||
// return Failure("must be positive")
|
||||
// }
|
||||
// result := Chain(validatePositive)(Success(42)) // Success(42)
|
||||
func Chain[A, B any](f Kleisli[A, B]) Operator[A, B] {
|
||||
return either.Chain(f)
|
||||
}
|
||||
|
||||
// MonadChain sequences two validation computations where the second depends on the first.
|
||||
// If the first validation fails, returns the failure without executing the second.
|
||||
// This is the monadic bind operation for Validation.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := MonadChain(
|
||||
// Success(42),
|
||||
// func(x int) Validation[string] {
|
||||
// return Success(fmt.Sprintf("Value: %d", x))
|
||||
// },
|
||||
// ) // Success("Value: 42")
|
||||
func MonadChain[A, B any](fa Validation[A], f Kleisli[A, B]) Validation[B] {
|
||||
return either.MonadChain(fa, f)
|
||||
}
|
||||
|
||||
// chainErrors is an internal helper that chains error transformations while accumulating errors.
|
||||
// When the transformation function f returns a failure, it concatenates the original errors (e1)
|
||||
// with the new errors (e2) using the Errors monoid, ensuring all validation errors are preserved.
|
||||
func chainErrors[A any](f Kleisli[Errors, A]) func(Errors) Validation[A] {
|
||||
return func(e1 Errors) Validation[A] {
|
||||
return either.MonadFold(
|
||||
f(e1),
|
||||
function.Flow2(array.Concat(e1), either.Left[A]),
|
||||
Of[A],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ChainLeft is the curried version of [MonadChainLeft].
|
||||
// Returns a function that transforms validation failures while preserving successes.
|
||||
//
|
||||
// Unlike the standard Either ChainLeft which replaces errors, this validation-specific
|
||||
// implementation **aggregates errors** using the Errors monoid. When the transformation
|
||||
// function returns a failure, both the original errors and the new errors are combined,
|
||||
// ensuring no validation errors are lost.
|
||||
//
|
||||
// This is particularly useful for:
|
||||
// - Error recovery with fallback validation
|
||||
// - Adding contextual information to existing errors
|
||||
// - Transforming error types while preserving all error details
|
||||
// - Building error handling pipelines that accumulate failures
|
||||
//
|
||||
// Key behavior:
|
||||
// - Success values pass through unchanged
|
||||
// - When transforming failures, if the transformation also fails, **all errors are aggregated**
|
||||
// - If the transformation succeeds, it recovers from the original failure
|
||||
//
|
||||
// Example - Error recovery with aggregation:
|
||||
//
|
||||
// recoverFromNotFound := ChainLeft(func(errs Errors) Validation[int] {
|
||||
// // Check if this is a "not found" error
|
||||
// for _, err := range errs {
|
||||
// if err.Messsage == "not found" {
|
||||
// return Success(0) // recover with default
|
||||
// }
|
||||
// }
|
||||
// // Add context to existing errors
|
||||
// return Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "recovery failed"},
|
||||
// })
|
||||
// // Result will contain BOTH original errors AND "recovery failed"
|
||||
// })
|
||||
//
|
||||
// result := recoverFromNotFound(Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "database error"},
|
||||
// }))
|
||||
// // Result contains: ["database error", "recovery failed"]
|
||||
//
|
||||
// Example - Adding context to errors:
|
||||
//
|
||||
// addContext := ChainLeft(func(errs Errors) Validation[string] {
|
||||
// // Add contextual information
|
||||
// return Failures[string](Errors{
|
||||
// &ValidationError{
|
||||
// Messsage: "validation failed in user.email field",
|
||||
// },
|
||||
// })
|
||||
// // Original errors are preserved and new context is added
|
||||
// })
|
||||
//
|
||||
// result := F.Pipe1(
|
||||
// Failures[string](Errors{
|
||||
// &ValidationError{Messsage: "invalid format"},
|
||||
// }),
|
||||
// addContext,
|
||||
// )
|
||||
// // Result contains: ["invalid format", "validation failed in user.email field"]
|
||||
//
|
||||
// Example - Success values pass through:
|
||||
//
|
||||
// handler := ChainLeft(func(errs Errors) Validation[int] {
|
||||
// return Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "never called"},
|
||||
// })
|
||||
// })
|
||||
// result := handler(Success(42)) // Success(42) - unchanged
|
||||
func ChainLeft[A any](f Kleisli[Errors, A]) Operator[A, A] {
|
||||
return either.Fold(
|
||||
chainErrors(f),
|
||||
Of[A],
|
||||
)
|
||||
}
|
||||
|
||||
// MonadChainLeft sequences a computation on the failure (Left) channel of a Validation.
|
||||
// If the Validation is a failure, applies the function to transform or recover from the errors.
|
||||
// If the Validation is a success, returns the success value unchanged.
|
||||
//
|
||||
// **Critical difference from Either.MonadChainLeft**: This validation-specific implementation
|
||||
// **aggregates errors** using the Errors monoid. When the transformation function returns a
|
||||
// failure, both the original errors and the new errors are combined, ensuring comprehensive
|
||||
// error reporting.
|
||||
//
|
||||
// This is the dual of [MonadChain] - while Chain operates on success values, ChainLeft
|
||||
// operates on failure values. It's particularly useful for:
|
||||
// - Error recovery: converting specific errors into successful values
|
||||
// - Error enrichment: adding context or transforming error messages
|
||||
// - Fallback logic: providing alternative validations when the first fails
|
||||
// - Error aggregation: combining multiple validation failures
|
||||
//
|
||||
// The function parameter receives the collection of validation errors and must return
|
||||
// a new Validation[A]. This allows you to:
|
||||
// - Recover by returning Success(value)
|
||||
// - Transform errors by returning Failures(newErrors) - **original errors are preserved**
|
||||
// - Implement conditional error handling based on error content
|
||||
//
|
||||
// Example - Error recovery:
|
||||
//
|
||||
// result := MonadChainLeft(
|
||||
// Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "not found"},
|
||||
// }),
|
||||
// func(errs Errors) Validation[int] {
|
||||
// // Check if we can recover
|
||||
// for _, err := range errs {
|
||||
// if err.Messsage == "not found" {
|
||||
// return Success(0) // recover with default value
|
||||
// }
|
||||
// }
|
||||
// return Failures[int](errs) // propagate errors
|
||||
// },
|
||||
// ) // Success(0)
|
||||
//
|
||||
// Example - Error aggregation (key feature):
|
||||
//
|
||||
// result := MonadChainLeft(
|
||||
// Failures[string](Errors{
|
||||
// &ValidationError{Messsage: "error 1"},
|
||||
// &ValidationError{Messsage: "error 2"},
|
||||
// }),
|
||||
// func(errs Errors) Validation[string] {
|
||||
// // Transformation also fails
|
||||
// return Failures[string](Errors{
|
||||
// &ValidationError{Messsage: "error 3"},
|
||||
// })
|
||||
// },
|
||||
// )
|
||||
// // Result contains ALL errors: ["error 1", "error 2", "error 3"]
|
||||
// // This is different from Either.MonadChainLeft which would only keep "error 3"
|
||||
//
|
||||
// Example - Adding context to errors:
|
||||
//
|
||||
// result := MonadChainLeft(
|
||||
// Failures[int](Errors{
|
||||
// &ValidationError{Value: "abc", Messsage: "invalid number"},
|
||||
// }),
|
||||
// func(errs Errors) Validation[int] {
|
||||
// // Add contextual information
|
||||
// contextErrors := Errors{
|
||||
// &ValidationError{
|
||||
// Context: []ContextEntry{{Key: "user", Type: "User"}, {Key: "age", Type: "int"}},
|
||||
// Messsage: "failed to parse user age",
|
||||
// },
|
||||
// }
|
||||
// return Failures[int](contextErrors)
|
||||
// },
|
||||
// )
|
||||
// // Result contains both original error and context:
|
||||
// // ["invalid number", "failed to parse user age"]
|
||||
//
|
||||
// Example - Success values pass through:
|
||||
//
|
||||
// result := MonadChainLeft(
|
||||
// Success(42),
|
||||
// func(errs Errors) Validation[int] {
|
||||
// return Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "never called"},
|
||||
// })
|
||||
// },
|
||||
// ) // Success(42) - unchanged
|
||||
func MonadChainLeft[A any](fa Validation[A], f Kleisli[Errors, A]) Validation[A] {
|
||||
return either.MonadFold(
|
||||
fa,
|
||||
chainErrors(f),
|
||||
Of[A],
|
||||
)
|
||||
}
|
||||
|
||||
// Applicative creates an Applicative instance for Validation with error accumulation.
|
||||
//
|
||||
// This returns a lawful Applicative that accumulates validation errors using the Errors monoid.
|
||||
@@ -123,6 +466,175 @@ func MonadChain[A, B any](fa Validation[A], f Kleisli[A, B]) Validation[B] {
|
||||
// An Applicative instance with Of, Map, and Ap operations that accumulate errors
|
||||
func Applicative[A, B any]() applicative.Applicative[A, B, Validation[A], Validation[B], Validation[func(A) B]] {
|
||||
return either.ApplicativeV[Errors, A, B](
|
||||
ErrorsMonoid(),
|
||||
errorsMonoid,
|
||||
)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func OrElse[A any](f Kleisli[Errors, A]) Operator[A, A] {
|
||||
return ChainLeft(f)
|
||||
}
|
||||
|
||||
// MonadAlt implements the Alternative operation for Validation, providing fallback behavior.
|
||||
// If the first validation fails, it evaluates and returns the second validation as an alternative.
|
||||
// If the first validation succeeds, it returns the first validation without evaluating the second.
|
||||
//
|
||||
// This is the fundamental operation for the Alt typeclass, enabling "try first, fallback to second"
|
||||
// semantics. It's particularly useful for:
|
||||
// - Providing default values when validation fails
|
||||
// - Trying multiple validation strategies in sequence
|
||||
// - Building validation pipelines with fallback logic
|
||||
// - Implementing optional validation with defaults
|
||||
//
|
||||
// **Key behavior**: Unlike error accumulation in [MonadAp], MonadAlt does NOT accumulate errors.
|
||||
// When falling back to the second validation, the first validation's errors are discarded.
|
||||
// This is the standard Alt behavior - it's about choosing alternatives, not combining errors.
|
||||
//
|
||||
// The second parameter is lazy (Lazy[Validation[A]]) to avoid unnecessary computation when
|
||||
// the first validation succeeds. The second validation is only evaluated if needed.
|
||||
//
|
||||
// Behavior:
|
||||
// - First succeeds: returns first validation (second is not evaluated)
|
||||
// - First fails: evaluates and returns second validation (first errors discarded)
|
||||
//
|
||||
// This is useful for:
|
||||
// - Fallback values: provide defaults when primary validation fails
|
||||
// - Alternative strategies: try different validation approaches
|
||||
// - Optional validation: make validation optional with a default
|
||||
// - Chaining attempts: try multiple sources until one succeeds
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the successful value
|
||||
//
|
||||
// Parameters:
|
||||
// - first: The primary validation to try
|
||||
// - second: A lazy computation producing the fallback validation (only evaluated if first fails)
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// The first validation if it succeeds, otherwise the second validation
|
||||
//
|
||||
// Example - Fallback to default:
|
||||
//
|
||||
// primary := parseConfig("config.json") // Fails
|
||||
// fallback := func() Validation[Config] {
|
||||
// return Success(defaultConfig)
|
||||
// }
|
||||
// result := MonadAlt(primary, fallback)
|
||||
// // Result: Success(defaultConfig)
|
||||
//
|
||||
// Example - First succeeds (second not evaluated):
|
||||
//
|
||||
// primary := Success(42)
|
||||
// fallback := func() Validation[int] {
|
||||
// panic("never called") // This won't execute
|
||||
// }
|
||||
// result := MonadAlt(primary, fallback)
|
||||
// // Result: Success(42)
|
||||
//
|
||||
// Example - Chaining multiple alternatives:
|
||||
//
|
||||
// result := MonadAlt(
|
||||
// parseFromEnv("API_KEY"),
|
||||
// func() Validation[string] {
|
||||
// return MonadAlt(
|
||||
// parseFromFile(".env"),
|
||||
// func() Validation[string] {
|
||||
// return Success("default-key")
|
||||
// },
|
||||
// )
|
||||
// },
|
||||
// )
|
||||
// // Tries: env var → file → default (uses first that succeeds)
|
||||
//
|
||||
// Example - No error accumulation:
|
||||
//
|
||||
// v1 := Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "error 1"},
|
||||
// &ValidationError{Messsage: "error 2"},
|
||||
// })
|
||||
// v2 := func() Validation[int] {
|
||||
// return Failures[int](Errors{
|
||||
// &ValidationError{Messsage: "error 3"},
|
||||
// })
|
||||
// }
|
||||
// result := MonadAlt(v1, v2)
|
||||
// // Result: Failures with only ["error 3"]
|
||||
// // The errors from v1 are discarded (not accumulated)
|
||||
func MonadAlt[A any](first Validation[A], second Lazy[Validation[A]]) Validation[A] {
|
||||
return MonadChainLeft(first, function.Ignore1of1[Errors](second))
|
||||
}
|
||||
|
||||
// Alt is the curried version of [MonadAlt].
|
||||
// Returns a function that provides fallback behavior for a Validation.
|
||||
//
|
||||
// This is useful for creating reusable fallback operators that can be applied
|
||||
// to multiple validations, or for use in function composition pipelines.
|
||||
//
|
||||
// The returned function takes a validation and returns either that validation
|
||||
// (if successful) or the provided alternative (if the validation fails).
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the successful value
|
||||
//
|
||||
// Parameters:
|
||||
// - second: A lazy computation producing the fallback validation
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// A function that takes a Validation[A] and returns a Validation[A] with fallback behavior
|
||||
//
|
||||
// Example - Creating a reusable fallback operator:
|
||||
//
|
||||
// withDefault := Alt(func() Validation[int] {
|
||||
// return Success(0)
|
||||
// })
|
||||
//
|
||||
// result1 := withDefault(parseNumber("42")) // Success(42)
|
||||
// result2 := withDefault(parseNumber("abc")) // Success(0) - fallback
|
||||
// result3 := withDefault(parseNumber("123")) // Success(123)
|
||||
//
|
||||
// Example - Using in a pipeline:
|
||||
//
|
||||
// import F "github.com/IBM/fp-go/v2/function"
|
||||
//
|
||||
// result := F.Pipe2(
|
||||
// parseFromEnv("CONFIG_PATH"),
|
||||
// Alt(func() Validation[string] {
|
||||
// return parseFromFile("config.json")
|
||||
// }),
|
||||
// Alt(func() Validation[string] {
|
||||
// return Success("./default-config.json")
|
||||
// }),
|
||||
// )
|
||||
// // Tries: env var → file → default path
|
||||
//
|
||||
// Example - Combining with Map:
|
||||
//
|
||||
// import F "github.com/IBM/fp-go/v2/function"
|
||||
//
|
||||
// result := F.Pipe2(
|
||||
// validatePositive(-5), // Fails
|
||||
// Alt(func() Validation[int] { return Success(1) }),
|
||||
// Map(func(x int) int { return x * 2 }),
|
||||
// )
|
||||
// // Result: Success(2) - uses fallback value 1, then doubles it
|
||||
//
|
||||
// Example - Multiple fallback layers:
|
||||
//
|
||||
// primaryFallback := Alt(func() Validation[Config] {
|
||||
// return loadFromFile("backup.json")
|
||||
// })
|
||||
// secondaryFallback := Alt(func() Validation[Config] {
|
||||
// return Success(defaultConfig)
|
||||
// })
|
||||
//
|
||||
// result := F.Pipe2(
|
||||
// loadFromFile("config.json"),
|
||||
// primaryFallback,
|
||||
// secondaryFallback,
|
||||
// )
|
||||
// // Tries: config.json → backup.json → default
|
||||
func Alt[A any](second Lazy[Validation[A]]) Operator[A, A] {
|
||||
return ChainLeft(function.Ignore1of1[Errors](second))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,3 +52,177 @@ func ApplicativeMonoid[A any](m Monoid[A]) Monoid[Validation[A]] {
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
// AlternativeMonoid creates a Monoid instance for Validation[A] using the Alternative pattern.
|
||||
// This combines the applicative error-accumulation behavior with the alternative fallback behavior,
|
||||
// allowing you to both accumulate errors and provide fallback alternatives.
|
||||
//
|
||||
// The Alternative pattern provides two key operations:
|
||||
// - Applicative operations (Of, Map, Ap): accumulate errors when combining validations
|
||||
// - Alternative operation (Alt): provide fallback when a validation fails
|
||||
//
|
||||
// This monoid is particularly useful when you want to:
|
||||
// - Try multiple validation strategies and fall back to alternatives
|
||||
// - Combine successful values using the provided monoid
|
||||
// - Accumulate all errors from failed attempts
|
||||
// - Build validation pipelines with fallback logic
|
||||
//
|
||||
// The resulting monoid:
|
||||
// - Empty: Returns a successful validation with the empty value from the inner monoid
|
||||
// - Concat: Combines two validations using both applicative and alternative semantics:
|
||||
// - If first succeeds and second succeeds: combines values using inner monoid
|
||||
// - If first fails: tries second as fallback (alternative behavior)
|
||||
// - If both fail: accumulates all errors
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the successful value
|
||||
//
|
||||
// Parameters:
|
||||
// - m: The monoid for combining successful values of type A
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// A Monoid[Validation[A]] that combines applicative and alternative behaviors
|
||||
//
|
||||
// Example - Combining successful validations:
|
||||
//
|
||||
// import "github.com/IBM/fp-go/v2/string"
|
||||
//
|
||||
// m := AlternativeMonoid(string.Monoid)
|
||||
// v1 := Success("Hello")
|
||||
// v2 := Success(" World")
|
||||
// result := m.Concat(v1, v2)
|
||||
// // Result: Success("Hello World")
|
||||
//
|
||||
// Example - Fallback behavior:
|
||||
//
|
||||
// m := AlternativeMonoid(string.Monoid)
|
||||
// v1 := Failures[string](Errors{&ValidationError{Messsage: "first failed"}})
|
||||
// v2 := Success("fallback value")
|
||||
// result := m.Concat(v1, v2)
|
||||
// // Result: Success("fallback value") - second validation used as fallback
|
||||
//
|
||||
// Example - Error accumulation when both fail:
|
||||
//
|
||||
// m := AlternativeMonoid(string.Monoid)
|
||||
// v1 := Failures[string](Errors{&ValidationError{Messsage: "error 1"}})
|
||||
// v2 := Failures[string](Errors{&ValidationError{Messsage: "error 2"}})
|
||||
// result := m.Concat(v1, v2)
|
||||
// // Result: Failures with accumulated errors: ["error 1", "error 2"]
|
||||
//
|
||||
// Example - Building validation with fallbacks:
|
||||
//
|
||||
// import N "github.com/IBM/fp-go/v2/number"
|
||||
//
|
||||
// m := AlternativeMonoid(N.MonoidSum[int]())
|
||||
//
|
||||
// // Try to parse from different sources
|
||||
// fromEnv := parseFromEnv() // Fails
|
||||
// fromConfig := parseFromConfig() // Succeeds with 42
|
||||
// fromDefault := Success(0) // Default fallback
|
||||
//
|
||||
// result := m.Concat(m.Concat(fromEnv, fromConfig), fromDefault)
|
||||
// // Result: Success(42) - uses first successful validation
|
||||
func AlternativeMonoid[A any](m Monoid[A]) Monoid[Validation[A]] {
|
||||
return M.AlternativeMonoid(
|
||||
Of[A],
|
||||
MonadMap[A, func(A) A],
|
||||
MonadAp[A, A],
|
||||
MonadAlt[A],
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
// AltMonoid creates a Monoid instance for Validation[A] using the Alt (alternative) operation.
|
||||
// This monoid provides a way to combine validations with fallback behavior, where the second
|
||||
// validation is used as an alternative if the first one fails.
|
||||
//
|
||||
// The Alt operation implements the "try first, fallback to second" pattern, which is useful
|
||||
// for validation scenarios where you want to attempt multiple validation strategies in sequence
|
||||
// and use the first one that succeeds.
|
||||
//
|
||||
// The resulting monoid:
|
||||
// - Empty: Returns the provided zero value (a lazy computation that produces a Validation[A])
|
||||
// - Concat: Combines two validations using Alt semantics:
|
||||
// - If first succeeds: returns the first validation (ignores second)
|
||||
// - If first fails: returns the second validation as fallback
|
||||
//
|
||||
// This is different from [AlternativeMonoid] in that:
|
||||
// - AltMonoid uses a custom zero value (provided by the user)
|
||||
// - AlternativeMonoid derives the zero from an inner monoid
|
||||
// - AltMonoid is simpler and only provides fallback behavior
|
||||
// - AlternativeMonoid combines applicative and alternative behaviors
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the successful value
|
||||
//
|
||||
// Parameters:
|
||||
// - zero: A lazy computation that produces the identity/empty Validation[A].
|
||||
// This is typically a successful validation with a default value, or could be
|
||||
// a failure representing "no validation attempted"
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// A Monoid[Validation[A]] that combines validations with fallback behavior
|
||||
//
|
||||
// Example - Using default value as zero:
|
||||
//
|
||||
// m := AltMonoid(func() Validation[int] { return Success(0) })
|
||||
//
|
||||
// v1 := Failures[int](Errors{&ValidationError{Messsage: "failed"}})
|
||||
// v2 := Success(42)
|
||||
//
|
||||
// result := m.Concat(v1, v2)
|
||||
// // Result: Success(42) - falls back to second validation
|
||||
//
|
||||
// empty := m.Empty()
|
||||
// // Result: Success(0) - the provided zero value
|
||||
//
|
||||
// Example - Chaining multiple fallbacks:
|
||||
//
|
||||
// m := AltMonoid(func() Validation[string] {
|
||||
// return Success("default")
|
||||
// })
|
||||
//
|
||||
// primary := parseFromPrimarySource() // Fails
|
||||
// secondary := parseFromSecondary() // Fails
|
||||
// tertiary := parseFromTertiary() // Succeeds with "value"
|
||||
//
|
||||
// result := m.Concat(m.Concat(primary, secondary), tertiary)
|
||||
// // Result: Success("value") - uses first successful validation
|
||||
//
|
||||
// Example - All validations fail:
|
||||
//
|
||||
// m := AltMonoid(func() Validation[int] {
|
||||
// return Failures[int](Errors{&ValidationError{Messsage: "no default"}})
|
||||
// })
|
||||
//
|
||||
// v1 := Failures[int](Errors{&ValidationError{Messsage: "error 1"}})
|
||||
// v2 := Failures[int](Errors{&ValidationError{Messsage: "error 2"}})
|
||||
//
|
||||
// result := m.Concat(v1, v2)
|
||||
// // Result: Failures with errors from v2: ["error 2"]
|
||||
// // Note: Unlike AlternativeMonoid, errors are NOT accumulated
|
||||
//
|
||||
// Example - Building a validation pipeline with fallbacks:
|
||||
//
|
||||
// m := AltMonoid(func() Validation[Config] {
|
||||
// return Success(defaultConfig)
|
||||
// })
|
||||
//
|
||||
// // Try multiple configuration sources in order
|
||||
// configs := []Validation[Config]{
|
||||
// loadFromFile("config.json"), // Try file first
|
||||
// loadFromEnv(), // Then environment
|
||||
// loadFromRemote("api.example.com"), // Then remote API
|
||||
// }
|
||||
//
|
||||
// // Fold using the monoid to get first successful config
|
||||
// result := A.MonoidFold(m)(configs)
|
||||
// // Result: First successful config, or defaultConfig if all fail
|
||||
func AltMonoid[A any](zero Lazy[Validation[A]]) Monoid[Validation[A]] {
|
||||
return M.AltMonoid(
|
||||
zero,
|
||||
MonadAlt[A],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,12 +74,7 @@ func TestApplicativeMonoid(t *testing.T) {
|
||||
t.Run("empty returns successful validation with empty string", func(t *testing.T) {
|
||||
empty := m.Empty()
|
||||
|
||||
assert.True(t, either.IsRight(empty))
|
||||
value := either.MonadFold(empty,
|
||||
func(Errors) string { return "ERROR" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "", value)
|
||||
assert.Equal(t, Success(""), empty)
|
||||
})
|
||||
|
||||
t.Run("concat combines successful validations", func(t *testing.T) {
|
||||
@@ -88,12 +83,7 @@ func TestApplicativeMonoid(t *testing.T) {
|
||||
|
||||
result := m.Concat(v1, v2)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "Hello World", value)
|
||||
assert.Equal(t, Success("Hello World"), result)
|
||||
})
|
||||
|
||||
t.Run("concat with failure returns failure", func(t *testing.T) {
|
||||
@@ -141,17 +131,8 @@ func TestApplicativeMonoid(t *testing.T) {
|
||||
result1 := m.Concat(v, empty)
|
||||
result2 := m.Concat(empty, v)
|
||||
|
||||
val1 := either.MonadFold(result1,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
val2 := either.MonadFold(result2,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
|
||||
assert.Equal(t, "test", val1)
|
||||
assert.Equal(t, "test", val2)
|
||||
assert.Equal(t, Of("test"), result1)
|
||||
assert.Equal(t, Of("test"), result2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,11 +147,7 @@ func TestApplicativeMonoid(t *testing.T) {
|
||||
t.Run("empty returns zero", func(t *testing.T) {
|
||||
empty := m.Empty()
|
||||
|
||||
value := either.MonadFold(empty,
|
||||
func(Errors) int { return -1 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 0, value)
|
||||
assert.Equal(t, Of(0), empty)
|
||||
})
|
||||
|
||||
t.Run("concat adds values", func(t *testing.T) {
|
||||
@@ -179,11 +156,7 @@ func TestApplicativeMonoid(t *testing.T) {
|
||||
|
||||
result := m.Concat(v1, v2)
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 42, value)
|
||||
assert.Equal(t, Of(42), result)
|
||||
})
|
||||
|
||||
t.Run("multiple concat operations", func(t *testing.T) {
|
||||
@@ -194,11 +167,7 @@ func TestApplicativeMonoid(t *testing.T) {
|
||||
|
||||
result := m.Concat(m.Concat(m.Concat(v1, v2), v3), v4)
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 10, value)
|
||||
assert.Equal(t, Of(10), result)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -245,21 +214,13 @@ func TestMonoidLaws(t *testing.T) {
|
||||
t.Run("left identity", func(t *testing.T) {
|
||||
// empty + a = a
|
||||
result := m.Concat(m.Empty(), v1)
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "a", value)
|
||||
assert.Equal(t, Of("a"), result)
|
||||
})
|
||||
|
||||
t.Run("right identity", func(t *testing.T) {
|
||||
// a + empty = a
|
||||
result := m.Concat(v1, m.Empty())
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "a", value)
|
||||
assert.Equal(t, Of("a"), result)
|
||||
})
|
||||
|
||||
t.Run("associativity", func(t *testing.T) {
|
||||
@@ -268,17 +229,8 @@ func TestMonoidLaws(t *testing.T) {
|
||||
left := m.Concat(m.Concat(v1, v2), v3)
|
||||
right := m.Concat(v1, m.Concat(v2, v3))
|
||||
|
||||
leftVal := either.MonadFold(left,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
rightVal := either.MonadFold(right,
|
||||
func(Errors) string { return "" },
|
||||
F.Identity[string],
|
||||
)
|
||||
|
||||
assert.Equal(t, "abc", leftVal)
|
||||
assert.Equal(t, "abc", rightVal)
|
||||
assert.Equal(t, Of("abc"), left)
|
||||
assert.Equal(t, Of("abc"), right)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -332,11 +284,7 @@ func TestApplicativeMonoidEdgeCases(t *testing.T) {
|
||||
|
||||
result := m.Concat(v1, v2)
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) Counter { return Counter{} },
|
||||
F.Identity[Counter],
|
||||
)
|
||||
assert.Equal(t, 15, value.Count)
|
||||
assert.Equal(t, Of(Counter{Count: 15}), result)
|
||||
})
|
||||
|
||||
t.Run("empty concat empty", func(t *testing.T) {
|
||||
@@ -344,10 +292,6 @@ func TestApplicativeMonoidEdgeCases(t *testing.T) {
|
||||
|
||||
result := m.Concat(m.Empty(), m.Empty())
|
||||
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) string { return "ERROR" },
|
||||
F.Identity[string],
|
||||
)
|
||||
assert.Equal(t, "", value)
|
||||
assert.Equal(t, Of(""), result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package validation
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/lazy"
|
||||
"github.com/IBM/fp-go/v2/monoid"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
@@ -257,4 +258,6 @@ type (
|
||||
// double := func(x int) int { return x * 2 } // Endomorphism[int]
|
||||
// result := LetL(lens, double)(Success(21)) // Success(42)
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
|
||||
Lazy[A any] = lazy.Lazy[A]
|
||||
)
|
||||
|
||||
@@ -186,12 +186,7 @@ func TestSuccess(t *testing.T) {
|
||||
t.Run("creates right either with value", func(t *testing.T) {
|
||||
result := Success(42)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(Errors) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 42, value)
|
||||
assert.Equal(t, Success(42), result)
|
||||
})
|
||||
|
||||
t.Run("works with different types", func(t *testing.T) {
|
||||
@@ -327,7 +322,7 @@ func TestValidationIntegration(t *testing.T) {
|
||||
&ValidationError{Value: "bad", Messsage: "error"},
|
||||
})
|
||||
|
||||
assert.True(t, either.IsRight(success))
|
||||
assert.Equal(t, Success(42), success)
|
||||
assert.True(t, either.IsLeft(failure))
|
||||
})
|
||||
|
||||
@@ -512,12 +507,7 @@ func TestToResult(t *testing.T) {
|
||||
|
||||
result := ToResult(validation)
|
||||
|
||||
assert.True(t, either.IsRight(result))
|
||||
value := either.MonadFold(result,
|
||||
func(error) int { return 0 },
|
||||
F.Identity[int],
|
||||
)
|
||||
assert.Equal(t, 42, value)
|
||||
assert.Equal(t, either.Of[error](42), result)
|
||||
})
|
||||
|
||||
t.Run("converts failed validation to result with error", func(t *testing.T) {
|
||||
@@ -569,23 +559,18 @@ func TestToResult(t *testing.T) {
|
||||
// String type
|
||||
strValidation := Success("hello")
|
||||
strResult := ToResult(strValidation)
|
||||
assert.True(t, either.IsRight(strResult))
|
||||
assert.Equal(t, either.Of[error]("hello"), strResult)
|
||||
|
||||
// Bool type
|
||||
boolValidation := Success(true)
|
||||
boolResult := ToResult(boolValidation)
|
||||
assert.True(t, either.IsRight(boolResult))
|
||||
assert.Equal(t, either.Of[error](true), boolResult)
|
||||
|
||||
// Struct type
|
||||
type User struct{ Name string }
|
||||
userValidation := Success(User{Name: "Alice"})
|
||||
userResult := ToResult(userValidation)
|
||||
assert.True(t, either.IsRight(userResult))
|
||||
user := either.MonadFold(userResult,
|
||||
func(error) User { return User{} },
|
||||
F.Identity[User],
|
||||
)
|
||||
assert.Equal(t, "Alice", user.Name)
|
||||
assert.Equal(t, either.Of[error](User{Name: "Alice"}), userResult)
|
||||
})
|
||||
|
||||
t.Run("preserves error context in result", func(t *testing.T) {
|
||||
|
||||
@@ -1104,6 +1104,8 @@ func After[R, E, A any](timestamp time.Time) Operator[R, E, A, A] {
|
||||
// If the ReaderIOEither is Left, it applies the provided function to the error value,
|
||||
// which returns a new ReaderIOEither that replaces the original.
|
||||
//
|
||||
// Note: OrElse is identical to [ChainLeft] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This is useful for error recovery, fallback logic, or chaining alternative IO computations
|
||||
// that need access to configuration or dependencies. The error type can be widened from E1 to E2.
|
||||
//
|
||||
|
||||
@@ -531,3 +531,205 @@ func TestReadIO(t *testing.T) {
|
||||
assert.Equal(t, E.Right[error](25), result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestChainLeftIdenticalToOrElse proves that ChainLeft and OrElse are identical functions.
|
||||
// This test verifies that both functions produce the same results for all scenarios with reader context:
|
||||
// - Left values with error recovery using reader context
|
||||
// - Left values with error transformation
|
||||
// - Right values passing through unchanged
|
||||
// - Error type widening
|
||||
func TestChainLeftIdenticalToOrElse(t *testing.T) {
|
||||
type Config struct {
|
||||
fallbackValue int
|
||||
retryEnabled bool
|
||||
}
|
||||
|
||||
// Test 1: Left value with error recovery using reader context
|
||||
t.Run("Left value recovery with reader - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
recoveryFn := func(e string) ReaderIOEither[Config, string, int] {
|
||||
if e == "recoverable" {
|
||||
return func(cfg Config) IOE.IOEither[string, int] {
|
||||
return IOE.Right[string](cfg.fallbackValue)
|
||||
}
|
||||
}
|
||||
return Left[Config, int](e)
|
||||
}
|
||||
|
||||
input := Left[Config, int]("recoverable")
|
||||
cfg := Config{fallbackValue: 42, retryEnabled: true}
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(recoveryFn)(input)(cfg)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(recoveryFn)(input)(cfg)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](42), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 2: Left value with error transformation
|
||||
t.Run("Left value transformation - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
transformFn := func(e string) ReaderIOEither[Config, string, int] {
|
||||
return Left[Config, int]("transformed: " + e)
|
||||
}
|
||||
|
||||
input := Left[Config, int]("original error")
|
||||
cfg := Config{fallbackValue: 0, retryEnabled: false}
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(transformFn)(input)(cfg)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(transformFn)(input)(cfg)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Left[int]("transformed: original error"), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 3: Right value - both should pass through unchanged
|
||||
t.Run("Right value passthrough - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
handlerFn := func(e string) ReaderIOEither[Config, string, int] {
|
||||
return Left[Config, int]("should not be called")
|
||||
}
|
||||
|
||||
input := Right[Config, string](100)
|
||||
cfg := Config{fallbackValue: 0, retryEnabled: false}
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(handlerFn)(input)(cfg)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(handlerFn)(input)(cfg)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](100), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 4: Error type widening
|
||||
t.Run("Error type widening - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
widenFn := func(e string) ReaderIOEither[Config, int, int] {
|
||||
return Left[Config, int](404)
|
||||
}
|
||||
|
||||
input := Left[Config, int]("not found")
|
||||
cfg := Config{fallbackValue: 0, retryEnabled: false}
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(widenFn)(input)(cfg)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(widenFn)(input)(cfg)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Left[int](404), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 5: Composition in pipeline with reader context
|
||||
t.Run("Pipeline composition with reader - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
recoveryFn := func(e string) ReaderIOEither[Config, string, int] {
|
||||
if e == "network error" {
|
||||
return func(cfg Config) IOE.IOEither[string, int] {
|
||||
if cfg.retryEnabled {
|
||||
return IOE.Right[string](cfg.fallbackValue)
|
||||
}
|
||||
return IOE.Left[int]("retry disabled")
|
||||
}
|
||||
}
|
||||
return Left[Config, int](e)
|
||||
}
|
||||
|
||||
input := Left[Config, int]("network error")
|
||||
cfg := Config{fallbackValue: 99, retryEnabled: true}
|
||||
|
||||
// Using ChainLeft in pipeline
|
||||
resultChainLeft := F.Pipe1(input, ChainLeft(recoveryFn))(cfg)()
|
||||
|
||||
// Using OrElse in pipeline
|
||||
resultOrElse := F.Pipe1(input, OrElse(recoveryFn))(cfg)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](99), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 6: Multiple chained operations with reader context
|
||||
t.Run("Multiple operations with reader - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
handler1 := func(e string) ReaderIOEither[Config, string, int] {
|
||||
if e == "error1" {
|
||||
return Right[Config, string](1)
|
||||
}
|
||||
return Left[Config, int](e)
|
||||
}
|
||||
|
||||
handler2 := func(e string) ReaderIOEither[Config, string, int] {
|
||||
if e == "error2" {
|
||||
return func(cfg Config) IOE.IOEither[string, int] {
|
||||
return IOE.Right[string](cfg.fallbackValue)
|
||||
}
|
||||
}
|
||||
return Left[Config, int](e)
|
||||
}
|
||||
|
||||
input := Left[Config, int]("error2")
|
||||
cfg := Config{fallbackValue: 2, retryEnabled: false}
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := F.Pipe2(
|
||||
input,
|
||||
ChainLeft(handler1),
|
||||
ChainLeft(handler2),
|
||||
)(cfg)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := F.Pipe2(
|
||||
input,
|
||||
OrElse(handler1),
|
||||
OrElse(handler2),
|
||||
)(cfg)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](2), resultChainLeft)
|
||||
})
|
||||
|
||||
// Test 7: Reader context is properly threaded through both functions
|
||||
t.Run("Reader context threading - ChainLeft equals OrElse", func(t *testing.T) {
|
||||
var chainLeftCfg, orElseCfg *Config
|
||||
|
||||
recoveryFn := func(e string) ReaderIOEither[Config, string, int] {
|
||||
return func(cfg Config) IOE.IOEither[string, int] {
|
||||
// Capture the config to verify it's passed correctly
|
||||
if chainLeftCfg == nil {
|
||||
chainLeftCfg = &cfg
|
||||
} else {
|
||||
orElseCfg = &cfg
|
||||
}
|
||||
return IOE.Right[string](cfg.fallbackValue)
|
||||
}
|
||||
}
|
||||
|
||||
input := Left[Config, int]("error")
|
||||
cfg := Config{fallbackValue: 123, retryEnabled: true}
|
||||
|
||||
// Using ChainLeft
|
||||
resultChainLeft := ChainLeft(recoveryFn)(input)(cfg)()
|
||||
|
||||
// Using OrElse
|
||||
resultOrElse := OrElse(recoveryFn)(input)(cfg)()
|
||||
|
||||
// Both should produce identical results
|
||||
assert.Equal(t, resultOrElse, resultChainLeft)
|
||||
assert.Equal(t, E.Right[string](123), resultChainLeft)
|
||||
|
||||
// Verify both received the same config
|
||||
assert.NotNil(t, chainLeftCfg)
|
||||
assert.NotNil(t, orElseCfg)
|
||||
assert.Equal(t, *chainLeftCfg, *orElseCfg)
|
||||
assert.Equal(t, cfg, *chainLeftCfg)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -475,6 +475,8 @@ func Alt[A any](that Lazy[Result[A]]) Operator[A, A] {
|
||||
// If the Result is Left, it applies the provided function to the error value,
|
||||
// which returns a new Result that replaces the original.
|
||||
//
|
||||
// Note: OrElse is identical to [ChainLeft] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This is useful for error recovery, fallback logic, or chaining alternative computations.
|
||||
//
|
||||
// Example:
|
||||
@@ -656,3 +658,118 @@ func InstanceOf[A any](a any) Result[A] {
|
||||
}
|
||||
return Left[A](fmt.Errorf("expected %T, got %T", res, a))
|
||||
}
|
||||
|
||||
// MonadChainLeft sequences a computation on the Left (error) channel.
|
||||
// If the Result is Left, applies the function to transform or recover from the error.
|
||||
// If the Result is Right, returns the Right value unchanged.
|
||||
//
|
||||
// This is the dual of [MonadChain] - while Chain operates on Right values,
|
||||
// ChainLeft operates on Left (error) values. It's particularly useful for:
|
||||
// - Error recovery: converting specific errors into successful values
|
||||
// - Error transformation: changing error types or adding context
|
||||
// - Fallback logic: providing alternative computations when errors occur
|
||||
//
|
||||
// Note: MonadChainLeft is identical to [OrElse] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// The function parameter receives the error value and must return a new Result[A].
|
||||
// This allows you to:
|
||||
// - Recover by returning Right[error](value)
|
||||
// - Transform the error by returning Left[A](newError)
|
||||
// - Implement conditional error handling based on error content
|
||||
//
|
||||
// Example - Error recovery:
|
||||
//
|
||||
// result := result.MonadChainLeft(
|
||||
// result.Left[int](errors.New("not found")),
|
||||
// func(err error) result.Result[int] {
|
||||
// if err.Error() == "not found" {
|
||||
// return result.Right(0) // recover with default value
|
||||
// }
|
||||
// return result.Left[int](err) // propagate other errors
|
||||
// },
|
||||
// ) // Right(0)
|
||||
//
|
||||
// Example - Error type transformation:
|
||||
//
|
||||
// result := result.MonadChainLeft(
|
||||
// result.Left[string](errors.New("database error")),
|
||||
// func(err error) result.Result[string] {
|
||||
// return result.Left[string](fmt.Errorf("wrapped: %w", err))
|
||||
// },
|
||||
// ) // Left(wrapped error)
|
||||
//
|
||||
// Example - Right values pass through:
|
||||
//
|
||||
// result := result.MonadChainLeft(
|
||||
// result.Right(42),
|
||||
// func(err error) result.Result[int] {
|
||||
// return result.Right(0) // never called
|
||||
// },
|
||||
// ) // Right(42) - unchanged
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainLeft[A any](fa Result[A], f Kleisli[error, A]) Result[A] {
|
||||
return either.MonadChainLeft(fa, f)
|
||||
}
|
||||
|
||||
// ChainLeft is the curried version of [MonadChainLeft].
|
||||
// Returns a function that transforms Left (error) values while preserving Right values.
|
||||
//
|
||||
// Note: ChainLeft is identical to [OrElse] - both provide the same functionality for error recovery.
|
||||
//
|
||||
// This curried form is particularly useful in functional pipelines and for creating
|
||||
// reusable error handlers that can be composed with other operations.
|
||||
//
|
||||
// The returned function can be used with [F.Pipe1], [F.Pipe2], etc., to build
|
||||
// complex error handling pipelines in a point-free style.
|
||||
//
|
||||
// Example - Creating reusable error handlers:
|
||||
//
|
||||
// // Handler that recovers from "not found" errors
|
||||
// recoverNotFound := result.ChainLeft(func(err error) result.Result[int] {
|
||||
// if err.Error() == "not found" {
|
||||
// return result.Right(0)
|
||||
// }
|
||||
// return result.Left[int](err)
|
||||
// })
|
||||
//
|
||||
// result1 := recoverNotFound(result.Left[int](errors.New("not found"))) // Right(0)
|
||||
// result2 := recoverNotFound(result.Right(42)) // Right(42)
|
||||
//
|
||||
// Example - Using in pipelines:
|
||||
//
|
||||
// result := F.Pipe2(
|
||||
// result.Left[int](errors.New("timeout")),
|
||||
// result.ChainLeft(func(err error) result.Result[int] {
|
||||
// if err.Error() == "timeout" {
|
||||
// return result.Right(999) // fallback value
|
||||
// }
|
||||
// return result.Left[int](err)
|
||||
// }),
|
||||
// result.Map(func(n int) string {
|
||||
// return fmt.Sprintf("Value: %d", n)
|
||||
// }),
|
||||
// ) // Right("Value: 999")
|
||||
//
|
||||
// Example - Composing multiple error handlers:
|
||||
//
|
||||
// // First handler: convert error to string
|
||||
// toStringError := result.ChainLeft(func(err error) result.Result[int] {
|
||||
// return result.Left[int](errors.New(err.Error()))
|
||||
// })
|
||||
//
|
||||
// // Second handler: add prefix
|
||||
// addPrefix := result.ChainLeft(func(err error) result.Result[int] {
|
||||
// return result.Left[int](fmt.Errorf("Error: %w", err))
|
||||
// })
|
||||
//
|
||||
// result := F.Pipe2(
|
||||
// result.Left[int](errors.New("failed")),
|
||||
// toStringError,
|
||||
// addPrefix,
|
||||
// ) // Left(Error: failed)
|
||||
//
|
||||
//go:inline
|
||||
func ChainLeft[A any](f Kleisli[error, A]) Operator[A, A] {
|
||||
return either.ChainLeft(f)
|
||||
}
|
||||
|
||||
@@ -356,3 +356,296 @@ func TestInstanceOf(t *testing.T) {
|
||||
assert.Equal(t, 2, v["b"])
|
||||
})
|
||||
}
|
||||
|
||||
// TestMonadChainLeft tests the MonadChainLeft function with various scenarios
|
||||
func TestMonadChainLeft(t *testing.T) {
|
||||
t.Run("Left value is transformed by function", func(t *testing.T) {
|
||||
// Transform error to success
|
||||
result := MonadChainLeft(
|
||||
Left[int](errors.New("not found")),
|
||||
func(err error) Result[int] {
|
||||
if err.Error() == "not found" {
|
||||
return Right(0) // default value
|
||||
}
|
||||
return Left[int](err)
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Of(0), result)
|
||||
})
|
||||
|
||||
t.Run("Left value error is transformed", func(t *testing.T) {
|
||||
// Transform error with additional context
|
||||
result := MonadChainLeft(
|
||||
Left[int](errors.New("database error")),
|
||||
func(err error) Result[int] {
|
||||
return Left[int](fmt.Errorf("wrapped: %w", err))
|
||||
},
|
||||
)
|
||||
assert.True(t, IsLeft(result))
|
||||
_, err := UnwrapError(result)
|
||||
assert.Contains(t, err.Error(), "wrapped:")
|
||||
assert.Contains(t, err.Error(), "database error")
|
||||
})
|
||||
|
||||
t.Run("Right value passes through unchanged", func(t *testing.T) {
|
||||
// Right value should not be affected
|
||||
result := MonadChainLeft(
|
||||
Right(42),
|
||||
func(err error) Result[int] {
|
||||
return Left[int](errors.New("should not be called"))
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Of(42), result)
|
||||
})
|
||||
|
||||
t.Run("Chain multiple error transformations", func(t *testing.T) {
|
||||
// First transformation
|
||||
step1 := MonadChainLeft(
|
||||
Left[int](errors.New("error1")),
|
||||
func(err error) Result[int] {
|
||||
return Left[int](errors.New("error2"))
|
||||
},
|
||||
)
|
||||
// Second transformation
|
||||
step2 := MonadChainLeft(
|
||||
step1,
|
||||
func(err error) Result[int] {
|
||||
return Left[int](fmt.Errorf("final: %s", err.Error()))
|
||||
},
|
||||
)
|
||||
assert.True(t, IsLeft(step2))
|
||||
_, err := UnwrapError(step2)
|
||||
assert.Equal(t, "final: error2", err.Error())
|
||||
})
|
||||
|
||||
t.Run("Error recovery with fallback", func(t *testing.T) {
|
||||
// Recover from specific errors
|
||||
result := MonadChainLeft(
|
||||
Left[int](errors.New("timeout")),
|
||||
func(err error) Result[int] {
|
||||
if err.Error() == "timeout" {
|
||||
return Right(999) // fallback value
|
||||
}
|
||||
return Left[int](err)
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Of(999), result)
|
||||
})
|
||||
|
||||
t.Run("Conditional error handling", func(t *testing.T) {
|
||||
// Handle different error types differently
|
||||
handleError := func(err error) Result[string] {
|
||||
switch err.Error() {
|
||||
case "not found":
|
||||
return Right("default")
|
||||
case "timeout":
|
||||
return Right("retry")
|
||||
default:
|
||||
return Left[string](err)
|
||||
}
|
||||
}
|
||||
|
||||
result1 := MonadChainLeft(Left[string](errors.New("not found")), handleError)
|
||||
assert.Equal(t, Of("default"), result1)
|
||||
|
||||
result2 := MonadChainLeft(Left[string](errors.New("timeout")), handleError)
|
||||
assert.Equal(t, Of("retry"), result2)
|
||||
|
||||
result3 := MonadChainLeft(Left[string](errors.New("other")), handleError)
|
||||
assert.True(t, IsLeft(result3))
|
||||
})
|
||||
|
||||
t.Run("Type preservation", func(t *testing.T) {
|
||||
// Ensure type is preserved through transformation
|
||||
result := MonadChainLeft(
|
||||
Left[string](errors.New("error")),
|
||||
func(err error) Result[string] {
|
||||
return Right("recovered")
|
||||
},
|
||||
)
|
||||
assert.Equal(t, Of("recovered"), result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestChainLeft tests the curried ChainLeft function
|
||||
func TestChainLeft(t *testing.T) {
|
||||
t.Run("Curried function transforms Left value", func(t *testing.T) {
|
||||
// Create a reusable error handler
|
||||
handleNotFound := ChainLeft(func(err error) Result[int] {
|
||||
if err.Error() == "not found" {
|
||||
return Right(0)
|
||||
}
|
||||
return Left[int](err)
|
||||
})
|
||||
|
||||
result := handleNotFound(Left[int](errors.New("not found")))
|
||||
assert.Equal(t, Of(0), result)
|
||||
})
|
||||
|
||||
t.Run("Curried function with Right value", func(t *testing.T) {
|
||||
handler := ChainLeft(func(err error) Result[int] {
|
||||
return Left[int](errors.New("should not be called"))
|
||||
})
|
||||
|
||||
result := handler(Right(42))
|
||||
assert.Equal(t, Of(42), result)
|
||||
})
|
||||
|
||||
t.Run("Use in pipeline with Pipe", func(t *testing.T) {
|
||||
// Create error transformer
|
||||
wrapError := ChainLeft(func(err error) Result[string] {
|
||||
return Left[string](fmt.Errorf("Error: %w", err))
|
||||
})
|
||||
|
||||
result := F.Pipe1(
|
||||
Left[string](errors.New("failed")),
|
||||
wrapError,
|
||||
)
|
||||
assert.True(t, IsLeft(result))
|
||||
_, err := UnwrapError(result)
|
||||
assert.Contains(t, err.Error(), "Error:")
|
||||
assert.Contains(t, err.Error(), "failed")
|
||||
})
|
||||
|
||||
t.Run("Compose multiple ChainLeft operations", func(t *testing.T) {
|
||||
// First handler: convert error to string representation
|
||||
handler1 := ChainLeft(func(err error) Result[int] {
|
||||
return Left[int](errors.New(err.Error()))
|
||||
})
|
||||
|
||||
// Second handler: add prefix to error
|
||||
handler2 := ChainLeft(func(err error) Result[int] {
|
||||
return Left[int](fmt.Errorf("Handled: %w", err))
|
||||
})
|
||||
|
||||
result := F.Pipe2(
|
||||
Left[int](errors.New("original")),
|
||||
handler1,
|
||||
handler2,
|
||||
)
|
||||
assert.True(t, IsLeft(result))
|
||||
_, err := UnwrapError(result)
|
||||
assert.Contains(t, err.Error(), "Handled:")
|
||||
assert.Contains(t, err.Error(), "original")
|
||||
})
|
||||
|
||||
t.Run("Error recovery in pipeline", func(t *testing.T) {
|
||||
// Handler that recovers from specific errors
|
||||
recoverFromTimeout := ChainLeft(func(err error) Result[int] {
|
||||
if err.Error() == "timeout" {
|
||||
return Right(0) // recovered value
|
||||
}
|
||||
return Left[int](err) // propagate other errors
|
||||
})
|
||||
|
||||
// Test with timeout error
|
||||
result1 := F.Pipe1(
|
||||
Left[int](errors.New("timeout")),
|
||||
recoverFromTimeout,
|
||||
)
|
||||
assert.Equal(t, Of(0), result1)
|
||||
|
||||
// Test with other error
|
||||
result2 := F.Pipe1(
|
||||
Left[int](errors.New("other error")),
|
||||
recoverFromTimeout,
|
||||
)
|
||||
assert.True(t, IsLeft(result2))
|
||||
})
|
||||
|
||||
t.Run("ChainLeft with Map combination", func(t *testing.T) {
|
||||
// Combine ChainLeft with Map to handle both channels
|
||||
errorHandler := ChainLeft(func(err error) Result[int] {
|
||||
return Left[int](fmt.Errorf("Error: %w", err))
|
||||
})
|
||||
|
||||
valueMapper := Map(func(n int) string {
|
||||
return fmt.Sprintf("Value: %d", n)
|
||||
})
|
||||
|
||||
// Test with Left
|
||||
result1 := F.Pipe2(
|
||||
Left[int](errors.New("fail")),
|
||||
errorHandler,
|
||||
valueMapper,
|
||||
)
|
||||
assert.True(t, IsLeft(result1))
|
||||
|
||||
// Test with Right
|
||||
result2 := F.Pipe2(
|
||||
Right(42),
|
||||
errorHandler,
|
||||
valueMapper,
|
||||
)
|
||||
assert.Equal(t, Of("Value: 42"), result2)
|
||||
})
|
||||
|
||||
t.Run("Reusable error handlers", func(t *testing.T) {
|
||||
// Create a library of reusable error handlers
|
||||
recoverNotFound := ChainLeft(func(err error) Result[string] {
|
||||
if err.Error() == "not found" {
|
||||
return Right("default")
|
||||
}
|
||||
return Left[string](err)
|
||||
})
|
||||
|
||||
recoverTimeout := ChainLeft(func(err error) Result[string] {
|
||||
if err.Error() == "timeout" {
|
||||
return Right("retry")
|
||||
}
|
||||
return Left[string](err)
|
||||
})
|
||||
|
||||
// Apply handlers in sequence
|
||||
result := F.Pipe2(
|
||||
Left[string](errors.New("not found")),
|
||||
recoverNotFound,
|
||||
recoverTimeout,
|
||||
)
|
||||
assert.Equal(t, Of("default"), result)
|
||||
})
|
||||
|
||||
t.Run("Error transformation pipeline", func(t *testing.T) {
|
||||
// Build a pipeline that transforms errors step by step
|
||||
addContext := ChainLeft(func(err error) Result[int] {
|
||||
return Left[int](fmt.Errorf("context: %w", err))
|
||||
})
|
||||
|
||||
addTimestamp := ChainLeft(func(err error) Result[int] {
|
||||
return Left[int](fmt.Errorf("[2024-01-01] %w", err))
|
||||
})
|
||||
|
||||
result := F.Pipe2(
|
||||
Left[int](errors.New("base error")),
|
||||
addContext,
|
||||
addTimestamp,
|
||||
)
|
||||
assert.True(t, IsLeft(result))
|
||||
_, err := UnwrapError(result)
|
||||
assert.Contains(t, err.Error(), "[2024-01-01]")
|
||||
assert.Contains(t, err.Error(), "context:")
|
||||
assert.Contains(t, err.Error(), "base error")
|
||||
})
|
||||
|
||||
t.Run("Conditional recovery based on error content", func(t *testing.T) {
|
||||
// Recover from errors matching specific patterns
|
||||
smartRecover := ChainLeft(func(err error) Result[int] {
|
||||
msg := err.Error()
|
||||
if msg == "not found" {
|
||||
return Right(0)
|
||||
}
|
||||
if msg == "timeout" {
|
||||
return Right(-1)
|
||||
}
|
||||
if msg == "unauthorized" {
|
||||
return Right(-2)
|
||||
}
|
||||
return Left[int](err)
|
||||
})
|
||||
|
||||
assert.Equal(t, Of(0), smartRecover(Left[int](errors.New("not found"))))
|
||||
assert.Equal(t, Of(-1), smartRecover(Left[int](errors.New("timeout"))))
|
||||
assert.Equal(t, Of(-2), smartRecover(Left[int](errors.New("unauthorized"))))
|
||||
assert.True(t, IsLeft(smartRecover(Left[int](errors.New("unknown")))))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user