1
0
mirror of https://github.com/IBM/fp-go.git synced 2026-01-17 00:53:55 +02:00

Compare commits

...

4 Commits

Author SHA1 Message Date
Dr. Carsten Leue
cdc2041d8e fix: implement ReadIO consistently
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-15 13:07:19 +01:00
Dr. Carsten Leue
777fff9a5a fix: implement ReadIO
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-15 12:24:46 +01:00
Carsten Leue
8acea9043f fix: refactor circuitbreaker (#152)
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-15 11:36:32 +01:00
Dr. Carsten Leue
c6445ac021 fix: better tests and docs
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-14 12:09:01 +01:00
38 changed files with 4836 additions and 373 deletions

View File

@@ -14,6 +14,8 @@ This document explains the key design decisions and principles behind fp-go's AP
fp-go follows the **"data last"** principle, where the data being operated on is always the last parameter in a function. This design choice enables powerful function composition and partial application patterns.
This principle is deeply rooted in functional programming tradition, particularly in **Haskell's design philosophy**. Haskell functions are automatically curried and follow the data-last convention, making function composition natural and elegant. For example, Haskell's `map` function has the signature `(a -> b) -> [a] -> [b]`, where the transformation function comes before the list.
### What is "Data Last"?
In the "data last" style, functions are structured so that:
@@ -31,6 +33,8 @@ The "data last" principle enables:
3. **Point-Free Style**: Write transformations without explicitly mentioning the data
4. **Reusability**: Create reusable transformation pipelines
This design aligns with Haskell's approach where all functions are curried by default, enabling elegant composition patterns that have proven effective over decades of functional programming practice.
### Examples
#### Basic Transformation
@@ -181,8 +185,18 @@ result := O.MonadMap(O.Some("hello"), strings.ToUpper)
The data-last currying pattern is well-documented in the functional programming community:
#### Haskell Design Philosophy
- [Haskell Wiki - Currying](https://wiki.haskell.org/Currying) - Comprehensive explanation of currying in Haskell
- [Learn You a Haskell - Higher Order Functions](http://learnyouahaskell.com/higher-order-functions) - Introduction to currying and partial application
- [Haskell's Prelude](https://hackage.haskell.org/package/base/docs/Prelude.html) - Standard library showing data-last convention throughout
#### General Functional Programming
- [Mostly Adequate Guide - Ch. 4: Currying](https://mostly-adequate.gitbook.io/mostly-adequate-guide/ch04) - Excellent introduction with clear examples
- [Curry and Function Composition](https://medium.com/javascript-scene/curry-and-function-composition-2c208d774983) by Eric Elliott
- [Why Curry Helps](https://hughfdjackson.com/javascript/why-curry-helps/) - Practical benefits of currying
#### Related Libraries
- [fp-ts Documentation](https://gcanti.github.io/fp-ts/) - TypeScript library that inspired fp-go's design
- [fp-ts Issue #1238](https://github.com/gcanti/fp-ts/issues/1238) - Real-world examples of data-last refactoring
## Kleisli and Operator Types

View File

@@ -446,6 +446,7 @@ func process() IOResult[string] {
## 📚 Documentation
- **[Design Decisions](./DESIGN.md)** - Key design principles and patterns explained
- **[API Documentation](https://pkg.go.dev/github.com/IBM/fp-go/v2)** - Complete API reference
- **[Code Samples](./samples/)** - Practical examples and use cases
- **[Go 1.24 Release Notes](https://tip.golang.org/doc/go1.24)** - Information about generic type aliases

View File

@@ -4,7 +4,6 @@ import (
"time"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/function"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/identity"
"github.com/IBM/fp-go/v2/io"
@@ -14,6 +13,7 @@ import (
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/retry"
)
@@ -241,125 +241,155 @@ func isResetTimeExceeded(ct time.Time) option.Kleisli[openState, openState] {
})
}
// handleSuccessOnClosed handles a successful request when the circuit breaker is in closed state.
// It updates the closed state by recording the success and returns an IO operation that
// modifies the breaker state.
// handleSuccessOnClosed creates a Reader that handles successful requests when the circuit is closed.
// This function is used to update the circuit breaker state after a successful operation completes
// while the circuit is in the closed state.
//
// This function is part of the circuit breaker's state management for the closed state.
// When a request succeeds in closed state:
// 1. The current time is obtained
// 2. The addSuccess function is called with the current time to update the ClosedState
// 3. The updated ClosedState is wrapped in a Right (closed) BreakerState
// 4. The breaker state is modified with the new state
// The function takes a Reader that adds a success record to the ClosedState and lifts it to work
// with BreakerState by mapping over the Right (closed) side of the Either type. This ensures that
// success tracking only affects the closed state and leaves any open state unchanged.
//
// Parameters:
// - currentTime: An IO operation that provides the current time
// - addSuccess: A Reader that takes a time and returns an endomorphism for ClosedState,
// typically resetting failure counters or history
// - addSuccess: A Reader that takes the current time and returns an Endomorphism that updates
// the ClosedState by recording a successful operation. This typically increments a success
// counter or updates a success history.
//
// Returns:
// - An io.Kleisli that takes another io.Kleisli and chains them together.
// The outer Kleisli takes an Endomorphism[BreakerState] and returns BreakerState.
// This allows composing the success handling with other state modifications.
// - A Reader[time.Time, Endomorphism[BreakerState]] that, when given the current time, produces
// an endomorphism that updates the BreakerState by applying the success update to the closed
// state (if closed) or leaving the state unchanged (if open).
//
// Thread Safety: This function creates IO operations that will atomically modify the
// IORef[BreakerState] when executed. The state modifications are thread-safe.
//
// Type signature:
//
// io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState]
// Thread Safety: This is a pure function that creates new state instances. The returned
// endomorphism is safe for concurrent use as it does not mutate its input.
//
// Usage Context:
// - Called when a request succeeds while the circuit is closed
// - Resets failure tracking (counter or history) in the ClosedState
// - Keeps the circuit in closed state
// - Called after a successful request completes while the circuit is closed
// - Updates success metrics/counters in the ClosedState
// - Does not affect the circuit state if it's already open
// - Part of the normal operation flow when the circuit breaker is functioning properly
func handleSuccessOnClosed(
currentTime IO[time.Time],
addSuccess Reader[time.Time, Endomorphism[ClosedState]],
) io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState] {
) Reader[time.Time, Endomorphism[BreakerState]] {
return F.Flow2(
io.Chain,
identity.Flap[IO[BreakerState]](F.Pipe1(
currentTime,
io.Map(F.Flow2(
addSuccess,
either.Map[openState],
)))),
addSuccess,
either.Map[openState],
)
}
// handleFailureOnClosed handles a failed request when the circuit breaker is in closed state.
// It updates the closed state by recording the failure and checks if the circuit should open.
// handleFailureOnClosed creates a Reader that handles failed requests when the circuit is closed.
// This function manages the critical logic for determining whether a failure should cause the
// circuit breaker to open (transition from closed to open state).
//
// This function is part of the circuit breaker's state management for the closed state.
// When a request fails in closed state:
// 1. The current time is obtained
// 2. The addError function is called to record the failure in the ClosedState
// 3. The checkClosedState function is called to determine if the failure threshold is exceeded
// 4. If the threshold is exceeded (Check returns None):
// - The circuit transitions to open state using openCircuit
// - A new openState is created with resetAt time calculated from the retry policy
// 5. If the threshold is not exceeded (Check returns Some):
// - The circuit remains closed with the updated failure tracking
// The function orchestrates three key operations:
// 1. Records the failure in the ClosedState using addError
// 2. Checks if the failure threshold has been exceeded using checkClosedState
// 3. If threshold exceeded, opens the circuit; otherwise, keeps it closed with updated error count
//
// The decision flow is:
// - Add the error to the closed state's error tracking
// - Check if the updated closed state exceeds the failure threshold
// - If threshold exceeded (checkClosedState returns None):
// - Create a new openState with calculated reset time based on retry policy
// - Transition the circuit to open state (Left side of Either)
// - If threshold not exceeded (checkClosedState returns Some):
// - Keep the circuit closed with the updated error count
// - Continue allowing requests through
//
// Parameters:
// - currentTime: An IO operation that provides the current time
// - addError: A Reader that takes a time and returns an endomorphism for ClosedState,
// recording a failure (incrementing counter or adding to history)
// - checkClosedState: A Reader that takes a time and returns an option.Kleisli that checks
// if the ClosedState should remain closed. Returns Some if circuit stays closed, None if it should open.
// - openCircuit: A Reader that takes a time and returns an openState with calculated resetAt time
// - addError: A Reader that takes the current time and returns an Endomorphism that updates
// the ClosedState by recording a failed operation. This typically increments an error
// counter or adds to an error history.
// - checkClosedState: A Reader that takes the current time and returns an option.Kleisli that
// validates whether the ClosedState is still within acceptable failure thresholds.
// Returns Some(ClosedState) if threshold not exceeded, None if threshold exceeded.
// - openCircuit: A Reader that takes the current time and creates a new openState with
// appropriate reset time calculated from the retry policy. Used when transitioning to open.
//
// Returns:
// - An io.Kleisli that takes another io.Kleisli and chains them together.
// The outer Kleisli takes an Endomorphism[BreakerState] and returns BreakerState.
// This allows composing the failure handling with other state modifications.
// - A Reader[time.Time, Endomorphism[BreakerState]] that, when given the current time, produces
// an endomorphism that either:
// - Keeps the circuit closed with updated error tracking (if threshold not exceeded)
// - Opens the circuit with calculated reset time (if threshold exceeded)
//
// Thread Safety: This function creates IO operations that will atomically modify the
// IORef[BreakerState] when executed. The state modifications are thread-safe.
//
// Type signature:
//
// io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState]
//
// State Transitions:
// - Closed -> Closed: When failure threshold is not exceeded (Some from checkClosedState)
// - Closed -> Open: When failure threshold is exceeded (None from checkClosedState)
// Thread Safety: This is a pure function that creates new state instances. The returned
// endomorphism is safe for concurrent use as it does not mutate its input.
//
// Usage Context:
// - Called when a request fails while the circuit is closed
// - Records the failure in the ClosedState (counter or history)
// - May trigger transition to open state if threshold is exceeded
// - Called after a failed request completes while the circuit is closed
// - Implements the core circuit breaker logic for opening the circuit
// - Determines when to stop allowing requests through to protect the failing service
// - Critical for preventing cascading failures in distributed systems
//
// State Transition:
// - Closed (under threshold) -> Closed (with incremented error count)
// - Closed (at/over threshold) -> Open (with reset time for recovery attempt)
func handleFailureOnClosed(
currentTime IO[time.Time],
addError Reader[time.Time, Endomorphism[ClosedState]],
checkClosedState Reader[time.Time, option.Kleisli[ClosedState, ClosedState]],
openCircuit Reader[time.Time, openState],
) io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState] {
return F.Flow2(
io.Chain,
identity.Flap[IO[BreakerState]](F.Pipe1(
currentTime,
io.Map(func(ct time.Time) either.Operator[openState, ClosedState, ClosedState] {
return either.Chain(F.Flow3(
addError(ct),
checkClosedState(ct),
option.Fold(
F.Pipe2(
ct,
lazy.Of,
lazy.Map(F.Flow2(
openCircuit,
createOpenCircuit,
)),
),
createClosedCircuit,
),
))
}))),
) Reader[time.Time, Endomorphism[BreakerState]] {
return F.Pipe2(
F.Pipe1(
addError,
reader.ApS(reader.Map[ClosedState], checkClosedState),
),
reader.Chain(F.Flow2(
reader.Map[ClosedState](option.Fold(
F.Pipe2(
openCircuit,
reader.Map[time.Time](createOpenCircuit),
lazy.Of,
),
F.Flow2(
createClosedCircuit,
reader.Of[time.Time],
),
)),
reader.Sequence,
)),
reader.Map[time.Time](either.Chain[openState, ClosedState, ClosedState]),
)
}
func handleErrorOnClosed2[E any](
checkError option.Kleisli[E, E],
onSuccess Reader[time.Time, Endomorphism[BreakerState]],
onFailure Reader[time.Time, Endomorphism[BreakerState]],
) reader.Kleisli[time.Time, E, Endomorphism[BreakerState]] {
return F.Flow3(
checkError,
option.MapTo[E](onFailure),
option.GetOrElse(lazy.Of(onSuccess)),
)
}
func stateModifier(
modify io.Kleisli[Endomorphism[BreakerState], BreakerState],
) reader.Operator[time.Time, Endomorphism[BreakerState], IO[BreakerState]] {
return reader.Map[time.Time](modify)
}
func reportOnClose2(
onClosed ReaderIO[time.Time, Void],
onOpened ReaderIO[time.Time, Void],
) readerio.Operator[time.Time, BreakerState, Void] {
return readerio.Chain(either.Fold(
reader.Of[openState](onOpened),
reader.Of[ClosedState](onClosed),
))
}
func applyAndReportClose2(
currentTime IO[time.Time],
metrics readerio.Operator[time.Time, BreakerState, Void],
) func(io.Kleisli[Endomorphism[BreakerState], BreakerState]) func(Reader[time.Time, Endomorphism[BreakerState]]) IO[Void] {
return func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) func(Reader[time.Time, Endomorphism[BreakerState]]) IO[Void] {
return F.Flow3(
reader.Map[time.Time](modify),
metrics,
readerio.ReadIO[Void](currentTime),
)
}
}
// MakeCircuitBreaker creates a circuit breaker implementation for a higher-kinded type.
@@ -402,6 +432,8 @@ func MakeCircuitBreaker[E, T, HKTT, HKTOP, HKTHKTT any](
chainFirstIOK func(io.Kleisli[T, BreakerState]) func(HKTT) HKTT,
chainFirstLeftIOK func(io.Kleisli[E, BreakerState]) func(HKTT) HKTT,
chainFirstIOK2 func(io.Kleisli[Either[E, T], Void]) func(HKTT) HKTT,
fromIO func(IO[func(HKTT) HKTT]) HKTOP,
flap func(HKTT) func(HKTOP) HKTHKTT,
flatten func(HKTHKTT) HKTT,
@@ -437,47 +469,22 @@ func MakeCircuitBreaker[E, T, HKTT, HKTOP, HKTHKTT any](
reader.Of[HKTT],
)
handleSuccess := handleSuccessOnClosed(currentTime, addSuccess)
handleFailure := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
handleSuccess2 := handleSuccessOnClosed(addSuccess)
handleFailure2 := handleFailureOnClosed(addError, checkClosedState, openCircuit)
handleError2 := handleErrorOnClosed2(checkError, handleSuccess2, handleFailure2)
metricsClose2 := reportOnClose2(metrics.Accept, metrics.Open)
apply2 := applyAndReportClose2(currentTime, metricsClose2)
onClosed := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {
return F.Flow2(
// error case
chainFirstLeftIOK(F.Flow3(
checkError,
option.Fold(
// the error is not applicable, handle as success
F.Pipe2(
modify,
handleSuccess,
lazy.Of,
),
// the error is relevant, record it
F.Pipe2(
modify,
handleFailure,
reader.Of[E],
),
),
// metering
io.ChainFirst(either.Fold(
F.Flow2(
openedAtLens.Get,
metrics.Open,
),
func(c ClosedState) IO[Void] {
return io.Of(function.VOID)
},
)),
)),
// good case
chainFirstIOK(F.Pipe2(
modify,
handleSuccess,
reader.Of[T],
)),
)
return chainFirstIOK2(F.Flow2(
either.Fold(
handleError2,
reader.Of[T](handleSuccess2),
),
apply2(modify),
))
}
onCanary := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {

View File

@@ -5,12 +5,12 @@ import (
"testing"
"time"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/function"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
)
@@ -452,43 +452,128 @@ func TestIsResetTimeExceeded(t *testing.T) {
// TestHandleSuccessOnClosed tests the handleSuccessOnClosed function
func TestHandleSuccessOnClosed(t *testing.T) {
t.Run("resets failure count on success", func(t *testing.T) {
t.Run("updates closed state with success when circuit is closed", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addSuccess := reader.From1(ClosedState.AddSuccess)
currentTime := vt.Now()
// Create initial state with some failures
now := vt.Now()
// Create a simple addSuccess reader that increments a counter
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddSuccess(ct)
}
}
// Create initial closed state
initialClosed := MakeClosedStateCounter(3)
initialClosed = initialClosed.AddError(now)
initialClosed = initialClosed.AddError(now)
initialState := createClosedCircuit(initialClosed)
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
// Apply handleSuccessOnClosed
handler := handleSuccessOnClosed(addSuccess)
endomorphism := handler(currentTime)
result := endomorphism(initialState)
handler := handleSuccessOnClosed(currentTime, addSuccess)
// Verify the state is still closed
assert.True(t, IsClosed(result), "state should remain closed after success")
// Apply the handler
result := io.Run(handler(modify))
// Verify state is still closed and failures are reset
assert.True(t, IsClosed(result), "circuit should remain closed after success")
// Verify the closed state was updated
closedState := either.Fold(
func(openState) ClosedState { return initialClosed },
F.Identity[ClosedState],
)(result)
// The success should have been recorded (implementation-specific verification)
assert.NotNil(t, closedState, "closed state should be present")
})
t.Run("keeps circuit closed", func(t *testing.T) {
t.Run("does not affect open state", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addSuccess := reader.From1(ClosedState.AddSuccess)
currentTime := vt.Now()
initialState := createClosedCircuit(MakeClosedStateCounter(3))
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddSuccess(ct)
}
}
handler := handleSuccessOnClosed(currentTime, addSuccess)
result := io.Run(handler(modify))
// Create initial open state
initialOpen := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: currentTime.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
initialState := createOpenCircuit(initialOpen)
assert.True(t, IsClosed(result), "circuit should remain closed")
// Apply handleSuccessOnClosed
handler := handleSuccessOnClosed(addSuccess)
endomorphism := handler(currentTime)
result := endomorphism(initialState)
// Verify the state remains open and unchanged
assert.True(t, IsOpen(result), "state should remain open")
// Extract and verify the open state is unchanged
openResult := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return initialOpen },
)(result)
assert.Equal(t, initialOpen.openedAt, openResult.openedAt, "openedAt should be unchanged")
assert.Equal(t, initialOpen.resetAt, openResult.resetAt, "resetAt should be unchanged")
assert.Equal(t, initialOpen.canaryRequest, openResult.canaryRequest, "canaryRequest should be unchanged")
})
t.Run("preserves time parameter through reader", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
time1 := vt.Now()
vt.Advance(1 * time.Hour)
time2 := vt.Now()
var capturedTime time.Time
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
capturedTime = ct
return F.Identity[ClosedState]
}
initialClosed := MakeClosedStateCounter(3)
initialState := createClosedCircuit(initialClosed)
handler := handleSuccessOnClosed(addSuccess)
// Apply with time1
endomorphism1 := handler(time1)
endomorphism1(initialState)
assert.Equal(t, time1, capturedTime, "should pass time1 to addSuccess")
// Apply with time2
endomorphism2 := handler(time2)
endomorphism2(initialState)
assert.Equal(t, time2, capturedTime, "should pass time2 to addSuccess")
})
t.Run("composes correctly with multiple successes", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddSuccess(ct)
}
}
initialClosed := MakeClosedStateCounter(3)
initialState := createClosedCircuit(initialClosed)
handler := handleSuccessOnClosed(addSuccess)
endomorphism := handler(currentTime)
// Apply multiple times
result1 := endomorphism(initialState)
result2 := endomorphism(result1)
result3 := endomorphism(result2)
// All should remain closed
assert.True(t, IsClosed(result1), "state should remain closed after first success")
assert.True(t, IsClosed(result2), "state should remain closed after second success")
assert.True(t, IsClosed(result3), "state should remain closed after third success")
})
}
@@ -496,9 +581,26 @@ func TestHandleSuccessOnClosed(t *testing.T) {
func TestHandleFailureOnClosed(t *testing.T) {
t.Run("keeps circuit closed when threshold not exceeded", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
currentTime := vt.Now()
// Create a closed state that allows 3 errors
initialClosed := MakeClosedStateCounter(3)
// addError increments error count
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
// checkClosedState returns Some if under threshold
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
// openCircuit creates an open state (shouldn't be called in this test)
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
@@ -508,26 +610,39 @@ func TestHandleFailureOnClosed(t *testing.T) {
}
}
// Create initial state with room for more failures
now := vt.Now()
initialClosed := MakeClosedStateCounter(5) // threshold is 5
initialClosed = initialClosed.AddError(now)
initialState := createClosedCircuit(initialClosed)
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
result := io.Run(handler(modify))
// First error - should stay closed
result1 := endomorphism(initialState)
assert.True(t, IsClosed(result1), "circuit should remain closed after first error")
assert.True(t, IsClosed(result), "circuit should remain closed when threshold not exceeded")
// Second error - should stay closed
result2 := endomorphism(result1)
assert.True(t, IsClosed(result2), "circuit should remain closed after second error")
})
t.Run("opens circuit when threshold exceeded", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
currentTime := vt.Now()
// Create a closed state that allows only 2 errors (opens at 2nd error)
initialClosed := MakeClosedStateCounter(2)
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
@@ -537,26 +652,85 @@ func TestHandleFailureOnClosed(t *testing.T) {
}
}
// Create initial state at threshold
now := vt.Now()
initialClosed := MakeClosedStateCounter(2) // threshold is 2
initialClosed = initialClosed.AddError(now)
initialState := createClosedCircuit(initialClosed)
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
result := io.Run(handler(modify))
// First error - should stay closed (count=1, threshold=2)
result1 := endomorphism(initialState)
assert.True(t, IsClosed(result1), "circuit should remain closed after first error")
assert.True(t, IsOpen(result), "circuit should open when threshold exceeded")
// Second error - should open (count=2, threshold=2)
result2 := endomorphism(result1)
assert.True(t, IsOpen(result2), "circuit should open when threshold reached")
})
t.Run("records failure in closed state", func(t *testing.T) {
t.Run("creates open state with correct reset time", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
currentTime := vt.Now()
expectedResetTime := currentTime.Add(5 * time.Minute)
initialClosed := MakeClosedStateCounter(1) // Opens at 1st error
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: expectedResetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// First error - should open immediately (threshold=1)
result1 := endomorphism(initialState)
assert.True(t, IsOpen(result1), "circuit should open after first error")
// Verify the open state has correct reset time
resultOpen := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return openState{} },
)(result1)
assert.Equal(t, expectedResetTime, resultOpen.resetAt, "reset time should match expected")
assert.Equal(t, currentTime, resultOpen.openedAt, "opened time should be current time")
})
t.Run("edge case: zero error threshold", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
// Create a closed state that allows 0 errors (opens immediately)
initialClosed := MakeClosedStateCounter(0)
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
@@ -566,14 +740,212 @@ func TestHandleFailureOnClosed(t *testing.T) {
}
}
initialState := createClosedCircuit(MakeClosedStateCounter(10))
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
result := io.Run(handler(modify))
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// Should still be closed but with failure recorded
assert.True(t, IsClosed(result), "circuit should remain closed")
// First error should immediately open the circuit
result := endomorphism(initialState)
assert.True(t, IsOpen(result), "circuit should open immediately with zero threshold")
})
t.Run("edge case: very high error threshold", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
// Create a closed state that allows 1000 errors
initialClosed := MakeClosedStateCounter(1000)
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// Apply many errors
result := initialState
for i := 0; i < 100; i++ {
result = endomorphism(result)
}
// Should still be closed after 100 errors
assert.True(t, IsClosed(result), "circuit should remain closed with high threshold")
})
t.Run("preserves time parameter through reader chain", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
time1 := vt.Now()
vt.Advance(2 * time.Hour)
time2 := vt.Now()
var capturedAddErrorTime, capturedCheckTime, capturedOpenTime time.Time
initialClosed := MakeClosedStateCounter(2) // Need 2 errors to open
addError := func(ct time.Time) Endomorphism[ClosedState] {
capturedAddErrorTime = ct
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
capturedCheckTime = ct
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
capturedOpenTime = ct
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
// Apply with time1 - first error, stays closed
endomorphism1 := handler(time1)
result1 := endomorphism1(initialState)
assert.Equal(t, time1, capturedAddErrorTime, "addError should receive time1")
assert.Equal(t, time1, capturedCheckTime, "checkClosedState should receive time1")
// Apply with time2 - second error, should trigger open
endomorphism2 := handler(time2)
endomorphism2(result1)
assert.Equal(t, time2, capturedAddErrorTime, "addError should receive time2")
assert.Equal(t, time2, capturedCheckTime, "checkClosedState should receive time2")
assert.Equal(t, time2, capturedOpenTime, "openCircuit should receive time2")
})
t.Run("handles transition from closed to open correctly", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
initialClosed := MakeClosedStateCounter(2) // Opens at 2nd error
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// Start with closed state
state := createClosedCircuit(initialClosed)
assert.True(t, IsClosed(state), "initial state should be closed")
// First error - should stay closed (count=1, threshold=2)
state = endomorphism(state)
assert.True(t, IsClosed(state), "should remain closed after first error")
// Second error - should open (count=2, threshold=2)
state = endomorphism(state)
assert.True(t, IsOpen(state), "should open after second error")
// Verify it's truly open with correct properties
resultOpen := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return openState{} },
)(state)
assert.False(t, resultOpen.canaryRequest, "canaryRequest should be false initially")
assert.Equal(t, currentTime, resultOpen.openedAt, "openedAt should be current time")
})
t.Run("does not affect already open state", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
// Start with an already open state
existingOpen := openState{
openedAt: currentTime.Add(-5 * time.Minute),
resetAt: currentTime.Add(5 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: true,
}
initialState := createOpenCircuit(existingOpen)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// Apply to open state - should not change it
result := endomorphism(initialState)
assert.True(t, IsOpen(result), "state should remain open")
// The open state should be unchanged since handleFailureOnClosed
// only operates on the Right (closed) side of the Either
openResult := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return openState{} },
)(result)
assert.Equal(t, existingOpen.openedAt, openResult.openedAt, "openedAt should be unchanged")
assert.Equal(t, existingOpen.resetAt, openResult.resetAt, "resetAt should be unchanged")
assert.Equal(t, existingOpen.canaryRequest, openResult.canaryRequest, "canaryRequest should be unchanged")
})
}

View File

@@ -28,7 +28,10 @@ import (
//
// Thread Safety: This type is immutable and safe for concurrent use.
type CircuitBreakerError struct {
Name string
// Name: The name identifying this circuit breaker instance
Name string
// ResetAt: The time at which the circuit breaker will transition from open to half-open state
ResetAt time.Time
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
)
type (
@@ -110,6 +111,25 @@ type (
name string
logger *log.Logger
}
// voidMetrics is a no-op implementation of the Metrics interface that does nothing.
// All methods return the same pre-allocated IO[Void] operation that immediately returns
// without performing any action.
//
// This implementation is useful for:
// - Testing scenarios where metrics collection is not needed
// - Production environments where metrics overhead should be eliminated
// - Benchmarking circuit breaker logic without metrics interference
// - Default initialization when no metrics implementation is provided
//
// Thread Safety: This implementation is safe for concurrent use. The noop IO operation
// is immutable and can be safely shared across goroutines.
//
// Performance: This is the most efficient Metrics implementation as it performs no
// operations and has minimal memory overhead (single shared IO[Void] instance).
voidMetrics struct {
noop IO[Void]
}
)
// doLog is a helper method that creates an IO operation for logging a circuit breaker event.
@@ -206,3 +226,79 @@ func (m *loggingMetrics) Canary(ct time.Time) IO[Void] {
func MakeMetricsFromLogger(name string, logger *log.Logger) Metrics {
return &loggingMetrics{name: name, logger: logger}
}
// Open implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Open(_ time.Time) IO[Void] {
return m.noop
}
// Accept implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Accept(_ time.Time) IO[Void] {
return m.noop
}
// Canary implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Canary(_ time.Time) IO[Void] {
return m.noop
}
// Close implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Close(_ time.Time) IO[Void] {
return m.noop
}
// Reject implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Reject(_ time.Time) IO[Void] {
return m.noop
}
// MakeVoidMetrics creates a no-op Metrics implementation that performs no operations.
// All methods return the same pre-allocated IO[Void] operation that does nothing when executed.
//
// This is useful for:
// - Testing scenarios where metrics collection is not needed
// - Production environments where metrics overhead should be eliminated
// - Benchmarking circuit breaker logic without metrics interference
// - Default initialization when no metrics implementation is provided
//
// Returns:
// - Metrics: A thread-safe no-op Metrics implementation
//
// Thread Safety: The returned Metrics implementation is safe for concurrent use.
// All methods return the same immutable IO[Void] operation.
//
// Performance: This is the most efficient Metrics implementation with minimal overhead.
// The IO[Void] operation is pre-allocated once and reused for all method calls.
//
// Example:
//
// metrics := MakeVoidMetrics()
//
// // All operations do nothing
// io.Run(metrics.Open(time.Now())) // No-op
// io.Run(metrics.Accept(time.Now())) // No-op
// io.Run(metrics.Reject(time.Now())) // No-op
//
// // Useful for testing
// breaker := MakeCircuitBreaker(
// // ... other parameters ...
// MakeVoidMetrics(), // No metrics overhead
// )
func MakeVoidMetrics() Metrics {
return &voidMetrics{io.Of(function.VOID)}
}

View File

@@ -504,3 +504,443 @@ func TestMetricsIOOperations(t *testing.T) {
assert.Len(t, lines, 3, "should execute multiple times")
})
}
// TestMakeVoidMetrics tests the MakeVoidMetrics constructor
func TestMakeVoidMetrics(t *testing.T) {
t.Run("creates valid Metrics implementation", func(t *testing.T) {
metrics := MakeVoidMetrics()
assert.NotNil(t, metrics, "MakeVoidMetrics should return non-nil Metrics")
})
t.Run("returns voidMetrics type", func(t *testing.T) {
metrics := MakeVoidMetrics()
_, ok := metrics.(*voidMetrics)
assert.True(t, ok, "should return *voidMetrics type")
})
t.Run("initializes noop IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics().(*voidMetrics)
assert.NotNil(t, metrics.noop, "noop IO operation should be initialized")
})
}
// TestVoidMetricsAccept tests the Accept method of voidMetrics
func TestVoidMetricsAccept(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics().(*voidMetrics)
timestamp := time.Now()
ioOp1 := metrics.Accept(timestamp)
ioOp2 := metrics.Accept(timestamp)
// Both should be non-nil (we can't compare functions directly in Go)
assert.NotNil(t, ioOp1, "should return non-nil IO operation")
assert.NotNil(t, ioOp2, "should return non-nil IO operation")
// Verify they execute without error
io.Run(ioOp1)
io.Run(ioOp2)
})
t.Run("ignores timestamp parameter", func(t *testing.T) {
metrics := MakeVoidMetrics()
time1 := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
time2 := time.Date(2026, 1, 9, 16, 30, 0, 0, time.UTC)
ioOp1 := metrics.Accept(time1)
ioOp2 := metrics.Accept(time2)
// Should return same operation regardless of timestamp
io.Run(ioOp1)
io.Run(ioOp2)
// No assertions needed - just verify it doesn't panic
})
}
// TestVoidMetricsReject tests the Reject method of voidMetrics
func TestVoidMetricsReject(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsOpen tests the Open method of voidMetrics
func TestVoidMetricsOpen(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsClose tests the Close method of voidMetrics
func TestVoidMetricsClose(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsCanary tests the Canary method of voidMetrics
func TestVoidMetricsCanary(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsThreadSafety tests concurrent access to voidMetrics
func TestVoidMetricsThreadSafety(t *testing.T) {
t.Run("handles concurrent metric calls", func(t *testing.T) {
metrics := MakeVoidMetrics()
var wg sync.WaitGroup
numGoroutines := 100
wg.Add(numGoroutines * 5) // 5 methods
timestamp := time.Now()
// Launch multiple goroutines calling all methods concurrently
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
io.Run(metrics.Accept(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Reject(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Open(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Close(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Canary(timestamp))
}()
}
wg.Wait()
// Test passes if no panic occurs
})
t.Run("all methods return valid IO operations concurrently", func(t *testing.T) {
metrics := MakeVoidMetrics()
var wg sync.WaitGroup
numGoroutines := 50
wg.Add(numGoroutines)
timestamp := time.Now()
results := make([]IO[Void], numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
// Each goroutine calls a different method
switch idx % 5 {
case 0:
results[idx] = metrics.Accept(timestamp)
case 1:
results[idx] = metrics.Reject(timestamp)
case 2:
results[idx] = metrics.Open(timestamp)
case 3:
results[idx] = metrics.Close(timestamp)
case 4:
results[idx] = metrics.Canary(timestamp)
}
}(i)
}
wg.Wait()
// All results should be non-nil and executable
for i, result := range results {
assert.NotNil(t, result, "result %d should be non-nil", i)
io.Run(result) // Verify it executes without error
}
})
}
// TestVoidMetricsPerformance tests performance characteristics
func TestVoidMetricsPerformance(t *testing.T) {
t.Run("has minimal overhead", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
// Execute many operations quickly
iterations := 10000
for i := 0; i < iterations; i++ {
io.Run(metrics.Accept(timestamp))
io.Run(metrics.Reject(timestamp))
io.Run(metrics.Open(timestamp))
io.Run(metrics.Close(timestamp))
io.Run(metrics.Canary(timestamp))
}
// Test passes if it completes quickly without issues
})
t.Run("all methods return valid IO operations", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
// All methods should return non-nil IO operations
accept := metrics.Accept(timestamp)
reject := metrics.Reject(timestamp)
open := metrics.Open(timestamp)
close := metrics.Close(timestamp)
canary := metrics.Canary(timestamp)
assert.NotNil(t, accept, "Accept should return non-nil")
assert.NotNil(t, reject, "Reject should return non-nil")
assert.NotNil(t, open, "Open should return non-nil")
assert.NotNil(t, close, "Close should return non-nil")
assert.NotNil(t, canary, "Canary should return non-nil")
// All should execute without error
io.Run(accept)
io.Run(reject)
io.Run(open)
io.Run(close)
io.Run(canary)
})
}
// TestVoidMetricsIntegration tests integration scenarios
func TestVoidMetricsIntegration(t *testing.T) {
t.Run("can be used as drop-in replacement for loggingMetrics", func(t *testing.T) {
// Create both types of metrics
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
loggingMetrics := MakeMetricsFromLogger("TestCircuit", logger)
voidMetrics := MakeVoidMetrics()
timestamp := time.Now()
// Both should implement the same interface
var m1 Metrics = loggingMetrics
var m2 Metrics = voidMetrics
// Both should be callable
io.Run(m1.Accept(timestamp))
io.Run(m2.Accept(timestamp))
// Logging metrics should have output
assert.NotEmpty(t, buf.String(), "logging metrics should produce output")
// Void metrics should have no observable side effects
// (we can't directly test this, but the test passes if no panic occurs)
})
t.Run("simulates complete circuit breaker lifecycle without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
baseTime := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
// Simulate circuit breaker lifecycle - all should be no-ops
io.Run(metrics.Accept(baseTime))
io.Run(metrics.Accept(baseTime.Add(1 * time.Second)))
io.Run(metrics.Open(baseTime.Add(2 * time.Second)))
io.Run(metrics.Reject(baseTime.Add(3 * time.Second)))
io.Run(metrics.Canary(baseTime.Add(30 * time.Second)))
io.Run(metrics.Close(baseTime.Add(31 * time.Second)))
// Test passes if no panic occurs and completes quickly
})
}
// TestVoidMetricsEdgeCases tests edge cases
func TestVoidMetricsEdgeCases(t *testing.T) {
t.Run("handles zero time", func(t *testing.T) {
metrics := MakeVoidMetrics()
zeroTime := time.Time{}
io.Run(metrics.Accept(zeroTime))
io.Run(metrics.Reject(zeroTime))
io.Run(metrics.Open(zeroTime))
io.Run(metrics.Close(zeroTime))
io.Run(metrics.Canary(zeroTime))
// Test passes if no panic occurs
})
t.Run("handles far future time", func(t *testing.T) {
metrics := MakeVoidMetrics()
futureTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
io.Run(metrics.Accept(futureTime))
io.Run(metrics.Reject(futureTime))
io.Run(metrics.Open(futureTime))
io.Run(metrics.Close(futureTime))
io.Run(metrics.Canary(futureTime))
// Test passes if no panic occurs
})
t.Run("IO operations are idempotent", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
// Execute same operation multiple times
io.Run(ioOp)
io.Run(ioOp)
io.Run(ioOp)
// Test passes if no panic occurs
})
}
// TestMetricsComparison compares loggingMetrics and voidMetrics
func TestMetricsComparison(t *testing.T) {
t.Run("both implement Metrics interface", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
var m1 Metrics = MakeMetricsFromLogger("Test", logger)
var m2 Metrics = MakeVoidMetrics()
assert.NotNil(t, m1)
assert.NotNil(t, m2)
})
t.Run("voidMetrics has no observable side effects unlike loggingMetrics", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
loggingMetrics := MakeMetricsFromLogger("Test", logger)
voidMetrics := MakeVoidMetrics()
timestamp := time.Now()
// Logging metrics produces output
io.Run(loggingMetrics.Accept(timestamp))
assert.NotEmpty(t, buf.String(), "logging metrics should produce output")
// Void metrics has no observable output
// (we can only verify it doesn't panic)
io.Run(voidMetrics.Accept(timestamp))
})
}

View File

@@ -34,6 +34,7 @@ import (
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/retry"
"github.com/IBM/fp-go/v2/state"
)
@@ -79,10 +80,13 @@ type (
// and produces a value of type A. Used for dependency injection and configuration.
Reader[R, A any] = reader.Reader[R, A]
ReaderIO[R, A any] = readerio.ReaderIO[R, A]
// openState represents the internal state when the circuit breaker is open.
// In the open state, requests are blocked to give the failing service time to recover.
// The circuit breaker will transition to a half-open state (canary request) after resetAt.
openState struct {
// openedAt is the time when the circuit breaker opened the circuit
openedAt time.Time
// resetAt is the time when the circuit breaker should attempt a canary request

View File

@@ -560,6 +560,63 @@ func Read[A any](r context.Context) func(ReaderIO[A]) IO[A] {
return RIO.Read[A](r)
}
// ReadIO executes a ReaderIO computation by providing a context wrapped in an IO effect.
// This is useful when the context itself needs to be computed or retrieved through side effects.
//
// The function takes an IO[context.Context] (an effectful computation that produces a context) and returns
// a function that can execute a ReaderIO[A] to produce an IO[A].
//
// This is particularly useful in scenarios where:
// - The context needs to be created with side effects (e.g., loading configuration)
// - The context requires initialization or setup
// - You want to compose context creation with the computation that uses it
//
// The execution flow is:
// 1. Execute the IO[context.Context] to get the context
// 2. Pass the context to the ReaderIO[A] to get an IO[A]
// 3. Execute the resulting IO[A] to get the final result A
//
// Type Parameters:
// - A: The result type of the ReaderIO computation
//
// Parameters:
// - r: An IO effect that produces a context.Context
//
// Returns:
// - A function that takes a ReaderIO[A] and returns an IO[A]
//
// Example:
//
// import (
// "context"
// G "github.com/IBM/fp-go/v2/io"
// F "github.com/IBM/fp-go/v2/function"
// )
//
// // Create context with side effects (e.g., loading config)
// createContext := G.Of(context.WithValue(context.Background(), "key", "value"))
//
// // A computation that uses the context
// getValue := readerio.FromReader(func(ctx context.Context) string {
// if val := ctx.Value("key"); val != nil {
// return val.(string)
// }
// return "default"
// })
//
// // Compose them together
// result := readerio.ReadIO[string](createContext)(getValue)
// value := result() // Executes both effects and returns "value"
//
// Comparison with Read:
// - [Read]: Takes a pure context.Context value and executes the ReaderIO immediately
// - [ReadIO]: Takes an IO[context.Context] and chains the effects together
//
//go:inline
func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
return RIO.ReadIO[A](r)
}
// Local transforms the context.Context environment before passing it to a ReaderIO computation.
//
// This is the Reader's local operation, which allows you to modify the environment

View File

@@ -500,3 +500,188 @@ func TestTapWithLogging(t *testing.T) {
assert.Equal(t, 84, value)
assert.Equal(t, []int{42, 84}, logged)
}
func TestReadIO(t *testing.T) {
// Test basic ReadIO functionality
contextIO := G.Of(context.WithValue(context.Background(), "testKey", "testValue"))
rio := FromReader(func(ctx context.Context) string {
if val := ctx.Value("testKey"); val != nil {
return val.(string)
}
return "default"
})
ioAction := ReadIO[string](contextIO)(rio)
result := ioAction()
assert.Equal(t, "testValue", result)
}
func TestReadIOWithBackground(t *testing.T) {
// Test ReadIO with plain background context
contextIO := G.Of(context.Background())
rio := Of(42)
ioAction := ReadIO[int](contextIO)(rio)
result := ioAction()
assert.Equal(t, 42, result)
}
func TestReadIOWithChain(t *testing.T) {
// Test ReadIO with chained operations
contextIO := G.Of(context.WithValue(context.Background(), "multiplier", 3))
result := F.Pipe1(
FromReader(func(ctx context.Context) int {
if val := ctx.Value("multiplier"); val != nil {
return val.(int)
}
return 1
}),
Chain(func(n int) ReaderIO[int] {
return Of(n * 10)
}),
)
ioAction := ReadIO[int](contextIO)(result)
value := ioAction()
assert.Equal(t, 30, value) // 3 * 10
}
func TestReadIOWithMap(t *testing.T) {
// Test ReadIO with Map operations
contextIO := G.Of(context.Background())
result := F.Pipe2(
Of(5),
Map(N.Mul(2)),
Map(N.Add(10)),
)
ioAction := ReadIO[int](contextIO)(result)
value := ioAction()
assert.Equal(t, 20, value) // (5 * 2) + 10
}
func TestReadIOWithSideEffects(t *testing.T) {
// Test ReadIO with side effects in context creation
counter := 0
contextIO := func() context.Context {
counter++
return context.WithValue(context.Background(), "counter", counter)
}
rio := FromReader(func(ctx context.Context) int {
if val := ctx.Value("counter"); val != nil {
return val.(int)
}
return 0
})
ioAction := ReadIO[int](contextIO)(rio)
result := ioAction()
assert.Equal(t, 1, result)
assert.Equal(t, 1, counter)
}
func TestReadIOMultipleExecutions(t *testing.T) {
// Test that ReadIO creates fresh effects on each execution
counter := 0
contextIO := func() context.Context {
counter++
return context.Background()
}
rio := Of(42)
ioAction := ReadIO[int](contextIO)(rio)
result1 := ioAction()
result2 := ioAction()
assert.Equal(t, 42, result1)
assert.Equal(t, 42, result2)
assert.Equal(t, 2, counter) // Context IO executed twice
}
func TestReadIOComparisonWithRead(t *testing.T) {
// Compare ReadIO with Read to show the difference
ctx := context.WithValue(context.Background(), "key", "value")
rio := FromReader(func(ctx context.Context) string {
if val := ctx.Value("key"); val != nil {
return val.(string)
}
return "default"
})
// Using Read (direct context)
ioAction1 := Read[string](ctx)(rio)
result1 := ioAction1()
// Using ReadIO (context wrapped in IO)
contextIO := G.Of(ctx)
ioAction2 := ReadIO[string](contextIO)(rio)
result2 := ioAction2()
assert.Equal(t, result1, result2)
assert.Equal(t, "value", result1)
assert.Equal(t, "value", result2)
}
func TestReadIOWithComplexContext(t *testing.T) {
// Test ReadIO with complex context manipulation
type contextKey string
const (
userKey contextKey = "user"
tokenKey contextKey = "token"
)
contextIO := G.Of(
context.WithValue(
context.WithValue(context.Background(), userKey, "Alice"),
tokenKey,
"secret123",
),
)
rio := FromReader(func(ctx context.Context) map[string]string {
result := make(map[string]string)
if user := ctx.Value(userKey); user != nil {
result["user"] = user.(string)
}
if token := ctx.Value(tokenKey); token != nil {
result["token"] = token.(string)
}
return result
})
ioAction := ReadIO[map[string]string](contextIO)(rio)
result := ioAction()
assert.Equal(t, "Alice", result["user"])
assert.Equal(t, "secret123", result["token"])
}
func TestReadIOWithAsk(t *testing.T) {
// Test ReadIO combined with Ask
contextIO := G.Of(context.WithValue(context.Background(), "data", 100))
result := F.Pipe1(
Ask(),
Map(func(ctx context.Context) int {
if val := ctx.Value("data"); val != nil {
return val.(int)
}
return 0
}),
)
ioAction := ReadIO[int](contextIO)(result)
value := ioAction()
assert.Equal(t, 100, value)
}

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/IBM/fp-go/v2/circuitbreaker"
"github.com/IBM/fp-go/v2/context/readerio"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/retry"
)
@@ -27,6 +28,9 @@ func MakeCircuitBreaker[T any](
Left,
ChainFirstIOK,
ChainFirstLeftIOK,
readerio.ChainFirstIOK,
FromIO,
Flap,
Flatten,

View File

@@ -914,6 +914,21 @@ func Read[A any](r context.Context) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.Read[A](r)
}
//go:inline
func ReadIO[A any](r IO[context.Context]) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.ReadIO[A](r)
}
//go:inline
func ReadIOEither[A any](r IOResult[context.Context]) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.ReadIOEither[A](r)
}
//go:inline
func ReadIOResult[A any](r IOResult[context.Context]) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.ReadIOResult[A](r)
}
// MonadChainLeft chains a computation on the left (error) side of a [ReaderIOResult].
// If the input is a Left value, it applies the function f to transform the error and potentially
// change the error type. If the input is a Right value, it passes through unchanged.

View File

@@ -148,6 +148,16 @@ func Read[A any](r context.Context) func(ReaderResult[A]) Result[A] {
return readereither.Read[error, A](r)
}
//go:inline
func ReadEither[A any](r Result[context.Context]) func(ReaderResult[A]) Result[A] {
return readereither.ReadEither[error, A](r)
}
//go:inline
func ReadResult[A any](r Result[context.Context]) func(ReaderResult[A]) Result[A] {
return readereither.ReadEither[error, A](r)
}
// MonadMapTo executes a ReaderResult computation, discards its success value, and returns a constant value.
// This is the monadic version that takes both the ReaderResult and the constant value as parameters.
//

91
v2/either/profunctor.go Normal file
View File

@@ -0,0 +1,91 @@
// 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 either
import F "github.com/IBM/fp-go/v2/function"
// MonadExtend applies a function to an Either value, where the function receives the entire Either as input.
// This is the Extend (or Comonad) operation that allows computations to depend on the context.
//
// If the Either is Left, it returns Left unchanged without applying the function.
// If the Either is Right, it applies the function to the entire Either and wraps the result in a Right.
//
// This operation is useful when you need to perform computations that depend on whether
// a value is present (Right) or absent (Left), not just on the value itself.
//
// Type Parameters:
// - E: The error type (Left channel)
// - A: The input value type (Right channel)
// - B: The output value type
//
// Parameters:
// - fa: The Either value to extend
// - f: Function that takes the entire Either[E, A] and produces a value of type B
//
// Returns:
// - Either[E, B]: Left if input was Left, otherwise Right containing the result of f(fa)
//
// Example:
//
// // Count how many times we've seen a Right value
// counter := func(e either.Either[error, int]) int {
// return either.Fold(
// func(err error) int { return 0 },
// func(n int) int { return 1 },
// )(e)
// }
// result := either.MonadExtend(either.Right[error](42), counter) // Right(1)
// result := either.MonadExtend(either.Left[int](errors.New("err")), counter) // Left(error)
//
//go:inline
func MonadExtend[E, A, B any](fa Either[E, A], f func(Either[E, A]) B) Either[E, B] {
if fa.isLeft {
return Left[B](fa.l)
}
return Of[E](f(fa))
}
// Extend is the curried version of [MonadExtend].
// It returns a function that applies the given function to an Either value.
//
// This is useful for creating reusable transformations that depend on the Either context.
//
// Type Parameters:
// - E: The error type (Left channel)
// - A: The input value type (Right channel)
// - B: The output value type
//
// Parameters:
// - f: Function that takes the entire Either[E, A] and produces a value of type B
//
// Returns:
// - Operator[E, A, B]: A function that transforms Either[E, A] to Either[E, B]
//
// Example:
//
// // Create a reusable extender that extracts metadata
// getMetadata := either.Extend(func(e either.Either[error, string]) string {
// return either.Fold(
// func(err error) string { return "error: " + err.Error() },
// func(s string) string { return "value: " + s },
// )(e)
// })
// result := getMetadata(either.Right[error]("hello")) // Right("value: hello")
//
//go:inline
func Extend[E, A, B any](f func(Either[E, A]) B) Operator[E, A, B] {
return F.Bind2nd(MonadExtend[E, A, B], f)
}

View File

@@ -0,0 +1,375 @@
// 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 either
import (
"errors"
"strconv"
"testing"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
)
// TestMonadExtendWithRight tests MonadExtend with Right values
func TestMonadExtendWithRight(t *testing.T) {
t.Run("applies function to Right value", func(t *testing.T) {
input := Right[error](42)
// Function that extracts and doubles the value if Right
f := func(e Either[error, int]) int {
return Fold(
F.Constant1[error](0),
N.Mul(2),
)(e)
}
result := MonadExtend(input, f)
assert.True(t, IsRight(result))
assert.Equal(t, 84, GetOrElse(F.Constant1[error](0))(result))
})
t.Run("function receives entire Either context", func(t *testing.T) {
input := Right[error]("hello")
// Function that creates metadata about the Either
f := func(e Either[error, string]) string {
return Fold(
func(err error) string { return "error: " + err.Error() },
S.Prepend("value: "),
)(e)
}
result := MonadExtend(input, f)
assert.True(t, IsRight(result))
assert.Equal(t, "value: hello", GetOrElse(func(error) string { return "" })(result))
})
t.Run("can count Right occurrences", func(t *testing.T) {
input := Right[error](100)
counter := func(e Either[error, int]) int {
return Fold(
F.Constant1[error](0),
F.Constant1[int](1),
)(e)
}
result := MonadExtend(input, counter)
assert.True(t, IsRight(result))
assert.Equal(t, 1, GetOrElse(func(error) int { return -1 })(result))
})
}
// TestMonadExtendWithLeft tests MonadExtend with Left values
func TestMonadExtendWithLeft(t *testing.T) {
t.Run("returns Left without applying function", func(t *testing.T) {
testErr := errors.New("test error")
input := Left[int](testErr)
// Function should not be called
called := false
f := func(e Either[error, int]) int {
called = true
return 42
}
result := MonadExtend(input, f)
assert.False(t, called, "function should not be called for Left")
assert.True(t, IsLeft(result))
_, leftVal := Unwrap(result)
assert.Equal(t, testErr, leftVal)
})
t.Run("preserves Left error type", func(t *testing.T) {
input := Left[string](errors.New("original error"))
f := func(e Either[error, string]) string {
return "should not be called"
}
result := MonadExtend(input, f)
assert.True(t, IsLeft(result))
_, leftVal := Unwrap(result)
assert.Equal(t, "original error", leftVal.Error())
})
}
// TestMonadExtendEdgeCases tests edge cases for MonadExtend
func TestMonadExtendEdgeCases(t *testing.T) {
t.Run("function returns zero value", func(t *testing.T) {
input := Right[error](42)
f := func(e Either[error, int]) int {
return 0
}
result := MonadExtend(input, f)
assert.True(t, IsRight(result))
assert.Equal(t, 0, GetOrElse(func(error) int { return -1 })(result))
})
t.Run("function changes type", func(t *testing.T) {
input := Right[error](42)
f := func(e Either[error, int]) string {
return Fold(
F.Constant1[error]("error"),
S.Format[int]("number: %d"),
)(e)
}
result := MonadExtend(input, f)
assert.True(t, IsRight(result))
assert.Equal(t, "number: 42", GetOrElse(func(error) string { return "" })(result))
})
t.Run("nested Either handling", func(t *testing.T) {
inner := Right[error](10)
outer := Right[error](inner)
// Extract the inner value
f := func(e Either[error, Either[error, int]]) int {
return Fold(
F.Constant1[error](-1),
func(innerEither Either[error, int]) int {
return GetOrElse(F.Constant1[error](-2))(innerEither)
},
)(e)
}
result := MonadExtend(outer, f)
assert.True(t, IsRight(result))
assert.Equal(t, 10, GetOrElse(F.Constant1[error](-3))(result))
})
}
// TestExtendWithRight tests Extend (curried version) with Right values
func TestExtendWithRight(t *testing.T) {
t.Run("creates reusable extender", func(t *testing.T) {
// Create a reusable extender
doubler := Extend(func(e Either[error, int]) int {
return Fold(
F.Constant1[error](0),
N.Mul(2),
)(e)
})
result1 := doubler(Right[error](21))
result2 := doubler(Right[error](50))
assert.True(t, IsRight(result1))
assert.Equal(t, 42, GetOrElse(F.Constant1[error](0))(result1))
assert.True(t, IsRight(result2))
assert.Equal(t, 100, GetOrElse(F.Constant1[error](0))(result2))
})
t.Run("metadata extractor", func(t *testing.T) {
getMetadata := Extend(func(e Either[error, string]) string {
return Fold(
func(err error) string { return "error: " + err.Error() },
S.Prepend("value: "),
)(e)
})
result := getMetadata(Right[error]("test"))
assert.True(t, IsRight(result))
assert.Equal(t, "value: test", GetOrElse(func(error) string { return "" })(result))
})
t.Run("composition with other operations", func(t *testing.T) {
// Create an extender that counts characters
charCounter := Extend(func(e Either[error, string]) int {
return Fold(
F.Constant1[error](0),
S.Size,
)(e)
})
// Apply to a Right value
input := Right[error]("hello")
result := charCounter(input)
assert.True(t, IsRight(result))
assert.Equal(t, 5, GetOrElse(func(error) int { return -1 })(result))
})
}
// TestExtendWithLeft tests Extend with Left values
func TestExtendWithLeft(t *testing.T) {
t.Run("returns Left without calling function", func(t *testing.T) {
testErr := errors.New("test error")
called := false
extender := Extend(func(e Either[error, int]) int {
called = true
return 42
})
result := extender(Left[int](testErr))
assert.False(t, called, "function should not be called for Left")
assert.True(t, IsLeft(result))
_, leftVal := Unwrap(result)
assert.Equal(t, testErr, leftVal)
})
t.Run("preserves error through multiple applications", func(t *testing.T) {
originalErr := errors.New("original")
extender := Extend(func(e Either[error, string]) string {
return "transformed"
})
result := extender(Left[string](originalErr))
assert.True(t, IsLeft(result))
_, leftVal := Unwrap(result)
assert.Equal(t, originalErr, leftVal)
})
}
// TestExtendChaining tests chaining multiple Extend operations
func TestExtendChaining(t *testing.T) {
t.Run("chain multiple extenders", func(t *testing.T) {
// First extender: double the value
doubler := Extend(func(e Either[error, int]) int {
return Fold(
F.Constant1[error](0),
N.Mul(2),
)(e)
})
// Second extender: add 10
adder := Extend(func(e Either[error, int]) int {
return Fold(
F.Constant1[error](0),
N.Add(10),
)(e)
})
input := Right[error](5)
result := adder(doubler(input))
assert.True(t, IsRight(result))
assert.Equal(t, 20, GetOrElse(F.Constant1[error](0))(result))
})
t.Run("short-circuits on Left", func(t *testing.T) {
testErr := errors.New("error")
extender1 := Extend(func(e Either[error, int]) int { return 1 })
extender2 := Extend(func(e Either[error, int]) int { return 2 })
input := Left[int](testErr)
result := extender2(extender1(input))
assert.True(t, IsLeft(result))
_, leftVal := Unwrap(result)
assert.Equal(t, testErr, leftVal)
})
}
// TestExtendTypeTransformations tests type transformations with Extend
func TestExtendTypeTransformations(t *testing.T) {
t.Run("int to string transformation", func(t *testing.T) {
toString := Extend(func(e Either[error, int]) string {
return Fold(
F.Constant1[error]("error"),
strconv.Itoa,
)(e)
})
result := toString(Right[error](42))
assert.True(t, IsRight(result))
assert.Equal(t, "42", GetOrElse(func(error) string { return "" })(result))
})
t.Run("string to bool transformation", func(t *testing.T) {
isEmpty := Extend(func(e Either[error, string]) bool {
return Fold(
F.Constant1[error](true),
S.IsEmpty,
)(e)
})
result1 := isEmpty(Right[error](""))
result2 := isEmpty(Right[error]("hello"))
assert.True(t, IsRight(result1))
assert.True(t, GetOrElse(F.Constant1[error](false))(result1))
assert.True(t, IsRight(result2))
assert.False(t, GetOrElse(F.Constant1[error](true))(result2))
})
}
// TestExtendWithComplexTypes tests Extend with complex types
func TestExtendWithComplexTypes(t *testing.T) {
type User struct {
Name string
Age int
}
t.Run("extract field from struct", func(t *testing.T) {
getName := Extend(func(e Either[error, User]) string {
return Fold(
func(err error) string { return "unknown" },
func(u User) string { return u.Name },
)(e)
})
user := User{Name: "Alice", Age: 30}
result := getName(Right[error](user))
assert.True(t, IsRight(result))
assert.Equal(t, "Alice", GetOrElse(func(error) string { return "" })(result))
})
t.Run("compute derived value", func(t *testing.T) {
isAdult := Extend(func(e Either[error, User]) bool {
return Fold(
func(err error) bool { return false },
func(u User) bool { return u.Age >= 18 },
)(e)
})
user1 := User{Name: "Bob", Age: 25}
user2 := User{Name: "Charlie", Age: 15}
result1 := isAdult(Right[error](user1))
result2 := isAdult(Right[error](user2))
assert.True(t, IsRight(result1))
assert.True(t, GetOrElse(F.Constant1[error](false))(result1))
assert.True(t, IsRight(result2))
assert.False(t, GetOrElse(F.Constant1[error](true))(result2))
})
}

89
v2/file/doc.go Normal file
View File

@@ -0,0 +1,89 @@
// 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 file provides functional programming utilities for working with file paths
// and I/O interfaces in Go.
//
// # Overview
//
// This package offers a collection of utility functions designed to work seamlessly
// with functional programming patterns, particularly with the fp-go library's pipe
// and composition utilities.
//
// # Path Manipulation
//
// The Join function provides a curried approach to path joining, making it easy to
// create reusable path builders:
//
// import (
// F "github.com/IBM/fp-go/v2/function"
// "github.com/IBM/fp-go/v2/file"
// )
//
// // Create a reusable path builder
// addConfig := file.Join("config.json")
// configPath := addConfig("/etc/myapp")
// // Result: "/etc/myapp/config.json"
//
// // Use in a functional pipeline
// logPath := F.Pipe1("/var/log", file.Join("app.log"))
// // Result: "/var/log/app.log"
//
// // Chain multiple joins
// deepPath := F.Pipe2(
// "/root",
// file.Join("subdir"),
// file.Join("file.txt"),
// )
// // Result: "/root/subdir/file.txt"
//
// # I/O Interface Conversions
//
// The package provides generic type conversion functions for common I/O interfaces.
// These are useful for type erasure when you need to work with interface types
// rather than concrete implementations:
//
// import (
// "bytes"
// "io"
// "github.com/IBM/fp-go/v2/file"
// )
//
// // Convert concrete types to interfaces
// buf := bytes.NewBuffer([]byte("hello"))
// var reader io.Reader = file.ToReader(buf)
//
// writer := &bytes.Buffer{}
// var w io.Writer = file.ToWriter(writer)
//
// f, _ := os.Open("file.txt")
// var closer io.Closer = file.ToCloser(f)
// defer closer.Close()
//
// # Design Philosophy
//
// The functions in this package follow functional programming principles:
//
// - Currying: Functions like Join return functions, enabling partial application
// - Type Safety: Generic functions maintain type safety while providing flexibility
// - Composability: All functions work well with fp-go's pipe and composition utilities
// - Immutability: Functions don't modify their inputs
//
// # Performance
//
// The type conversion functions (ToReader, ToWriter, ToCloser) have zero overhead
// as they simply return their input cast to the interface type. The Join function
// uses Go's standard filepath.Join internally, ensuring cross-platform compatibility.
package file

View File

@@ -13,6 +13,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package file provides utility functions for working with file paths and I/O interfaces.
// It offers functional programming utilities for path manipulation and type conversions
// for common I/O interfaces.
package file
import (
@@ -20,24 +23,93 @@ import (
"path/filepath"
)
// Join appends a filename to a root path
func Join(name string) func(root string) string {
// Join appends a filename to a root path using the operating system's path separator.
// Returns a curried function that takes a root path and joins it with the provided name.
//
// This function follows the "data last" principle, where the data (root path) is provided
// last, making it ideal for use in functional pipelines and partial application. The name
// parameter is fixed first, creating a reusable path builder function.
//
// This is useful for creating reusable path builders in functional pipelines.
//
// Example:
//
// import F "github.com/IBM/fp-go/v2/function"
//
// // Data last: fix the filename first, apply root path later
// addConfig := file.Join("config.json")
// path := addConfig("/etc/myapp")
// // path is "/etc/myapp/config.json" on Unix
// // path is "\etc\myapp\config.json" on Windows
//
// // Using with Pipe (data flows through the pipeline)
// result := F.Pipe1("/var/log", file.Join("app.log"))
// // result is "/var/log/app.log" on Unix
//
// // Chain multiple joins
// result := F.Pipe2(
// "/root",
// file.Join("subdir"),
// file.Join("file.txt"),
// )
// // result is "/root/subdir/file.txt"
func Join(name string) Endomorphism[string] {
return func(root string) string {
return filepath.Join(root, name)
}
}
// ToReader converts a [io.Reader]
// ToReader converts any type that implements io.Reader to the io.Reader interface.
// This is useful for type erasure when you need to work with the interface type
// rather than a concrete implementation.
//
// Example:
//
// import (
// "bytes"
// "io"
// )
//
// buf := bytes.NewBuffer([]byte("hello"))
// var reader io.Reader = file.ToReader(buf)
// // reader is now of type io.Reader
func ToReader[R io.Reader](r R) io.Reader {
return r
}
// ToWriter converts a [io.Writer]
// ToWriter converts any type that implements io.Writer to the io.Writer interface.
// This is useful for type erasure when you need to work with the interface type
// rather than a concrete implementation.
//
// Example:
//
// import (
// "bytes"
// "io"
// )
//
// buf := &bytes.Buffer{}
// var writer io.Writer = file.ToWriter(buf)
// // writer is now of type io.Writer
func ToWriter[W io.Writer](w W) io.Writer {
return w
}
// ToCloser converts a [io.Closer]
// ToCloser converts any type that implements io.Closer to the io.Closer interface.
// This is useful for type erasure when you need to work with the interface type
// rather than a concrete implementation.
//
// Example:
//
// import (
// "os"
// "io"
// )
//
// f, _ := os.Open("file.txt")
// var closer io.Closer = file.ToCloser(f)
// defer closer.Close()
// // closer is now of type io.Closer
func ToCloser[C io.Closer](c C) io.Closer {
return c
}

367
v2/file/getters_test.go Normal file
View File

@@ -0,0 +1,367 @@
// 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 file
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/stretchr/testify/assert"
)
func TestJoin(t *testing.T) {
t.Run("joins simple paths", func(t *testing.T) {
result := Join("config.json")("/etc/myapp")
expected := filepath.Join("/etc/myapp", "config.json")
assert.Equal(t, expected, result)
})
t.Run("joins with subdirectories", func(t *testing.T) {
result := Join("logs/app.log")("/var")
expected := filepath.Join("/var", "logs/app.log")
assert.Equal(t, expected, result)
})
t.Run("handles empty root", func(t *testing.T) {
result := Join("file.txt")("")
assert.Equal(t, "file.txt", result)
})
t.Run("handles empty name", func(t *testing.T) {
result := Join("")("/root")
expected := filepath.Join("/root", "")
assert.Equal(t, expected, result)
})
t.Run("handles relative paths", func(t *testing.T) {
result := Join("config.json")("./app")
expected := filepath.Join("./app", "config.json")
assert.Equal(t, expected, result)
})
t.Run("normalizes path separators", func(t *testing.T) {
result := Join("file.txt")("/root/path")
// Should use OS-specific separator
assert.Contains(t, result, "file.txt")
assert.Contains(t, result, "root")
assert.Contains(t, result, "path")
})
t.Run("works with Pipe", func(t *testing.T) {
result := F.Pipe1("/var/log", Join("app.log"))
expected := filepath.Join("/var/log", "app.log")
assert.Equal(t, expected, result)
})
t.Run("chains multiple joins", func(t *testing.T) {
result := F.Pipe2(
"/root",
Join("subdir"),
Join("file.txt"),
)
expected := filepath.Join("/root", "subdir", "file.txt")
assert.Equal(t, expected, result)
})
t.Run("handles special characters", func(t *testing.T) {
result := Join("my file.txt")("/path with spaces")
expected := filepath.Join("/path with spaces", "my file.txt")
assert.Equal(t, expected, result)
})
t.Run("handles dots in path", func(t *testing.T) {
result := Join("../config.json")("/app/current")
expected := filepath.Join("/app/current", "../config.json")
assert.Equal(t, expected, result)
})
}
func TestToReader(t *testing.T) {
t.Run("converts bytes.Buffer to io.Reader", func(t *testing.T) {
buf := bytes.NewBuffer([]byte("hello world"))
reader := ToReader(buf)
// Verify it's an io.Reader
var _ io.Reader = reader
// Verify it works
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "hello world", string(data))
})
t.Run("converts bytes.Reader to io.Reader", func(t *testing.T) {
bytesReader := bytes.NewReader([]byte("test data"))
reader := ToReader(bytesReader)
var _ io.Reader = reader
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "test data", string(data))
})
t.Run("converts strings.Reader to io.Reader", func(t *testing.T) {
strReader := strings.NewReader("string content")
reader := ToReader(strReader)
var _ io.Reader = reader
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "string content", string(data))
})
t.Run("preserves reader functionality", func(t *testing.T) {
original := bytes.NewBuffer([]byte("test"))
reader := ToReader(original)
// Read once
buf1 := make([]byte, 2)
n, err := reader.Read(buf1)
assert.NoError(t, err)
assert.Equal(t, 2, n)
assert.Equal(t, "te", string(buf1))
// Read again
buf2 := make([]byte, 2)
n, err = reader.Read(buf2)
assert.NoError(t, err)
assert.Equal(t, 2, n)
assert.Equal(t, "st", string(buf2))
})
t.Run("handles empty reader", func(t *testing.T) {
buf := bytes.NewBuffer([]byte{})
reader := ToReader(buf)
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "", string(data))
})
}
func TestToWriter(t *testing.T) {
t.Run("converts bytes.Buffer to io.Writer", func(t *testing.T) {
buf := &bytes.Buffer{}
writer := ToWriter(buf)
// Verify it's an io.Writer
var _ io.Writer = writer
// Verify it works
n, err := writer.Write([]byte("hello"))
assert.NoError(t, err)
assert.Equal(t, 5, n)
assert.Equal(t, "hello", buf.String())
})
t.Run("preserves writer functionality", func(t *testing.T) {
buf := &bytes.Buffer{}
writer := ToWriter(buf)
// Write multiple times
writer.Write([]byte("hello "))
writer.Write([]byte("world"))
assert.Equal(t, "hello world", buf.String())
})
t.Run("handles empty writes", func(t *testing.T) {
buf := &bytes.Buffer{}
writer := ToWriter(buf)
n, err := writer.Write([]byte{})
assert.NoError(t, err)
assert.Equal(t, 0, n)
assert.Equal(t, "", buf.String())
})
t.Run("handles large writes", func(t *testing.T) {
buf := &bytes.Buffer{}
writer := ToWriter(buf)
data := make([]byte, 10000)
for i := range data {
data[i] = byte('A' + (i % 26))
}
n, err := writer.Write(data)
assert.NoError(t, err)
assert.Equal(t, 10000, n)
assert.Equal(t, 10000, buf.Len())
})
}
func TestToCloser(t *testing.T) {
t.Run("converts file to io.Closer", func(t *testing.T) {
// Create a temporary file
tmpfile, err := os.CreateTemp("", "test")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
closer := ToCloser(tmpfile)
// Verify it's an io.Closer
var _ io.Closer = closer
// Verify it works
err = closer.Close()
assert.NoError(t, err)
})
t.Run("converts nopCloser to io.Closer", func(t *testing.T) {
// Use io.NopCloser which is a standard implementation
reader := strings.NewReader("test")
nopCloser := io.NopCloser(reader)
closer := ToCloser(nopCloser)
var _ io.Closer = closer
err := closer.Close()
assert.NoError(t, err)
})
t.Run("preserves close functionality", func(t *testing.T) {
tmpfile, err := os.CreateTemp("", "test")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
closer := ToCloser(tmpfile)
// Close should work
err = closer.Close()
assert.NoError(t, err)
// Subsequent operations should fail
_, err = tmpfile.Write([]byte("test"))
assert.Error(t, err)
})
}
// Test type conversions work together
func TestIntegration(t *testing.T) {
t.Run("reader and closer together", func(t *testing.T) {
tmpfile, err := os.CreateTemp("", "test")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
// Write some data
tmpfile.Write([]byte("test content"))
tmpfile.Seek(0, 0)
// Convert to interfaces
reader := ToReader(tmpfile)
closer := ToCloser(tmpfile)
// Use as reader
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "test content", string(data))
// Close
err = closer.Close()
assert.NoError(t, err)
})
t.Run("writer and closer together", func(t *testing.T) {
tmpfile, err := os.CreateTemp("", "test")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
// Convert to interfaces
writer := ToWriter(tmpfile)
closer := ToCloser(tmpfile)
// Use as writer
n, err := writer.Write([]byte("test data"))
assert.NoError(t, err)
assert.Equal(t, 9, n)
// Close
err = closer.Close()
assert.NoError(t, err)
// Verify data was written
data, err := os.ReadFile(tmpfile.Name())
assert.NoError(t, err)
assert.Equal(t, "test data", string(data))
})
t.Run("all conversions with file", func(t *testing.T) {
tmpfile, err := os.CreateTemp("", "test")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
// File implements Reader, Writer, and Closer
var reader io.Reader = ToReader(tmpfile)
var writer io.Writer = ToWriter(tmpfile)
var closer io.Closer = ToCloser(tmpfile)
// All should be non-nil
assert.NotNil(t, reader)
assert.NotNil(t, writer)
assert.NotNil(t, closer)
// Write, read, close
writer.Write([]byte("hello"))
tmpfile.Seek(0, 0)
data, _ := io.ReadAll(reader)
assert.Equal(t, "hello", string(data))
closer.Close()
})
}
// Benchmark tests
func BenchmarkJoin(b *testing.B) {
joiner := Join("config.json")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = joiner("/etc/myapp")
}
}
func BenchmarkToReader(b *testing.B) {
buf := bytes.NewBuffer([]byte("test data"))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ToReader(buf)
}
}
func BenchmarkToWriter(b *testing.B) {
buf := &bytes.Buffer{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ToWriter(buf)
}
}
func BenchmarkToCloser(b *testing.B) {
tmpfile, _ := os.CreateTemp("", "bench")
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ToCloser(tmpfile)
}
}

45
v2/file/types.go Normal file
View File

@@ -0,0 +1,45 @@
// 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 file
import "github.com/IBM/fp-go/v2/endomorphism"
type (
// Endomorphism represents a function from a type to itself: A -> A.
// This is a type alias for endomorphism.Endomorphism[A].
//
// In the context of the file package, this is used for functions that
// transform strings (paths) into strings (paths), such as the Join function.
//
// An endomorphism has useful algebraic properties:
// - Identity: There exists an identity endomorphism (the identity function)
// - Composition: Endomorphisms can be composed to form new endomorphisms
// - Associativity: Composition is associative
//
// Example:
//
// import F "github.com/IBM/fp-go/v2/function"
//
// // Join returns an Endomorphism[string]
// addConfig := file.Join("config.json") // Endomorphism[string]
// addLogs := file.Join("logs") // Endomorphism[string]
//
// // Compose endomorphisms
// addConfigLogs := F.Flow2(addLogs, addConfig)
// result := addConfigLogs("/var")
// // result is "/var/logs/config.json"
Endomorphism[A any] = endomorphism.Endomorphism[A]
)

320
v2/ord/monoid_test.go Normal file
View File

@@ -0,0 +1,320 @@
// 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 ord
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Test Semigroup laws
func TestSemigroup_Associativity(t *testing.T) {
type Person struct {
LastName string
FirstName string
MiddleName string
}
stringOrd := FromStrictCompare[string]()
byLastName := Contramap(func(p Person) string { return p.LastName })(stringOrd)
byFirstName := Contramap(func(p Person) string { return p.FirstName })(stringOrd)
byMiddleName := Contramap(func(p Person) string { return p.MiddleName })(stringOrd)
sg := Semigroup[Person]()
// Test associativity: (a <> b) <> c == a <> (b <> c)
left := sg.Concat(sg.Concat(byLastName, byFirstName), byMiddleName)
right := sg.Concat(byLastName, sg.Concat(byFirstName, byMiddleName))
p1 := Person{LastName: "Smith", FirstName: "John", MiddleName: "A"}
p2 := Person{LastName: "Smith", FirstName: "John", MiddleName: "B"}
assert.Equal(t, left.Compare(p1, p2), right.Compare(p1, p2), "Associativity should hold")
}
// Test Semigroup with three levels
func TestSemigroup_ThreeLevels(t *testing.T) {
type Employee struct {
Department string
Level int
Name string
}
stringOrd := FromStrictCompare[string]()
intOrd := FromStrictCompare[int]()
byDept := Contramap(func(e Employee) string { return e.Department })(stringOrd)
byLevel := Contramap(func(e Employee) int { return e.Level })(intOrd)
byName := Contramap(func(e Employee) string { return e.Name })(stringOrd)
sg := Semigroup[Employee]()
employeeOrd := sg.Concat(sg.Concat(byDept, byLevel), byName)
e1 := Employee{Department: "IT", Level: 3, Name: "Alice"}
e2 := Employee{Department: "IT", Level: 3, Name: "Bob"}
e3 := Employee{Department: "IT", Level: 2, Name: "Charlie"}
e4 := Employee{Department: "HR", Level: 3, Name: "David"}
// Same dept, same level, different name
assert.Equal(t, -1, employeeOrd.Compare(e1, e2), "Alice < Bob")
// Same dept, different level
assert.Equal(t, 1, employeeOrd.Compare(e1, e3), "Level 3 > Level 2")
// Different dept
assert.Equal(t, -1, employeeOrd.Compare(e4, e1), "HR < IT")
}
// Test Monoid identity laws
func TestMonoid_IdentityLaws(t *testing.T) {
m := Monoid[int]()
intOrd := FromStrictCompare[int]()
emptyOrd := m.Empty()
// Left identity: empty <> x == x
leftIdentity := m.Concat(emptyOrd, intOrd)
assert.Equal(t, -1, leftIdentity.Compare(3, 5), "Left identity: 3 < 5")
assert.Equal(t, 1, leftIdentity.Compare(5, 3), "Left identity: 5 > 3")
// Right identity: x <> empty == x
rightIdentity := m.Concat(intOrd, emptyOrd)
assert.Equal(t, -1, rightIdentity.Compare(3, 5), "Right identity: 3 < 5")
assert.Equal(t, 1, rightIdentity.Compare(5, 3), "Right identity: 5 > 3")
}
// Test Monoid with multiple empty concatenations
func TestMonoid_MultipleEmpty(t *testing.T) {
m := Monoid[int]()
emptyOrd := m.Empty()
// Concatenating multiple empty orderings should still be empty
combined := m.Concat(m.Concat(emptyOrd, emptyOrd), emptyOrd)
assert.Equal(t, 0, combined.Compare(5, 3), "Multiple empties: always equal")
assert.Equal(t, 0, combined.Compare(3, 5), "Multiple empties: always equal")
assert.True(t, combined.Equals(5, 3), "Multiple empties: always equal")
}
// Test MaxSemigroup with edge cases
func TestMaxSemigroup_EdgeCases(t *testing.T) {
intOrd := FromStrictCompare[int]()
maxSg := MaxSemigroup(intOrd)
tests := []struct {
name string
a int
b int
expected int
}{
{"both positive", 5, 3, 5},
{"both negative", -5, -3, -3},
{"mixed signs", -5, 3, 3},
{"zero and positive", 0, 5, 5},
{"zero and negative", 0, -5, 0},
{"both zero", 0, 0, 0},
{"equal positive", 5, 5, 5},
{"equal negative", -5, -5, -5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maxSg.Concat(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
// Test MinSemigroup with edge cases
func TestMinSemigroup_EdgeCases(t *testing.T) {
intOrd := FromStrictCompare[int]()
minSg := MinSemigroup(intOrd)
tests := []struct {
name string
a int
b int
expected int
}{
{"both positive", 5, 3, 3},
{"both negative", -5, -3, -5},
{"mixed signs", -5, 3, -5},
{"zero and positive", 0, 5, 0},
{"zero and negative", 0, -5, -5},
{"both zero", 0, 0, 0},
{"equal positive", 5, 5, 5},
{"equal negative", -5, -5, -5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := minSg.Concat(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
// Test MaxSemigroup with strings
func TestMaxSemigroup_Strings(t *testing.T) {
stringOrd := FromStrictCompare[string]()
maxSg := MaxSemigroup(stringOrd)
tests := []struct {
name string
a string
b string
expected string
}{
{"alphabetical", "apple", "banana", "banana"},
{"same string", "apple", "apple", "apple"},
{"empty and non-empty", "", "apple", "apple"},
{"both empty", "", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maxSg.Concat(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
// Test MinSemigroup with strings
func TestMinSemigroup_Strings(t *testing.T) {
stringOrd := FromStrictCompare[string]()
minSg := MinSemigroup(stringOrd)
tests := []struct {
name string
a string
b string
expected string
}{
{"alphabetical", "apple", "banana", "apple"},
{"same string", "apple", "apple", "apple"},
{"empty and non-empty", "", "apple", ""},
{"both empty", "", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := minSg.Concat(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
// Test MaxSemigroup associativity
func TestMaxSemigroup_Associativity(t *testing.T) {
intOrd := FromStrictCompare[int]()
maxSg := MaxSemigroup(intOrd)
// (a <> b) <> c == a <> (b <> c)
a, b, c := 5, 3, 7
left := maxSg.Concat(maxSg.Concat(a, b), c)
right := maxSg.Concat(a, maxSg.Concat(b, c))
assert.Equal(t, left, right, "MaxSemigroup should be associative")
assert.Equal(t, 7, left, "Should return maximum value")
}
// Test MinSemigroup associativity
func TestMinSemigroup_Associativity(t *testing.T) {
intOrd := FromStrictCompare[int]()
minSg := MinSemigroup(intOrd)
// (a <> b) <> c == a <> (b <> c)
a, b, c := 5, 3, 7
left := minSg.Concat(minSg.Concat(a, b), c)
right := minSg.Concat(a, minSg.Concat(b, c))
assert.Equal(t, left, right, "MinSemigroup should be associative")
assert.Equal(t, 3, left, "Should return minimum value")
}
// Test Semigroup with reversed ordering
func TestSemigroup_WithReverse(t *testing.T) {
type Person struct {
Age int
Name string
}
intOrd := FromStrictCompare[int]()
stringOrd := FromStrictCompare[string]()
// Order by age descending, then by name ascending
byAge := Contramap(func(p Person) int { return p.Age })(Reverse(intOrd))
byName := Contramap(func(p Person) string { return p.Name })(stringOrd)
sg := Semigroup[Person]()
personOrd := sg.Concat(byAge, byName)
p1 := Person{Age: 30, Name: "Alice"}
p2 := Person{Age: 30, Name: "Bob"}
p3 := Person{Age: 25, Name: "Charlie"}
// Same age, different name
assert.Equal(t, -1, personOrd.Compare(p1, p2), "Alice < Bob (same age)")
// Different age (descending)
assert.Equal(t, -1, personOrd.Compare(p1, p3), "30 > 25 (descending)")
}
// Benchmark MaxSemigroup
func BenchmarkMaxSemigroup(b *testing.B) {
intOrd := FromStrictCompare[int]()
maxSg := MaxSemigroup(intOrd)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = maxSg.Concat(i, i+1)
}
}
// Benchmark MinSemigroup
func BenchmarkMinSemigroup(b *testing.B) {
intOrd := FromStrictCompare[int]()
minSg := MinSemigroup(intOrd)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = minSg.Concat(i, i+1)
}
}
// Benchmark Semigroup concatenation
func BenchmarkSemigroup_Concat(b *testing.B) {
type Person struct {
LastName string
FirstName string
}
stringOrd := FromStrictCompare[string]()
byLastName := Contramap(func(p Person) string { return p.LastName })(stringOrd)
byFirstName := Contramap(func(p Person) string { return p.FirstName })(stringOrd)
sg := Semigroup[Person]()
personOrd := sg.Concat(byLastName, byFirstName)
p1 := Person{LastName: "Smith", FirstName: "Alice"}
p2 := Person{LastName: "Smith", FirstName: "Bob"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = personOrd.Compare(p1, p2)
}
}

View File

@@ -171,7 +171,7 @@ func Reverse[T any](o Ord[T]) Ord[T] {
// return p.Age
// })(intOrd)
// // Now persons are ordered by age
func Contramap[A, B any](f func(B) A) func(Ord[A]) Ord[B] {
func Contramap[A, B any](f func(B) A) Operator[A, B] {
return func(o Ord[A]) Ord[B] {
return MakeOrd(func(x, y B) int {
return o.Compare(f(x), f(y))
@@ -373,6 +373,8 @@ func Between[A any](o Ord[A]) func(A, A) func(A) bool {
}
}
// compareTime is a helper function that compares two time.Time values.
// Returns -1 if a is before b, 1 if a is after b, and 0 if they are equal.
func compareTime(a, b time.Time) int {
if a.Before(b) {
return -1

59
v2/ord/types.go Normal file
View File

@@ -0,0 +1,59 @@
// 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 ord
type (
// Kleisli represents a function that takes a value of type A and returns an Ord[B].
// This is useful for creating orderings that depend on input values.
//
// Type Parameters:
// - A: The input type
// - B: The type for which ordering is produced
//
// Example:
//
// // Create a Kleisli that produces different orderings based on input
// var orderingFactory Kleisli[string, int] = func(mode string) Ord[int] {
// if mode == "ascending" {
// return ord.FromStrictCompare[int]()
// }
// return ord.Reverse(ord.FromStrictCompare[int]())
// }
// ascOrd := orderingFactory("ascending")
// descOrd := orderingFactory("descending")
Kleisli[A, B any] = func(A) Ord[B]
// Operator represents a function that transforms an Ord[A] into a value of type B.
// This is commonly used for operations that modify or combine orderings.
//
// Type Parameters:
// - A: The type for which ordering is defined
// - B: The result type of the operation
//
// This is equivalent to Kleisli[Ord[A], B] and is used for operations like
// Contramap, which takes an Ord[A] and produces an Ord[B].
//
// Example:
//
// // Contramap is an Operator that transforms Ord[A] to Ord[B]
// type Person struct { Age int }
// var ageOperator Operator[int, Person] = ord.Contramap(func(p Person) int {
// return p.Age
// })
// intOrd := ord.FromStrictCompare[int]()
// personOrd := ageOperator(intOrd)
Operator[A, B any] = Kleisli[Ord[A], B]
)

203
v2/ord/types_test.go Normal file
View File

@@ -0,0 +1,203 @@
// 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 ord
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Test Kleisli type
func TestKleisli(t *testing.T) {
// Create a Kleisli that produces different orderings based on input
var orderingFactory Kleisli[string, int] = func(mode string) Ord[int] {
if mode == "ascending" {
return FromStrictCompare[int]()
}
return Reverse(FromStrictCompare[int]())
}
// Test ascending order
ascOrd := orderingFactory("ascending")
assert.Equal(t, -1, ascOrd.Compare(3, 5), "ascending: 3 < 5")
assert.Equal(t, 1, ascOrd.Compare(5, 3), "ascending: 5 > 3")
assert.Equal(t, 0, ascOrd.Compare(5, 5), "ascending: 5 == 5")
// Test descending order
descOrd := orderingFactory("descending")
assert.Equal(t, 1, descOrd.Compare(3, 5), "descending: 3 > 5")
assert.Equal(t, -1, descOrd.Compare(5, 3), "descending: 5 < 3")
assert.Equal(t, 0, descOrd.Compare(5, 5), "descending: 5 == 5")
}
// Test Kleisli with complex types
func TestKleisli_ComplexType(t *testing.T) {
type Person struct {
Name string
Age int
}
// Kleisli that creates orderings based on a field selector
var personOrderingFactory Kleisli[string, Person] = func(field string) Ord[Person] {
stringOrd := FromStrictCompare[string]()
intOrd := FromStrictCompare[int]()
switch field {
case "name":
return Contramap(func(p Person) string { return p.Name })(stringOrd)
case "age":
return Contramap(func(p Person) int { return p.Age })(intOrd)
default:
// Default to name ordering
return Contramap(func(p Person) string { return p.Name })(stringOrd)
}
}
p1 := Person{Name: "Alice", Age: 30}
p2 := Person{Name: "Bob", Age: 25}
// Order by name
nameOrd := personOrderingFactory("name")
assert.Equal(t, -1, nameOrd.Compare(p1, p2), "Alice < Bob by name")
// Order by age
ageOrd := personOrderingFactory("age")
assert.Equal(t, 1, ageOrd.Compare(p1, p2), "30 > 25 by age")
}
// Test Operator type
func TestOperator(t *testing.T) {
type Person struct {
Name string
Age int
}
// Operator that transforms Ord[int] to Ord[Person] by age
var ageOperator Operator[int, Person] = Contramap(func(p Person) int {
return p.Age
})
intOrd := FromStrictCompare[int]()
personOrd := ageOperator(intOrd)
p1 := Person{Name: "Alice", Age: 30}
p2 := Person{Name: "Bob", Age: 25}
p3 := Person{Name: "Charlie", Age: 30}
assert.Equal(t, 1, personOrd.Compare(p1, p2), "30 > 25")
assert.Equal(t, -1, personOrd.Compare(p2, p1), "25 < 30")
assert.Equal(t, 0, personOrd.Compare(p1, p3), "30 == 30")
assert.True(t, personOrd.Equals(p1, p3), "same age")
assert.False(t, personOrd.Equals(p1, p2), "different age")
}
// Test Operator composition
func TestOperator_Composition(t *testing.T) {
type Address struct {
Street string
City string
}
type Person struct {
Name string
Address Address
}
// Create operators for different transformations
stringOrd := FromStrictCompare[string]()
// Operator to order Person by city
var cityOperator Operator[string, Person] = Contramap(func(p Person) string {
return p.Address.City
})
personOrd := cityOperator(stringOrd)
p1 := Person{Name: "Alice", Address: Address{Street: "Main St", City: "Boston"}}
p2 := Person{Name: "Bob", Address: Address{Street: "Oak Ave", City: "Austin"}}
assert.Equal(t, 1, personOrd.Compare(p1, p2), "Boston > Austin")
assert.Equal(t, -1, personOrd.Compare(p2, p1), "Austin < Boston")
}
// Test Operator with multiple transformations
func TestOperator_MultipleTransformations(t *testing.T) {
type Product struct {
Name string
Price float64
}
floatOrd := FromStrictCompare[float64]()
// Operator to order by price
var priceOperator Operator[float64, Product] = Contramap(func(p Product) float64 {
return p.Price
})
// Operator to reverse the ordering
var reverseOperator Operator[float64, Product] = func(o Ord[float64]) Ord[Product] {
return priceOperator(Reverse(o))
}
// Order by price descending
productOrd := reverseOperator(floatOrd)
prod1 := Product{Name: "Widget", Price: 19.99}
prod2 := Product{Name: "Gadget", Price: 29.99}
assert.Equal(t, 1, productOrd.Compare(prod1, prod2), "19.99 > 29.99 (reversed)")
assert.Equal(t, -1, productOrd.Compare(prod2, prod1), "29.99 < 19.99 (reversed)")
}
// Example test for Kleisli
func ExampleKleisli() {
// Create a Kleisli that produces different orderings based on input
var orderingFactory Kleisli[string, int] = func(mode string) Ord[int] {
if mode == "ascending" {
return FromStrictCompare[int]()
}
return Reverse(FromStrictCompare[int]())
}
ascOrd := orderingFactory("ascending")
descOrd := orderingFactory("descending")
println(ascOrd.Compare(5, 3)) // 1
println(descOrd.Compare(5, 3)) // -1
}
// Example test for Operator
func ExampleOperator() {
type Person struct {
Name string
Age int
}
// Operator that transforms Ord[int] to Ord[Person] by age
var ageOperator Operator[int, Person] = Contramap(func(p Person) int {
return p.Age
})
intOrd := FromStrictCompare[int]()
personOrd := ageOperator(intOrd)
p1 := Person{Name: "Alice", Age: 30}
p2 := Person{Name: "Bob", Age: 25}
result := personOrd.Compare(p1, p2)
println(result) // 1 (30 > 25)
}

View File

@@ -60,6 +60,8 @@ import (
// - You need to partially apply environments in a different order
// - You're composing functions that expect parameters in reverse order
// - You want to curry multi-parameter functions differently
//
//go:inline
func Sequence[R1, R2, A any](ma Reader[R2, Reader[R1, A]]) Kleisli[R2, R1, A] {
return function.Flip(ma)
}

View File

@@ -249,6 +249,34 @@ func MonadChain[R, A, B any](ma Reader[R, A], f Kleisli[R, A, B]) Reader[R, B] {
// Chain sequences two Reader computations where the second depends on the result of the first.
// This is the Monad operation that enables dependent computations.
//
// Relationship with Compose:
//
// Chain and Compose serve different purposes in Reader composition:
//
// - Chain: Monadic composition - sequences Readers that share the SAME environment type.
// The second Reader depends on the VALUE produced by the first Reader, but both
// Readers receive the same environment R. This is the monadic bind (>>=) operation.
// Signature: Chain[R, A, B](f: A -> Reader[R, B]) -> Reader[R, A] -> Reader[R, B]
//
// - Compose: Function composition - chains Readers where the OUTPUT of the first
// becomes the INPUT environment of the second. The environment types can differ.
// This is standard function composition (.) for Readers as functions.
// Signature: Compose[C, R, B](ab: Reader[R, B]) -> Reader[B, C] -> Reader[R, C]
//
// Key Differences:
//
// 1. Environment handling:
// - Chain: Both Readers use the same environment R
// - Compose: First Reader's output B becomes second Reader's input environment
//
// 2. Data flow:
// - Chain: R -> A, then A -> Reader[R, B], both using same R
// - Compose: R -> B, then B -> C (B is both output and environment)
//
// 3. Use cases:
// - Chain: Dependent computations in the same context (e.g., fetch user, then fetch user's posts)
// - Compose: Transforming nested environments (e.g., extract config from app state, then read from config)
//
// Example:
//
// type Config struct { UserId int }
@@ -360,6 +388,53 @@ func Flatten[R, A any](mma Reader[R, Reader[R, A]]) Reader[R, A] {
// Compose composes two Readers sequentially, where the output environment of the first
// becomes the input environment of the second.
//
// Relationship with Chain:
//
// Compose and Chain serve different purposes in Reader composition:
//
// - Compose: Function composition - chains Readers where the OUTPUT of the first
// becomes the INPUT environment of the second. The environment types can differ.
// This is standard function composition (.) for Readers as functions.
// Signature: Compose[C, R, B](ab: Reader[R, B]) -> Reader[B, C] -> Reader[R, C]
//
// - Chain: Monadic composition - sequences Readers that share the SAME environment type.
// The second Reader depends on the VALUE produced by the first Reader, but both
// Readers receive the same environment R. This is the monadic bind (>>=) operation.
// Signature: Chain[R, A, B](f: A -> Reader[R, B]) -> Reader[R, A] -> Reader[R, B]
//
// Key Differences:
//
// 1. Environment handling:
// - Compose: First Reader's output B becomes second Reader's input environment
// - Chain: Both Readers use the same environment R
//
// 2. Data flow:
// - Compose: R -> B, then B -> C (B is both output and environment)
// - Chain: R -> A, then A -> Reader[R, B], both using same R
//
// 3. Use cases:
// - Compose: Transforming nested environments (e.g., extract config from app state, then read from config)
// - Chain: Dependent computations in the same context (e.g., fetch user, then fetch user's posts)
//
// Visual Comparison:
//
// // Compose: Environment transformation
// type AppState struct { Config Config }
// type Config struct { Port int }
// getConfig := func(s AppState) Config { return s.Config }
// getPort := func(c Config) int { return c.Port }
// getPortFromState := reader.Compose(getConfig)(getPort)
// // Flow: AppState -> Config -> int (Config is both output and next input)
//
// // Chain: Same environment, dependent values
// type Env struct { UserId int; Users map[int]string }
// getUserId := func(e Env) int { return e.UserId }
// getUser := func(id int) reader.Reader[Env, string] {
// return func(e Env) string { return e.Users[id] }
// }
// getUserName := reader.Chain(getUser)(getUserId)
// // Flow: Env -> int, then int -> Reader[Env, string] (Env used twice)
//
// Example:
//
// type Config struct { Port int }

View File

@@ -160,6 +160,66 @@ func Read[E1, A, E any](e E) func(ReaderEither[E, E1, A]) Either[E1, A] {
return reader.Read[Either[E1, A]](e)
}
// ReadEither applies a context wrapped in an Either to a ReaderEither to obtain its result.
// This function is useful when the context itself may be absent or invalid (represented as Left),
// allowing you to conditionally execute a ReaderEither computation based on the availability
// of the required context.
//
// If the context Either is Left, it short-circuits and returns Left without executing the ReaderEither.
// If the context Either is Right, it extracts the context value and applies it to the ReaderEither,
// returning the resulting Either.
//
// This is particularly useful in scenarios where:
// - Configuration or dependencies may be missing or invalid
// - You want to chain context validation with computation execution
// - You need to propagate context errors through your computation pipeline
//
// Type Parameters:
// - E1: The error type (Left value) of both the input Either and the ReaderEither result
// - A: The success type (Right value) of the ReaderEither result
// - E: The context/environment type that the ReaderEither depends on
//
// Parameters:
// - e: An Either[E1, E] representing the context that may or may not be available
//
// Returns:
// - A function that takes a ReaderEither[E, E1, A] and returns Either[E1, A]
//
// Example:
//
// type Config struct{ apiKey string }
// type ConfigError struct{ msg string }
//
// // A computation that needs config
// fetchData := func(cfg Config) either.Either[ConfigError, string] {
// if cfg.apiKey == "" {
// return either.Left[string](ConfigError{"missing API key"})
// }
// return either.Right[ConfigError]("data from API")
// }
//
// // Context may be invalid
// validConfig := either.Right[ConfigError](Config{apiKey: "secret"})
// invalidConfig := either.Left[Config](ConfigError{"config not found"})
//
// computation := readereither.FromReader[ConfigError](fetchData)
//
// // With valid config - executes computation
// result1 := readereither.ReadEither(validConfig)(computation)
// // result1 = Right("data from API")
//
// // With invalid config - short-circuits without executing
// result2 := readereither.ReadEither(invalidConfig)(computation)
// // result2 = Left(ConfigError{"config not found"})
//
//go:inline
func ReadEither[E1, A, E any](e Either[E1, E]) func(ReaderEither[E, E1, A]) Either[E1, A] {
return function.Flow2(
ET.Chain[E1, E],
Read[E1, A](e),
)
}
func MonadFlap[L, E, A, B any](fab ReaderEither[L, E, func(A) B], a A) ReaderEither[L, E, B] {
return functor.MonadFlap(MonadMap[L, E, func(A) B, B], fab, a)
}

View File

@@ -223,3 +223,164 @@ func TestOrElse(t *testing.T) {
appResult := wideningRecover(validationErr)(Config{})
assert.Equal(t, ET.Right[AppError](100), appResult)
}
func TestReadEither(t *testing.T) {
type Config struct {
apiKey string
host string
}
// Test with Right context - should execute the ReaderEither
t.Run("Right context executes computation", func(t *testing.T) {
validConfig := ET.Right[string](Config{apiKey: "secret", host: "localhost"})
computation := func(cfg Config) Either[string, int] {
if cfg.apiKey == "secret" {
return ET.Right[string](42)
}
return ET.Left[int]("invalid key")
}
result := ReadEither[string, int](validConfig)(computation)
assert.Equal(t, ET.Right[string](42), result)
})
// Test with Right context but computation fails
t.Run("Right context with failing computation", func(t *testing.T) {
validConfig := ET.Right[string](Config{apiKey: "wrong", host: "localhost"})
computation := func(cfg Config) Either[string, int] {
if cfg.apiKey == "secret" {
return ET.Right[string](42)
}
return ET.Left[int]("invalid key")
}
result := ReadEither[string, int](validConfig)(computation)
assert.Equal(t, ET.Left[int]("invalid key"), result)
})
// Test with Left context - should short-circuit without executing
t.Run("Left context short-circuits", func(t *testing.T) {
invalidConfig := ET.Left[Config]("config not found")
executed := false
computation := func(cfg Config) Either[string, int] {
executed = true
return ET.Right[string](42)
}
result := ReadEither[string, int](invalidConfig)(computation)
assert.Equal(t, ET.Left[int]("config not found"), result)
assert.False(t, executed, "computation should not be executed with Left context")
})
// Test with complex ReaderEither computation
t.Run("Complex ReaderEither computation", func(t *testing.T) {
validConfig := ET.Right[string](Config{apiKey: "secret", host: "api.example.com"})
// A more complex computation using the config
computation := F.Pipe2(
Ask[Config, string](),
Map[Config, string](func(cfg Config) string {
return cfg.host + "/data"
}),
Chain[Config, string, string, int](func(url string) ReaderEither[Config, string, int] {
return func(cfg Config) Either[string, int] {
if cfg.apiKey != "" {
return ET.Right[string](len(url))
}
return ET.Left[int]("no API key")
}
}),
)
result := ReadEither[string, int](validConfig)(computation)
assert.Equal(t, ET.Right[string](20), result) // len("api.example.com/data") = 20
})
// Test error type consistency
t.Run("Error type consistency", func(t *testing.T) {
type AppError struct {
code int
message string
}
configError := AppError{code: 404, message: "config not found"}
invalidConfig := ET.Left[Config](configError)
computation := func(cfg Config) Either[AppError, string] {
return ET.Right[AppError]("success")
}
result := ReadEither[AppError, string](invalidConfig)(computation)
assert.Equal(t, ET.Left[string](configError), result)
})
// Test with chained operations
t.Run("Chained operations with ReadEither", func(t *testing.T) {
config1 := ET.Right[string](Config{apiKey: "key1", host: "host1"})
config2 := ET.Right[string](Config{apiKey: "key2", host: "host2"})
computation := func(cfg Config) Either[string, string] {
return ET.Right[string](cfg.host)
}
// Apply first config
result1 := ReadEither[string, string](config1)(computation)
assert.Equal(t, ET.Right[string]("host1"), result1)
// Apply second config
result2 := ReadEither[string, string](config2)(computation)
assert.Equal(t, ET.Right[string]("host2"), result2)
})
// Test with FromReader
t.Run("ReadEither with FromReader", func(t *testing.T) {
validConfig := ET.Right[string](Config{apiKey: "secret", host: "localhost"})
// Create a ReaderEither from a Reader
readerComputation := func(cfg Config) int {
return len(cfg.apiKey)
}
computation := FromReader[string](readerComputation)
result := ReadEither[string, int](validConfig)(computation)
assert.Equal(t, ET.Right[string](6), result) // len("secret") = 6
})
// Test with Of (pure value)
t.Run("ReadEither with pure value", func(t *testing.T) {
validConfig := ET.Right[string](Config{apiKey: "secret", host: "localhost"})
computation := Of[Config, string](100)
result := ReadEither[string, int](validConfig)(computation)
assert.Equal(t, ET.Right[string](100), result)
})
// Test with Left computation
t.Run("ReadEither with Left computation", func(t *testing.T) {
validConfig := ET.Right[string](Config{apiKey: "secret", host: "localhost"})
computation := Left[Config, int]("computation error")
result := ReadEither[string, int](validConfig)(computation)
assert.Equal(t, ET.Left[int]("computation error"), result)
})
// Test composition with Read
t.Run("ReadEither vs Read comparison", func(t *testing.T) {
config := Config{apiKey: "secret", host: "localhost"}
computation := func(cfg Config) Either[string, int] {
return ET.Right[string](len(cfg.apiKey))
}
// Using Read directly
resultRead := Read[string, int](config)(computation)
// Using ReadEither with Right
resultReadEither := ReadEither[string, int](ET.Right[string](config))(computation)
assert.Equal(t, resultRead, resultReadEither)
})
}

View File

@@ -1112,6 +1112,63 @@ func Read[A, R any](r R) func(ReaderIO[R, A]) IO[A] {
return reader.Read[IO[A]](r)
}
// ReadIO executes a ReaderIO computation by providing an environment wrapped in an IO effect.
// This is useful when the environment itself needs to be computed or retrieved through side effects.
//
// The function takes an IO[R] (an effectful computation that produces an environment) and returns
// a function that can execute a ReaderIO[R, A] to produce an IO[A].
//
// This is particularly useful in scenarios where:
// - The environment needs to be loaded from a file, database, or network
// - The environment requires initialization with side effects
// - You want to compose environment retrieval with the computation that uses it
//
// The execution flow is:
// 1. Execute the IO[R] to get the environment R
// 2. Pass the environment to the ReaderIO[R, A] to get an IO[A]
// 3. Execute the resulting IO[A] to get the final result A
//
// Type Parameters:
// - A: The result type of the ReaderIO computation
// - R: The environment type required by the ReaderIO
//
// Parameters:
// - r: An IO effect that produces the environment of type R
//
// Returns:
// - A function that takes a ReaderIO[R, A] and returns an IO[A]
//
// Example:
//
// type Config struct {
// DatabaseURL string
// Port int
// }
//
// // Load config from file (side effect)
// loadConfig := io.Of(Config{DatabaseURL: "localhost:5432", Port: 8080})
//
// // A computation that uses the config
// getConnectionString := readerio.Asks(func(c Config) io.IO[string] {
// return io.Of(c.DatabaseURL)
// })
//
// // Compose them together
// result := readerio.ReadIO[string](loadConfig)(getConnectionString)
// connectionString := result() // Executes both effects and returns "localhost:5432"
//
// Comparison with Read:
// - [Read]: Takes a pure value R and executes the ReaderIO immediately
// - [ReadIO]: Takes an IO[R] and chains the effects together
//
//go:inline
func ReadIO[A, R any](r IO[R]) func(ReaderIO[R, A]) IO[A] {
return function.Flow2(
io.Chain[R, A],
Read[A](r),
)
}
// Delay creates an operation that passes in the value after some delay
//
//go:inline

View File

@@ -23,6 +23,7 @@ import (
"github.com/IBM/fp-go/v2/internal/utils"
G "github.com/IBM/fp-go/v2/io"
N "github.com/IBM/fp-go/v2/number"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
)
@@ -697,6 +698,150 @@ func TestRead(t *testing.T) {
assert.Equal(t, 42, result)
}
func TestReadIO(t *testing.T) {
t.Run("basic usage with IO environment", func(t *testing.T) {
// Create a ReaderIO that uses the config
rio := Of[ReaderTestConfig](42)
// Create an IO that produces the config
configIO := G.Of(ReaderTestConfig{Value: 21, Name: "test"})
// Use ReadIO to execute the ReaderIO with the IO environment
result := ReadIO[int](configIO)(rio)()
assert.Equal(t, 42, result)
})
t.Run("chains IO effects correctly", func(t *testing.T) {
// Track execution order
executionOrder := []string{}
// Create an IO that produces the config with a side effect
configIO := func() ReaderTestConfig {
executionOrder = append(executionOrder, "load config")
return ReaderTestConfig{Value: 10, Name: "test"}
}
// Create a ReaderIO that uses the config with a side effect
rio := func(c ReaderTestConfig) G.IO[int] {
return func() int {
executionOrder = append(executionOrder, "use config")
return c.Value * 3
}
}
// Execute the composed computation
result := ReadIO[int](configIO)(rio)()
assert.Equal(t, 30, result)
assert.Equal(t, []string{"load config", "use config"}, executionOrder)
})
t.Run("works with complex environment loading", func(t *testing.T) {
// Simulate loading config from a file or database
loadConfigFromDB := func() ReaderTestConfig {
// Simulate side effect
return ReaderTestConfig{Value: 100, Name: "production"}
}
// A computation that depends on the loaded config
getConnectionString := func(c ReaderTestConfig) G.IO[string] {
return G.Of(c.Name + ":" + S.Format[int]("%d")(c.Value))
}
result := ReadIO[string](loadConfigFromDB)(getConnectionString)()
assert.Equal(t, "production:100", result)
})
t.Run("composes with other ReaderIO operations", func(t *testing.T) {
configIO := G.Of(ReaderTestConfig{Value: 5, Name: "test"})
// Build a pipeline using ReaderIO operations
pipeline := F.Pipe2(
Ask[ReaderTestConfig](),
Map[ReaderTestConfig](func(c ReaderTestConfig) int { return c.Value }),
Chain(func(n int) ReaderIO[ReaderTestConfig, int] {
return Of[ReaderTestConfig](n * 4)
}),
)
result := ReadIO[int](configIO)(pipeline)()
assert.Equal(t, 20, result)
})
t.Run("handles environment with multiple fields", func(t *testing.T) {
configIO := G.Of(ReaderTestConfig{Value: 42, Name: "answer"})
// Access both fields from the environment
rio := func(c ReaderTestConfig) G.IO[string] {
return G.Of(c.Name + "=" + S.Format[int]("%d")(c.Value))
}
result := ReadIO[string](configIO)(rio)()
assert.Equal(t, "answer=42", result)
})
t.Run("lazy evaluation - IO not executed until called", func(t *testing.T) {
executed := false
configIO := func() ReaderTestConfig {
executed = true
return ReaderTestConfig{Value: 1, Name: "test"}
}
rio := Of[ReaderTestConfig](42)
// Create the composed IO but don't execute it yet
composedIO := ReadIO[int](configIO)(rio)
// Config IO should not be executed yet
assert.False(t, executed)
// Now execute it
result := composedIO()
// Now it should be executed
assert.True(t, executed)
assert.Equal(t, 42, result)
})
t.Run("works with ChainIOK", func(t *testing.T) {
configIO := G.Of(ReaderTestConfig{Value: 10, Name: "test"})
pipeline := F.Pipe1(
Of[ReaderTestConfig](5),
ChainIOK[ReaderTestConfig](func(n int) G.IO[int] {
return G.Of(n * 2)
}),
)
result := ReadIO[int](configIO)(pipeline)()
assert.Equal(t, 10, result)
})
t.Run("comparison with Read - different input types", func(t *testing.T) {
rio := func(c ReaderTestConfig) G.IO[int] {
return G.Of(c.Value + 10)
}
config := ReaderTestConfig{Value: 5, Name: "test"}
// Using Read with a pure value
resultRead := Read[int](config)(rio)()
// Using ReadIO with an IO value
resultReadIO := ReadIO[int](G.Of(config))(rio)()
// Both should produce the same result
assert.Equal(t, 15, resultRead)
assert.Equal(t, 15, resultReadIO)
})
}
func TestTapWithLogging(t *testing.T) {
// Simulate logging scenario
logged := []int{}

View File

@@ -16,6 +16,82 @@
// Package readerioeither provides a functional programming abstraction that combines
// three powerful concepts: Reader, IO, and Either monads.
//
// # Type Parameter Ordering Convention
//
// This package follows a consistent convention for ordering type parameters in function signatures.
// The general rule is: R -> E -> T (context, error, type), where:
// - R: The Reader context/environment type
// - E: The Either error type
// - T: The value type(s) (A, B, etc.)
//
// However, when some type parameters can be automatically inferred by the Go compiler from
// function arguments, the convention is modified to minimize explicit type annotations:
//
// Rule: Undetectable types come first, followed by detectable types, while preserving
// the relative order within each group (R -> E -> T).
//
// Examples:
//
// 1. All types detectable from first argument:
// MonadMap[R, E, A, B](fa ReaderIOEither[R, E, A], f func(A) B)
// - R, E, A are detectable from fa
// - B is detectable from f
// - Order: R, E, A, B (standard order, all detectable)
//
// 2. Some types undetectable:
// FromReader[E, R, A](ma Reader[R, A]) ReaderIOEither[R, E, A]
// - R, A are detectable from ma
// - E is undetectable (not in any argument)
// - Order: E, R, A (E first as undetectable, then R, A in standard order)
//
// 3. Multiple undetectable types:
// Local[E, A, R1, R2](f func(R2) R1) func(ReaderIOEither[R1, E, A]) ReaderIOEither[R2, E, A]
// - E, A are undetectable
// - R1, R2 are detectable from f
//
// 4. Functions returning Kleisli arrows:
// ChainReaderOptionK[R, A, B, E](onNone func() E) func(readeroption.Kleisli[R, A, B]) Operator[R, E, A, B]
// - Canonical order would be R, E, A, B
// - E is detectable from onNone parameter
// - R, A, B are not detectable (they're in the Kleisli argument type)
// - Order: R, A, B, E (undetectable R, A, B first, then detectable E)
//
// This convention allows for more ergonomic function calls:
//
// // Without convention - need to specify all types:
// result := FromReader[context.Context, error, User](readerFunc)
//
// // With convention - only specify undetectable type:
// result := FromReader[error](readerFunc) // R and A inferred from readerFunc
//
// The reasoning behind this approach is to reduce the number of explicit type parameters
// that developers need to specify when calling functions, improving code readability and
// reducing verbosity while maintaining type safety.
//
// Additional examples demonstrating the convention:
//
// 5. FromReaderOption[R, A, E](onNone func() E) Kleisli[R, E, ReaderOption[R, A], A]
// - Canonical order would be R, E, A
// - E is detectable from onNone parameter
// - R, A are not detectable (they're in the return type's Kleisli)
// - Order: R, A, E (undetectable R, A first, then detectable E)
//
// 6. MapLeft[R, A, E1, E2](f func(E1) E2) func(ReaderIOEither[R, E1, A]) ReaderIOEither[R, E2, A]
// - Canonical order would be R, E1, E2, A
// - E1, E2 are detectable from f parameter
// - R, A are not detectable (they're in the return type)
// - Order: R, A, E1, E2 (undetectable R, A first, then detectable E1, E2)
//
// Additional special cases:
//
// - Ap[B, R, E, A]: B is undetectable (in function return type), so B comes first
// - OrLeft[A, E1, R, E2]: A is undetectable, comes first before detectable E1, R, E2
// - ReadIO[E, A, R]: E and A are undetectable, R is detectable from IO[R]
// - ChainFirstLeft[A, R, EA, EB, B]: A is undetectable, comes first before detectable R, EA, EB, B
// - TapLeft[A, R, EB, EA, B]: Similar to ChainFirstLeft, A is undetectable and comes first
//
// All functions in this package follow this convention consistently.
//
// # Fantasy Land Specification
//
// This is a monad transformer combining:

View File

@@ -38,7 +38,7 @@ import (
)
//go:inline
func FromReaderOption[R, A, E any](onNone func() E) Kleisli[R, E, ReaderOption[R, A], A] {
func FromReaderOption[R, A, E any](onNone Lazy[E]) Kleisli[R, E, ReaderOption[R, A], A] {
return function.Bind2nd(function.Flow2[ReaderOption[R, A], IOE.Kleisli[E, Option[A], A]], IOE.FromOption[A](onNone))
}
@@ -348,7 +348,7 @@ func TapReaderEitherK[E, R, A, B any](f RE.Kleisli[R, E, A, B]) Operator[R, E, A
}
//go:inline
func ChainReaderOptionK[R, A, B, E any](onNone func() E) func(readeroption.Kleisli[R, A, B]) Operator[R, E, A, B] {
func ChainReaderOptionK[R, A, B, E any](onNone Lazy[E]) func(readeroption.Kleisli[R, A, B]) Operator[R, E, A, B] {
fro := FromReaderOption[R, B](onNone)
return func(f readeroption.Kleisli[R, A, B]) Operator[R, E, A, B] {
return fromreader.ChainReaderK(
@@ -360,7 +360,7 @@ func ChainReaderOptionK[R, A, B, E any](onNone func() E) func(readeroption.Kleis
}
//go:inline
func ChainFirstReaderOptionK[R, A, B, E any](onNone func() E) func(readeroption.Kleisli[R, A, B]) Operator[R, E, A, A] {
func ChainFirstReaderOptionK[R, A, B, E any](onNone Lazy[E]) func(readeroption.Kleisli[R, A, B]) Operator[R, E, A, A] {
fro := FromReaderOption[R, B](onNone)
return func(f readeroption.Kleisli[R, A, B]) Operator[R, E, A, A] {
return fromreader.ChainFirstReaderK(
@@ -372,7 +372,7 @@ func ChainFirstReaderOptionK[R, A, B, E any](onNone func() E) func(readeroption.
}
//go:inline
func TapReaderOptionK[R, A, B, E any](onNone func() E) func(readeroption.Kleisli[R, A, B]) Operator[R, E, A, A] {
func TapReaderOptionK[R, A, B, E any](onNone Lazy[E]) func(readeroption.Kleisli[R, A, B]) Operator[R, E, A, A] {
return ChainFirstReaderOptionK[R, A, B](onNone)
}
@@ -467,7 +467,7 @@ func TapIOK[R, E, A, B any](f io.Kleisli[A, B]) Operator[R, E, A, A] {
// If the Option is None, the provided error function is called to produce the error value.
//
//go:inline
func ChainOptionK[R, A, B, E any](onNone func() E) func(func(A) Option[B]) Operator[R, E, A, B] {
func ChainOptionK[R, A, B, E any](onNone Lazy[E]) func(func(A) Option[B]) Operator[R, E, A, B] {
return fromeither.ChainOptionK(
MonadChain[R, E, A, B],
FromEither[R, E, B],
@@ -651,7 +651,7 @@ func Asks[E, R, A any](r Reader[R, A]) ReaderIOEither[R, E, A] {
// If the Option is None, the provided function is called to produce the error.
//
//go:inline
func FromOption[R, A, E any](onNone func() E) func(Option[A]) ReaderIOEither[R, E, A] {
func FromOption[R, A, E any](onNone Lazy[E]) func(Option[A]) ReaderIOEither[R, E, A] {
return fromeither.FromOption(FromEither[R, E, A], onNone)
}
@@ -821,6 +821,108 @@ func Read[E, A, R any](r R) func(ReaderIOEither[R, E, A]) IOEither[E, A] {
return reader.Read[IOEither[E, A]](r)
}
// ReadIOEither executes a ReaderIOEither computation by providing it with an environment
// obtained from an IOEither computation. This is useful when the environment itself needs
// to be computed with side effects and error handling.
//
// The function first executes the IOEither[E, R] to get the environment R (or fail with error E),
// then uses that environment to run the ReaderIOEither[R, E, A] computation.
//
// Type parameters:
// - A: The success value type of the ReaderIOEither computation
// - R: The environment/context type required by the ReaderIOEither
// - E: The error type
//
// Parameters:
// - r: An IOEither[E, R] that produces the environment (or an error)
//
// Returns:
// - A function that takes a ReaderIOEither[R, E, A] and returns IOEither[E, A]
//
// Behavior:
// - If the IOEither[E, R] fails (Left), the error is propagated without running the ReaderIOEither
// - If the IOEither[E, R] succeeds (Right), the resulting environment is used to execute the ReaderIOEither
//
// Example:
//
// // Load configuration from a file (may fail)
// loadConfig := func() IOEither[error, Config] {
// return Lazy[E]ither[error, Config] {
// // Read config file with error handling
// return either.Right(Config{BaseURL: "https://api.example.com"})
// }
// }
//
// // A computation that needs the config
// fetchUser := func(id int) ReaderIOEither[Config, error, User] {
// return func(cfg Config) IOEither[error, User] {
// // Use cfg.BaseURL to fetch user
// return ioeither.Right[error](User{ID: id})
// }
// }
//
// // Execute the computation with dynamically loaded config
// result := ReadIOEither[User](loadConfig())(fetchUser(123))()
//
//go:inline
func ReadIOEither[A, R, E any](r IOEither[E, R]) func(ReaderIOEither[R, E, A]) IOEither[E, A] {
return function.Flow2(
IOE.Chain[E, R, A],
Read[E, A](r),
)
}
// ReadIO executes a ReaderIOEither computation by providing it with an environment
// obtained from an IO computation. This is useful when the environment needs to be
// computed with side effects but cannot fail.
//
// The function first executes the IO[R] to get the environment R,
// then uses that environment to run the ReaderIOEither[R, E, A] computation.
//
// Type parameters:
// - E: The error type of the ReaderIOEither computation
// - A: The success value type of the ReaderIOEither computation
// - R: The environment/context type required by the ReaderIOEither
//
// Parameters:
// - r: An IO[R] that produces the environment
//
// Returns:
// - A function that takes a ReaderIOEither[R, E, A] and returns IOEither[E, A]
//
// Behavior:
// - The IO[R] is always executed successfully to obtain the environment
// - The resulting environment is then used to execute the ReaderIOEither
// - Only the ReaderIOEither computation can fail with error type E
//
// Example:
//
// // Get current timestamp (cannot fail)
// getCurrentTime := func() IO[time.Time] {
// return func() time.Time {
// return time.Now()
// }
// }
//
// // A computation that needs the timestamp
// logWithTimestamp := func(msg string) ReaderIOEither[time.Time, error, string] {
// return func(t time.Time) IOEither[error, string] {
// logged := fmt.Sprintf("[%s] %s", t.Format(time.RFC3339), msg)
// return ioeither.Right[error](logged)
// }
// }
//
// // Execute the computation with current time
// result := ReadIO[error, string](getCurrentTime())(logWithTimestamp("Hello"))()
//
//go:inline
func ReadIO[E, A, R any](r IO[R]) func(ReaderIOEither[R, E, A]) IOEither[E, A] {
return function.Flow2(
io.Chain[R, Either[E, A]],
Read[E, A](r),
)
}
// MonadChainLeft chains a computation on the left (error) side of a ReaderIOEither.
// 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.
@@ -957,7 +1059,7 @@ func MonadTapLeft[A, R, EA, EB, B any](ma ReaderIOEither[R, EA, A], f Kleisli[R,
// - An Operator that performs the side effect but always returns the original error if input was Left
//
//go:inline
func ChainFirstLeft[A, R, EB, EA, B any](f Kleisli[R, EB, EA, B]) Operator[R, EA, A, A] {
func ChainFirstLeft[A, R, EA, EB, B any](f Kleisli[R, EB, EA, B]) Operator[R, EA, A, A] {
return eithert.ChainFirstLeft(
readerio.Chain[R, Either[EA, A], Either[EA, A]],
readerio.Map[R, Either[EB, B], Either[EA, A]],
@@ -974,7 +1076,7 @@ func ChainFirstLeftIOK[A, R, EA, B any](f io.Kleisli[EA, B]) Operator[R, EA, A,
}
//go:inline
func TapLeft[A, R, EB, EA, B any](f Kleisli[R, EB, EA, B]) Operator[R, EA, A, A] {
func TapLeft[A, R, EA, EB, B any](f Kleisli[R, EB, EA, B]) Operator[R, EA, A, A] {
return ChainFirstLeft[A](f)
}

View File

@@ -308,3 +308,226 @@ func TestTapLeft(t *testing.T) {
assert.Equal(t, E.Left[int]("error"), result)
assert.True(t, sideEffectRan)
}
func TestReadIOEither(t *testing.T) {
type Config struct {
baseURL string
timeout int
}
// Test with Right IOEither - should execute ReaderIOEither with the environment
t.Run("Right IOEither provides environment", func(t *testing.T) {
// IOEither that successfully produces a config
configIO := IOE.Right[error](Config{baseURL: "https://api.example.com", timeout: 30})
// ReaderIOEither that uses the config
computation := func(cfg Config) IOEither[error, string] {
return IOE.Right[error](cfg.baseURL + "/users")
}
// Execute using ReadIOEither
result := ReadIOEither[string](configIO)(computation)()
assert.Equal(t, E.Right[error]("https://api.example.com/users"), result)
})
// Test with Left IOEither - should propagate error without executing ReaderIOEither
t.Run("Left IOEither propagates error", func(t *testing.T) {
configError := errors.New("failed to load config")
configIO := IOE.Left[Config](configError)
executed := false
computation := func(cfg Config) IOEither[error, string] {
executed = true
return IOE.Right[error]("should not execute")
}
result := ReadIOEither[string](configIO)(computation)()
assert.Equal(t, E.Left[string](configError), result)
assert.False(t, executed, "ReaderIOEither should not execute when IOEither is Left")
})
// Test with Right IOEither but ReaderIOEither fails
t.Run("Right IOEither but ReaderIOEither fails", func(t *testing.T) {
configIO := IOE.Right[error](Config{baseURL: "https://api.example.com", timeout: 30})
computationError := errors.New("computation failed")
computation := func(cfg Config) IOEither[error, string] {
// Use the config but fail
if cfg.timeout < 60 {
return IOE.Left[string](computationError)
}
return IOE.Right[error]("success")
}
result := ReadIOEither[string](configIO)(computation)()
assert.Equal(t, E.Left[string](computationError), result)
})
// Test chaining with ReadIOEither
t.Run("Chaining with ReadIOEither", func(t *testing.T) {
// First get the config
configIO := IOE.Right[error](Config{baseURL: "https://api.example.com", timeout: 30})
// Chain multiple operations
result := F.Pipe2(
Of[Config, error](10),
Map[Config, error](func(x int) int { return x * 2 }),
ReadIOEither[int](configIO),
)()
assert.Equal(t, E.Right[error](20), result)
})
// Test with complex error type
t.Run("Complex error type", func(t *testing.T) {
type AppError struct {
Code int
Message string
}
configIO := IOE.Left[Config](AppError{Code: 500, Message: "Internal error"})
computation := func(cfg Config) IOEither[AppError, string] {
return IOE.Right[AppError]("success")
}
result := ReadIOEither[string](configIO)(computation)()
assert.Equal(t, E.Left[string](AppError{Code: 500, Message: "Internal error"}), result)
})
}
func TestReadIO(t *testing.T) {
type Config struct {
baseURL string
version string
}
// Test basic execution - IO provides environment
t.Run("IO provides environment successfully", func(t *testing.T) {
// IO that produces a config (cannot fail)
configIO := func() Config {
return Config{baseURL: "https://api.example.com", version: "v1"}
}
// ReaderIOEither that uses the config
computation := func(cfg Config) IOEither[error, string] {
return IOE.Right[error](cfg.baseURL + "/" + cfg.version)
}
result := ReadIO[error, string](configIO)(computation)()
assert.Equal(t, E.Right[error]("https://api.example.com/v1"), result)
})
// Test when ReaderIOEither fails
t.Run("ReaderIOEither fails after IO succeeds", func(t *testing.T) {
configIO := func() Config {
return Config{baseURL: "https://api.example.com", version: "v1"}
}
computationError := errors.New("validation failed")
computation := func(cfg Config) IOEither[error, string] {
// Validate config
if cfg.version != "v2" {
return IOE.Left[string](computationError)
}
return IOE.Right[error]("success")
}
result := ReadIO[error, string](configIO)(computation)()
assert.Equal(t, E.Left[string](computationError), result)
})
// Test with side effects in IO
t.Run("IO with side effects", func(t *testing.T) {
counter := 0
configIO := func() Config {
counter++
return Config{baseURL: fmt.Sprintf("https://api%d.example.com", counter), version: "v1"}
}
computation := func(cfg Config) IOEither[error, string] {
return IOE.Right[error](cfg.baseURL)
}
result := ReadIO[error, string](configIO)(computation)()
assert.Equal(t, E.Right[error]("https://api1.example.com"), result)
assert.Equal(t, 1, counter, "IO should execute exactly once")
})
// Test chaining with ReadIO
t.Run("Chaining with ReadIO", func(t *testing.T) {
configIO := func() Config {
return Config{baseURL: "https://api.example.com", version: "v1"}
}
result := F.Pipe2(
Of[Config, error](42),
Map[Config, error](func(x int) string { return fmt.Sprintf("value-%d", x) }),
ReadIO[error, string](configIO),
)()
assert.Equal(t, E.Right[error]("value-42"), result)
})
// Test with different error types
t.Run("Different error types", func(t *testing.T) {
configIO := func() int {
return 100
}
computation := func(cfg int) IOEither[string, int] {
if cfg < 200 {
return IOE.Left[int]("value too low")
}
return IOE.Right[string](cfg)
}
result := ReadIO[string, int](configIO)(computation)()
assert.Equal(t, E.Left[int]("value too low"), result)
})
// Test ReadIO vs ReadIOEither - ReadIO cannot fail during environment loading
t.Run("ReadIO always provides environment", func(t *testing.T) {
// This demonstrates that ReadIO's IO always succeeds
configIO := func() Config {
// Even if we wanted to fail here, we can't - IO cannot fail
return Config{baseURL: "fallback", version: "v0"}
}
executed := false
computation := func(cfg Config) IOEither[error, string] {
executed = true
return IOE.Right[error](cfg.baseURL)
}
result := ReadIO[error, string](configIO)(computation)()
assert.Equal(t, E.Right[error]("fallback"), result)
assert.True(t, executed, "ReaderIOEither should always execute with ReadIO")
})
// Test with complex computation
t.Run("Complex computation with environment", func(t *testing.T) {
type Env struct {
multiplier int
offset int
}
envIO := func() Env {
return Env{multiplier: 3, offset: 10}
}
computation := func(env Env) IOEither[error, int] {
return func() Either[error, int] {
// Simulate some computation using the environment
result := env.multiplier*5 + env.offset
if result > 20 {
return E.Right[error](result)
}
return E.Left[int](errors.New("result too small"))
}
}
result := ReadIO[error, int](envIO)(computation)()
assert.Equal(t, E.Right[error](25), result)
})
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/optics/lens/option"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
@@ -109,4 +110,6 @@ type (
// Predicate represents a function that tests a value of type A and returns a boolean.
Predicate[A any] = predicate.Predicate[A]
Lazy[A any] = lazy.Lazy[A]
)

View File

@@ -824,3 +824,141 @@ func Delay[R, A any](delay time.Duration) Operator[R, A, A] {
func After[R, A any](timestamp time.Time) Operator[R, A, A] {
return function.Bind2nd(function.Flow2[ReaderIOResult[R, A]], io.After[Result[A]](timestamp))
}
// ReadIOEither executes a ReaderIOResult computation by providing an environment
// obtained from an IOResult. This function bridges the gap between IOResult-based
// environment acquisition and ReaderIOResult-based computations.
//
// The function first executes the IOResult[R] to obtain the environment (or an error),
// then uses that environment to run the ReaderIOResult[R, A] computation.
//
// Type parameters:
// - A: The success value type of the ReaderIOResult computation
// - R: The environment/context type required by the ReaderIOResult
//
// Parameters:
// - r: An IOResult[R] that produces the environment (or an error)
//
// Returns:
// - A function that takes a ReaderIOResult[R, A] and returns IOResult[A]
//
// Example:
//
// type Config struct { BaseURL string }
//
// // Get config from environment with potential error
// getConfig := func() IOResult[Config] {
// return func() Result[Config] {
// // Load config, may fail
// return result.Of(Config{BaseURL: "https://api.example.com"})
// }
// }
//
// // A computation that needs config
// fetchUser := func(id int) ReaderIOResult[Config, User] {
// return func(cfg Config) IOResult[User] {
// return func() Result[User] {
// // Use cfg.BaseURL to fetch user
// return result.Of(User{ID: id})
// }
// }
// }
//
// // Execute the computation with the config
// result := ReadIOEither[User](getConfig())(fetchUser(123))()
//
//go:inline
func ReadIOEither[A, R any](r IOResult[R]) func(ReaderIOResult[R, A]) IOResult[A] {
return RIOE.ReadIOEither[A](r)
}
// ReadIOResult executes a ReaderIOResult computation by providing an environment
// obtained from an IOResult. This is an alias for ReadIOEither with more explicit naming.
//
// The function first executes the IOResult[R] to obtain the environment (or an error),
// then uses that environment to run the ReaderIOResult[R, A] computation.
//
// Type parameters:
// - A: The success value type of the ReaderIOResult computation
// - R: The environment/context type required by the ReaderIOResult
//
// Parameters:
// - r: An IOResult[R] that produces the environment (or an error)
//
// Returns:
// - A function that takes a ReaderIOResult[R, A] and returns IOResult[A]
//
// Example:
//
// type Database struct { ConnectionString string }
//
// // Get database connection with potential error
// getDB := func() IOResult[Database] {
// return func() Result[Database] {
// return result.Of(Database{ConnectionString: "localhost:5432"})
// }
// }
//
// // Query that needs database
// queryUsers := ReaderIOResult[Database, []User] {
// return func(db Database) IOResult[[]User] {
// return func() Result[[]User] {
// // Execute query using db
// return result.Of([]User{})
// }
// }
// }
//
// // Execute query with database
// users := ReadIOResult[[]User](getDB())(queryUsers)()
//
//go:inline
func ReadIOResult[A, R any](r IOResult[R]) func(ReaderIOResult[R, A]) IOResult[A] {
return RIOE.ReadIOEither[A](r)
}
// ReadIO executes a ReaderIOResult computation by providing an environment
// obtained from an IO computation. Unlike ReadIOEither/ReadIOResult, the environment
// acquisition cannot fail (it's a pure IO, not IOResult).
//
// The function first executes the IO[R] to obtain the environment,
// then uses that environment to run the ReaderIOResult[R, A] computation.
//
// Type parameters:
// - A: The success value type of the ReaderIOResult computation
// - R: The environment/context type required by the ReaderIOResult
//
// Parameters:
// - r: An IO[R] that produces the environment (cannot fail)
//
// Returns:
// - A function that takes a ReaderIOResult[R, A] and returns IOResult[A]
//
// Example:
//
// type Logger struct { Level string }
//
// // Get logger (always succeeds)
// getLogger := func() IO[Logger] {
// return func() Logger {
// return Logger{Level: "INFO"}
// }
// }
//
// // Log operation that may fail
// logMessage := func(msg string) ReaderIOResult[Logger, string] {
// return func(logger Logger) IOResult[string] {
// return func() Result[string] {
// // Log with logger, may fail
// return result.Of(fmt.Sprintf("[%s] %s", logger.Level, msg))
// }
// }
// }
//
// // Execute logging with logger
// logged := ReadIO[string](getLogger())(logMessage("Hello"))()
//
//go:inline
func ReadIO[A, R any](r IO[R]) func(ReaderIOResult[R, A]) IOResult[A] {
return RIOE.ReadIO[error, A](r)
}

View File

@@ -77,3 +77,249 @@ func TestTapReaderIOK(t *testing.T) {
x(10)()
}
func TestReadIOEither(t *testing.T) {
type Config struct {
BaseURL string
}
t.Run("success case - environment and computation both succeed", func(t *testing.T) {
// Create an IOResult that successfully produces a config
getConfig := func() IOResult[Config] {
return func() Result[Config] {
return result.Of(Config{BaseURL: "https://api.example.com"})
}
}
// Create a ReaderIOResult that uses the config
computation := func(cfg Config) IOResult[string] {
return func() Result[string] {
return result.Of(cfg.BaseURL + "/users")
}
}
// Execute using ReadIOEither
ioResult := ReadIOEither[string](getConfig())(computation)
res := ioResult()
assert.True(t, result.IsRight(res))
assert.Equal(t, "https://api.example.com/users", result.GetOrElse(func(error) string { return "" })(res))
})
t.Run("failure case - environment acquisition fails", func(t *testing.T) {
expectedErr := fmt.Errorf("config load failed")
// Create an IOResult that fails to produce a config
getConfig := func() IOResult[Config] {
return func() Result[Config] {
return result.Left[Config](expectedErr)
}
}
// Create a ReaderIOResult (won't be executed)
computation := func(cfg Config) IOResult[string] {
return func() Result[string] {
return result.Of("should not be called")
}
}
// Execute using ReadIOEither
ioResult := ReadIOEither[string](getConfig())(computation)
res := ioResult()
assert.True(t, result.IsLeft(res))
leftVal := result.Fold(F.Identity[error], func(string) error { return nil })(res)
assert.Equal(t, expectedErr, leftVal)
})
t.Run("failure case - computation fails", func(t *testing.T) {
expectedErr := fmt.Errorf("computation failed")
// Create an IOResult that successfully produces a config
getConfig := func() IOResult[Config] {
return func() Result[Config] {
return result.Of(Config{BaseURL: "https://api.example.com"})
}
}
// Create a ReaderIOResult that fails
computation := func(cfg Config) IOResult[string] {
return func() Result[string] {
return result.Left[string](expectedErr)
}
}
// Execute using ReadIOEither
ioResult := ReadIOEither[string](getConfig())(computation)
res := ioResult()
assert.True(t, result.IsLeft(res))
leftVal := result.Fold(F.Identity[error], func(string) error { return nil })(res)
assert.Equal(t, expectedErr, leftVal)
})
}
func TestReadIOResult(t *testing.T) {
type Database struct {
ConnectionString string
}
t.Run("success case - database and query both succeed", func(t *testing.T) {
// Create an IOResult that successfully produces a database
getDB := func() IOResult[Database] {
return func() Result[Database] {
return result.Of(Database{ConnectionString: "localhost:5432"})
}
}
// Create a ReaderIOResult that uses the database
queryUsers := func(db Database) IOResult[int] {
return func() Result[int] {
// Simulate query returning user count
return result.Of(42)
}
}
// Execute using ReadIOResult
ioResult := ReadIOResult[int](getDB())(queryUsers)
res := ioResult()
assert.True(t, result.IsRight(res))
assert.Equal(t, 42, result.GetOrElse(func(error) int { return 0 })(res))
})
t.Run("failure case - database connection fails", func(t *testing.T) {
expectedErr := fmt.Errorf("connection failed")
// Create an IOResult that fails to produce a database
getDB := func() IOResult[Database] {
return func() Result[Database] {
return result.Left[Database](expectedErr)
}
}
// Create a ReaderIOResult (won't be executed)
queryUsers := func(db Database) IOResult[int] {
return func() Result[int] {
return result.Of(0)
}
}
// Execute using ReadIOResult
ioResult := ReadIOResult[int](getDB())(queryUsers)
res := ioResult()
assert.True(t, result.IsLeft(res))
leftVal := result.Fold(F.Identity[error], func(int) error { return nil })(res)
assert.Equal(t, expectedErr, leftVal)
})
t.Run("failure case - query fails", func(t *testing.T) {
expectedErr := fmt.Errorf("query failed")
// Create an IOResult that successfully produces a database
getDB := func() IOResult[Database] {
return func() Result[Database] {
return result.Of(Database{ConnectionString: "localhost:5432"})
}
}
// Create a ReaderIOResult that fails
queryUsers := func(db Database) IOResult[int] {
return func() Result[int] {
return result.Left[int](expectedErr)
}
}
// Execute using ReadIOResult
ioResult := ReadIOResult[int](getDB())(queryUsers)
res := ioResult()
assert.True(t, result.IsLeft(res))
leftVal := result.Fold(F.Identity[error], func(int) error { return nil })(res)
assert.Equal(t, expectedErr, leftVal)
})
}
func TestReadIO(t *testing.T) {
type Logger struct {
Level string
}
t.Run("success case - logger and operation both succeed", func(t *testing.T) {
// Create an IO that produces a logger (always succeeds)
getLogger := func() IO[Logger] {
return func() Logger {
return Logger{Level: "INFO"}
}
}
// Create a ReaderIOResult that uses the logger
logMessage := func(logger Logger) IOResult[string] {
return func() Result[string] {
return result.Of(fmt.Sprintf("[%s] Message logged", logger.Level))
}
}
// Execute using ReadIO
ioResult := ReadIO[string](getLogger())(logMessage)
res := ioResult()
assert.True(t, result.IsRight(res))
assert.Equal(t, "[INFO] Message logged", result.GetOrElse(func(error) string { return "" })(res))
})
t.Run("failure case - operation fails", func(t *testing.T) {
expectedErr := fmt.Errorf("logging failed")
// Create an IO that produces a logger (always succeeds)
getLogger := func() IO[Logger] {
return func() Logger {
return Logger{Level: "ERROR"}
}
}
// Create a ReaderIOResult that fails
logMessage := func(logger Logger) IOResult[string] {
return func() Result[string] {
return result.Left[string](expectedErr)
}
}
// Execute using ReadIO
ioResult := ReadIO[string](getLogger())(logMessage)
res := ioResult()
assert.True(t, result.IsLeft(res))
leftVal := result.Fold(F.Identity[error], func(string) error { return nil })(res)
assert.Equal(t, expectedErr, leftVal)
})
t.Run("success case - complex computation with context", func(t *testing.T) {
type AppContext struct {
UserID int
Username string
}
// Create an IO that produces an app context
getContext := func() IO[AppContext] {
return func() AppContext {
return AppContext{UserID: 123, Username: "alice"}
}
}
// Create a ReaderIOResult that uses the context
processUser := func(ctx AppContext) IOResult[string] {
return func() Result[string] {
return result.Of(fmt.Sprintf("Processing user %s (ID: %d)", ctx.Username, ctx.UserID))
}
}
// Execute using ReadIO
ioResult := ReadIO[string](getContext())(processUser)
res := ioResult()
assert.True(t, result.IsRight(res))
assert.Equal(t, "Processing user alice (ID: 123)", result.GetOrElse(func(error) string { return "" })(res))
})
}

View File

@@ -337,6 +337,26 @@ func Read[A, E any](e E) func(ReaderOption[E, A]) Option[A] {
return reader.Read[Option[A]](e)
}
// ReadOption executes a ReaderOption with an optional environment.
// If the environment is None, the result is None.
// If the environment is Some(e), the ReaderOption is executed with e.
//
// This is useful when the environment itself might not be available.
//
// Example:
//
// ro := readeroption.Of[Config](42)
// result1 := readeroption.ReadOption[int](option.Some(myConfig))(ro) // Returns option.Some(42)
// result2 := readeroption.ReadOption[int](option.None[Config]())(ro) // Returns option.None[int]()
//
//go:inline
func ReadOption[A, E any](e Option[E]) func(ReaderOption[E, A]) Option[A] {
return function.Flow2(
O.Chain[E],
Read[A](e),
)
}
// MonadFlap applies a value to a function wrapped in a ReaderOption.
// This is the reverse of MonadAp.
//

View File

@@ -26,214 +26,534 @@ import (
"github.com/stretchr/testify/assert"
)
type MyContext string
const defaultContext MyContext = "default"
func TestMap(t *testing.T) {
g := F.Pipe1(
Of[MyContext](1),
Map[MyContext](utils.Double),
)
assert.Equal(t, O.Of(2), g(defaultContext))
// Test context type
type Config struct {
Host string
Port int
Timeout int
}
func TestAp(t *testing.T) {
g := F.Pipe1(
Of[MyContext](utils.Double),
Ap[int](Of[MyContext](1)),
)
assert.Equal(t, O.Of(2), g(defaultContext))
}
func TestFlatten(t *testing.T) {
g := F.Pipe1(
Of[MyContext](Of[MyContext]("a")),
Flatten[MyContext, string],
)
assert.Equal(t, O.Of("a"), g(defaultContext))
}
func TestFromOption(t *testing.T) {
// Test with Some
opt1 := O.Of(42)
ro1 := FromOption[MyContext](opt1)
assert.Equal(t, O.Of(42), ro1(defaultContext))
// Test with None
opt2 := O.None[int]()
ro2 := FromOption[MyContext](opt2)
assert.Equal(t, O.None[int](), ro2(defaultContext))
}
func TestSome(t *testing.T) {
ro := Some[MyContext](42)
assert.Equal(t, O.Of(42), ro(defaultContext))
}
func TestFromReader(t *testing.T) {
reader := func(ctx MyContext) int {
return 42
}
ro := FromReader(reader)
assert.Equal(t, O.Of(42), ro(defaultContext))
var defaultConfig = Config{
Host: "localhost",
Port: 8080,
Timeout: 30,
}
// TestOf tests the Of function which wraps a value in a ReaderOption
func TestOf(t *testing.T) {
ro := Of[MyContext](42)
assert.Equal(t, O.Of(42), ro(defaultContext))
ro := Of[Config](42)
result := ro(defaultConfig)
assert.Equal(t, O.Some(42), result)
}
// TestSome tests the Some function which is an alias for Of
func TestSome(t *testing.T) {
ro := Some[Config](42)
result := ro(defaultConfig)
assert.Equal(t, O.Some(42), result)
}
// TestNone tests the None function which creates a ReaderOption representing no value
func TestNone(t *testing.T) {
ro := None[MyContext, int]()
assert.Equal(t, O.None[int](), ro(defaultContext))
ro := None[Config, int]()
result := ro(defaultConfig)
assert.Equal(t, O.None[int](), result)
}
func TestChain(t *testing.T) {
double := func(x int) ReaderOption[MyContext, int] {
return Of[MyContext](x * 2)
}
g := F.Pipe1(
Of[MyContext](21),
Chain(double),
)
assert.Equal(t, O.Of(42), g(defaultContext))
// Test with None
g2 := F.Pipe1(
None[MyContext, int](),
Chain(double),
)
assert.Equal(t, O.None[int](), g2(defaultContext))
}
func TestFromPredicate(t *testing.T) {
isPositive := FromPredicate[MyContext](func(x int) bool {
return x > 0
// TestFromOption tests lifting an Option into a ReaderOption
func TestFromOption(t *testing.T) {
t.Run("Some value", func(t *testing.T) {
opt := O.Some(42)
ro := FromOption[Config](opt)
result := ro(defaultConfig)
assert.Equal(t, O.Some(42), result)
})
// Test with positive number
g1 := F.Pipe1(
Of[MyContext](42),
Chain(isPositive),
)
assert.Equal(t, O.Of(42), g1(defaultContext))
// Test with negative number
g2 := F.Pipe1(
Of[MyContext](-5),
Chain(isPositive),
)
assert.Equal(t, O.None[int](), g2(defaultContext))
t.Run("None value", func(t *testing.T) {
opt := O.None[int]()
ro := FromOption[Config](opt)
result := ro(defaultConfig)
assert.Equal(t, O.None[int](), result)
})
}
// TestFromReader tests lifting a Reader into a ReaderOption
func TestFromReader(t *testing.T) {
r := reader.Of[Config](42)
ro := FromReader(r)
result := ro(defaultConfig)
assert.Equal(t, O.Some(42), result)
}
// TestSomeReader tests lifting a Reader into a ReaderOption (alias for FromReader)
func TestSomeReader(t *testing.T) {
r := reader.Of[Config](42)
ro := SomeReader(r)
result := ro(defaultConfig)
assert.Equal(t, O.Some(42), result)
}
// TestMonadMap tests applying a function to the value inside a ReaderOption
func TestMonadMap(t *testing.T) {
t.Run("Map over Some", func(t *testing.T) {
ro := Of[Config](21)
mapped := MonadMap(ro, utils.Double)
result := mapped(defaultConfig)
assert.Equal(t, O.Some(42), result)
})
t.Run("Map over None", func(t *testing.T) {
ro := None[Config, int]()
mapped := MonadMap(ro, utils.Double)
result := mapped(defaultConfig)
assert.Equal(t, O.None[int](), result)
})
}
// TestMap tests the curried version of MonadMap
func TestMap(t *testing.T) {
t.Run("Map over Some", func(t *testing.T) {
result := F.Pipe1(
Of[Config](21),
Map[Config](utils.Double),
)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("Map over None", func(t *testing.T) {
result := F.Pipe1(
None[Config, int](),
Map[Config](utils.Double),
)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
// TestMonadChain tests sequencing two ReaderOption computations
func TestMonadChain(t *testing.T) {
t.Run("Chain with Some", func(t *testing.T) {
ro := Of[Config](21)
chained := MonadChain(ro, func(x int) ReaderOption[Config, int] {
return Of[Config](x * 2)
})
result := chained(defaultConfig)
assert.Equal(t, O.Some(42), result)
})
t.Run("Chain with None", func(t *testing.T) {
ro := None[Config, int]()
chained := MonadChain(ro, func(x int) ReaderOption[Config, int] {
return Of[Config](x * 2)
})
result := chained(defaultConfig)
assert.Equal(t, O.None[int](), result)
})
t.Run("Chain returning None", func(t *testing.T) {
ro := Of[Config](21)
chained := MonadChain(ro, func(x int) ReaderOption[Config, int] {
return None[Config, int]()
})
result := chained(defaultConfig)
assert.Equal(t, O.None[int](), result)
})
}
// TestChain tests the curried version of MonadChain
func TestChain(t *testing.T) {
t.Run("Chain with Some", func(t *testing.T) {
result := F.Pipe1(
Of[Config](21),
Chain(func(x int) ReaderOption[Config, int] {
return Of[Config](x * 2)
}),
)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("Chain with None", func(t *testing.T) {
result := F.Pipe1(
None[Config, int](),
Chain(func(x int) ReaderOption[Config, int] {
return Of[Config](x * 2)
}),
)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
// TestMonadAp tests applying a function wrapped in a ReaderOption
func TestMonadAp(t *testing.T) {
t.Run("Ap with both Some", func(t *testing.T) {
fab := Of[Config](utils.Double)
fa := Of[Config](21)
result := MonadAp(fab, fa)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("Ap with None function", func(t *testing.T) {
fab := None[Config, func(int) int]()
fa := Of[Config](21)
result := MonadAp(fab, fa)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
t.Run("Ap with None value", func(t *testing.T) {
fab := Of[Config](utils.Double)
fa := None[Config, int]()
result := MonadAp(fab, fa)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
// TestAp tests the curried version of MonadAp
func TestAp(t *testing.T) {
t.Run("Ap with both Some", func(t *testing.T) {
result := F.Pipe1(
Of[Config](utils.Double),
Ap[int](Of[Config](21)),
)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
}
// TestFromPredicate tests creating a Kleisli arrow that filters based on a predicate
func TestFromPredicate(t *testing.T) {
isPositive := FromPredicate[Config](func(x int) bool { return x > 0 })
t.Run("Predicate satisfied", func(t *testing.T) {
result := F.Pipe1(
Of[Config](42),
Chain(isPositive),
)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("Predicate not satisfied", func(t *testing.T) {
result := F.Pipe1(
Of[Config](-5),
Chain(isPositive),
)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
// TestFold tests extracting the value from a ReaderOption with handlers
func TestFold(t *testing.T) {
onNone := reader.Of[MyContext]("none")
onSome := func(x int) Reader[MyContext, string] {
return reader.Of[MyContext](fmt.Sprintf("%d", x))
}
t.Run("Fold with Some", func(t *testing.T) {
ro := Of[Config](42)
result := Fold(
reader.Of[Config]("none"),
func(x int) reader.Reader[Config, string] {
return reader.Of[Config](fmt.Sprintf("%d", x))
},
)(ro)
assert.Equal(t, "42", result(defaultConfig))
})
// Test with Some
g1 := Fold(onNone, onSome)(Of[MyContext](42))
assert.Equal(t, "42", g1(defaultContext))
// Test with None
g2 := Fold(onNone, onSome)(None[MyContext, int]())
assert.Equal(t, "none", g2(defaultContext))
t.Run("Fold with None", func(t *testing.T) {
ro := None[Config, int]()
result := Fold(
reader.Of[Config]("none"),
func(x int) reader.Reader[Config, string] {
return reader.Of[Config](fmt.Sprintf("%d", x))
},
)(ro)
assert.Equal(t, "none", result(defaultConfig))
})
}
// TestMonadFold tests the non-curried version of Fold
func TestMonadFold(t *testing.T) {
t.Run("MonadFold with Some", func(t *testing.T) {
ro := Of[Config](42)
result := MonadFold(
ro,
reader.Of[Config]("none"),
func(x int) reader.Reader[Config, string] {
return reader.Of[Config](fmt.Sprintf("%d", x))
},
)
assert.Equal(t, "42", result(defaultConfig))
})
t.Run("MonadFold with None", func(t *testing.T) {
ro := None[Config, int]()
result := MonadFold(
ro,
reader.Of[Config]("none"),
func(x int) reader.Reader[Config, string] {
return reader.Of[Config](fmt.Sprintf("%d", x))
},
)
assert.Equal(t, "none", result(defaultConfig))
})
}
// TestGetOrElse tests getting the value or a default
func TestGetOrElse(t *testing.T) {
defaultValue := reader.Of[MyContext](0)
t.Run("GetOrElse with Some", func(t *testing.T) {
ro := Of[Config](42)
result := GetOrElse(reader.Of[Config](0))(ro)
assert.Equal(t, 42, result(defaultConfig))
})
// Test with Some
g1 := GetOrElse(defaultValue)(Of[MyContext](42))
assert.Equal(t, 42, g1(defaultContext))
// Test with None
g2 := GetOrElse(defaultValue)(None[MyContext, int]())
assert.Equal(t, 0, g2(defaultContext))
t.Run("GetOrElse with None", func(t *testing.T) {
ro := None[Config, int]()
result := GetOrElse(reader.Of[Config](99))(ro)
assert.Equal(t, 99, result(defaultConfig))
})
}
// TestAsk tests retrieving the current environment
func TestAsk(t *testing.T) {
ro := Ask[MyContext]()
result := ro(defaultContext)
assert.Equal(t, O.Of(defaultContext), result)
ro := Ask[Config]()
result := ro(defaultConfig)
assert.Equal(t, O.Some(defaultConfig), result)
}
// TestAsks tests applying a function to the environment
func TestAsks(t *testing.T) {
reader := func(ctx MyContext) string {
return string(ctx)
}
ro := Asks(reader)
result := ro(defaultContext)
assert.Equal(t, O.Of("default"), result)
getPort := Asks(func(cfg Config) int {
return cfg.Port
})
result := getPort(defaultConfig)
assert.Equal(t, O.Some(8080), result)
}
func TestChainOptionK(t *testing.T) {
// TestMonadChainOptionK tests chaining with a function that returns an Option
func TestMonadChainOptionK(t *testing.T) {
parsePositive := func(x int) O.Option[int] {
if x > 0 {
return O.Of(x)
return O.Some(x)
}
return O.None[int]()
}
// Test with positive number
g1 := F.Pipe1(
Of[MyContext](42),
ChainOptionK[MyContext](parsePositive),
)
assert.Equal(t, O.Of(42), g1(defaultContext))
// Test with negative number
g2 := F.Pipe1(
Of[MyContext](-5),
ChainOptionK[MyContext](parsePositive),
)
assert.Equal(t, O.None[int](), g2(defaultContext))
}
func TestLocal(t *testing.T) {
type GlobalContext struct {
Value string
}
// A computation that needs a string context
ro := Asks(func(s string) string {
return "Hello, " + s
t.Run("ChainOptionK with valid value", func(t *testing.T) {
ro := Of[Config](42)
result := MonadChainOptionK(ro, parsePositive)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
// Transform GlobalContext to string
transformed := Local[string](func(g GlobalContext) string {
return g.Value
})(ro)
t.Run("ChainOptionK with invalid value", func(t *testing.T) {
ro := Of[Config](-5)
result := MonadChainOptionK(ro, parsePositive)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
result := transformed(GlobalContext{Value: "World"})
assert.Equal(t, O.Of("Hello, World"), result)
t.Run("ChainOptionK with None", func(t *testing.T) {
ro := None[Config, int]()
result := MonadChainOptionK(ro, parsePositive)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
func TestRead(t *testing.T) {
ro := Of[MyContext](42)
result := Read[int](defaultContext)(ro)
assert.Equal(t, O.Of(42), result)
}
func TestFlap(t *testing.T) {
addFunc := func(x int) int {
return x + 10
// TestChainOptionK tests the curried version of MonadChainOptionK
func TestChainOptionK(t *testing.T) {
parsePositive := func(x int) O.Option[int] {
if x > 0 {
return O.Some(x)
}
return O.None[int]()
}
g := F.Pipe1(
Of[MyContext](addFunc),
Flap[MyContext, int](32),
t.Run("ChainOptionK with valid value", func(t *testing.T) {
result := F.Pipe1(
Of[Config](42),
ChainOptionK[Config](parsePositive),
)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("ChainOptionK with invalid value", func(t *testing.T) {
result := F.Pipe1(
Of[Config](-5),
ChainOptionK[Config](parsePositive),
)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
// TestFlatten tests removing one level of nesting
func TestFlatten(t *testing.T) {
t.Run("Flatten nested Some", func(t *testing.T) {
nested := Of[Config](Of[Config](42))
flattened := Flatten(nested)
result := flattened(defaultConfig)
assert.Equal(t, O.Some(42), result)
})
t.Run("Flatten outer None", func(t *testing.T) {
nested := None[Config, ReaderOption[Config, int]]()
flattened := Flatten(nested)
result := flattened(defaultConfig)
assert.Equal(t, O.None[int](), result)
})
t.Run("Flatten inner None", func(t *testing.T) {
nested := Of[Config](None[Config, int]())
flattened := Flatten(nested)
result := flattened(defaultConfig)
assert.Equal(t, O.None[int](), result)
})
}
// TestLocal tests transforming the environment before passing it to a computation
func TestLocal(t *testing.T) {
type GlobalConfig struct {
DB Config
}
getPort := Asks(func(cfg Config) int {
return cfg.Port
})
globalConfig := GlobalConfig{
DB: defaultConfig,
}
result := Local[int](func(g GlobalConfig) Config {
return g.DB
})(getPort)
assert.Equal(t, O.Some(8080), result(globalConfig))
}
// TestRead tests executing a ReaderOption with an environment
func TestRead(t *testing.T) {
ro := Of[Config](42)
result := Read[int](defaultConfig)(ro)
assert.Equal(t, O.Some(42), result)
}
// TestReadOption tests executing a ReaderOption with an optional environment
func TestReadOption(t *testing.T) {
ro := Of[Config](42)
t.Run("ReadOption with Some environment", func(t *testing.T) {
result := ReadOption[int](O.Some(defaultConfig))(ro)
assert.Equal(t, O.Some(42), result)
})
t.Run("ReadOption with None environment", func(t *testing.T) {
result := ReadOption[int](O.None[Config]())(ro)
assert.Equal(t, O.None[int](), result)
})
}
// TestMonadFlap tests applying a value to a function wrapped in a ReaderOption
func TestMonadFlap(t *testing.T) {
t.Run("Flap with Some function", func(t *testing.T) {
fab := Of[Config](utils.Double)
result := MonadFlap(fab, 21)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("Flap with None function", func(t *testing.T) {
fab := None[Config, func(int) int]()
result := MonadFlap(fab, 21)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
// TestFlap tests the curried version of MonadFlap
func TestFlap(t *testing.T) {
t.Run("Flap with Some function", func(t *testing.T) {
result := F.Pipe1(
Of[Config](utils.Double),
Flap[Config, int](21),
)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
}
// TestMonadAlt tests providing an alternative ReaderOption
func TestMonadAlt(t *testing.T) {
t.Run("Alt with first Some", func(t *testing.T) {
primary := Of[Config](42)
fallback := Of[Config](99)
result := MonadAlt(primary, fallback)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("Alt with first None", func(t *testing.T) {
primary := None[Config, int]()
fallback := Of[Config](99)
result := MonadAlt(primary, fallback)
assert.Equal(t, O.Some(99), result(defaultConfig))
})
t.Run("Alt with both None", func(t *testing.T) {
primary := None[Config, int]()
fallback := None[Config, int]()
result := MonadAlt(primary, fallback)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}
// TestAlt tests the curried version of MonadAlt
func TestAlt(t *testing.T) {
t.Run("Alt with first Some", func(t *testing.T) {
result := F.Pipe1(
Of[Config](42),
Alt(Of[Config](99)),
)
assert.Equal(t, O.Some(42), result(defaultConfig))
})
t.Run("Alt with first None", func(t *testing.T) {
result := F.Pipe1(
None[Config, int](),
Alt(Of[Config](99)),
)
assert.Equal(t, O.Some(99), result(defaultConfig))
})
}
// TestComplexChaining tests a complex chain of operations
func TestComplexChaining(t *testing.T) {
// Simulate a complex workflow with environment access
result := F.Pipe3(
Ask[Config](),
Map[Config](func(cfg Config) int { return cfg.Port }),
Chain(func(port int) ReaderOption[Config, int] {
if port > 0 {
return Of[Config](port * 2)
}
return None[Config, int]()
}),
Map[Config](func(x int) string { return fmt.Sprintf("%d", x) }),
)
assert.Equal(t, O.Of(42), g(defaultContext))
assert.Equal(t, O.Some("16160"), result(defaultConfig))
}
// TestEnvironmentDependentComputation tests computations that depend on environment
func TestEnvironmentDependentComputation(t *testing.T) {
// A computation that uses the environment to make decisions
validateTimeout := func(value int) ReaderOption[Config, int] {
return func(cfg Config) O.Option[int] {
if value <= cfg.Timeout {
return O.Some(value)
}
return O.None[int]()
}
}
t.Run("Value within timeout", func(t *testing.T) {
result := F.Pipe1(
Of[Config](20),
Chain(validateTimeout),
)
assert.Equal(t, O.Some(20), result(defaultConfig))
})
t.Run("Value exceeds timeout", func(t *testing.T) {
result := F.Pipe1(
Of[Config](50),
Chain(validateTimeout),
)
assert.Equal(t, O.None[int](), result(defaultConfig))
})
}

View File

@@ -23,6 +23,10 @@ import (
"github.com/stretchr/testify/assert"
)
type MyContext string
const defaultContext MyContext = "default"
func TestSequenceT1(t *testing.T) {
t1 := Of[MyContext]("s1")