1
0
mirror of https://github.com/IBM/fp-go.git synced 2026-03-12 13:36:56 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Dr. Carsten Leue
3a954e0d1f fix: introduce Promap for Effect
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-10 16:10:12 +01:00
Dr. Carsten Leue
cb2e0b23e8 fix: improve docs
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-09 20:41:56 +01:00
12 changed files with 1322 additions and 69 deletions

View File

@@ -52,7 +52,7 @@ import (
//
// - f: A Kleisli arrow (A => ReaderIOResult[Trampoline[A, B]]) that:
// - Takes the current state A
// - Returns a ReaderIOResult that depends on [context.Context]
// - Returns a ReaderIOResult that depends on context.Context
// - Can fail with error (Left in the outer Either)
// - Produces Trampoline[A, B] to control recursion flow (Right in the outer Either)
//
@@ -60,13 +60,13 @@ import (
//
// A Kleisli arrow (A => ReaderIOResult[B]) that:
// - Takes an initial state A
// - Returns a ReaderIOResult that requires [context.Context]
// - Returns a ReaderIOResult that requires context.Context
// - Can fail with error or context cancellation
// - Produces the final result B after recursion completes
//
// # Context Cancellation
//
// Unlike the base [readerioresult.TailRec], this version automatically integrates
// Unlike the base readerioresult.TailRec, this version automatically integrates
// context cancellation checking:
// - Each recursive iteration checks if the context is cancelled
// - If cancelled, recursion terminates immediately with a cancellation error
@@ -92,9 +92,9 @@ import (
//
// # Example: Cancellable Countdown
//
// countdownStep := func(n int) readerioresult.ReaderIOResult[tailrec.Trampoline[int, string]] {
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[int, string]] {
// return func() either.Either[error, tailrec.Trampoline[int, string]] {
// countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
// return func(ctx context.Context) IOEither[Trampoline[int, string]] {
// return func() Either[Trampoline[int, string]] {
// if n <= 0 {
// return either.Right[error](tailrec.Land[int]("Done!"))
// }
@@ -105,7 +105,7 @@ import (
// }
// }
//
// countdown := readerioresult.TailRec(countdownStep)
// countdown := TailRec(countdownStep)
//
// // With cancellation
// ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
@@ -119,9 +119,9 @@ import (
// processed []string
// }
//
// processStep := func(state ProcessState) readerioresult.ReaderIOResult[tailrec.Trampoline[ProcessState, []string]] {
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[ProcessState, []string]] {
// return func() either.Either[error, tailrec.Trampoline[ProcessState, []string]] {
// processStep := func(state ProcessState) ReaderIOResult[Trampoline[ProcessState, []string]] {
// return func(ctx context.Context) IOEither[Trampoline[ProcessState, []string]] {
// return func() Either[Trampoline[ProcessState, []string]] {
// if len(state.files) == 0 {
// return either.Right[error](tailrec.Land[ProcessState](state.processed))
// }
@@ -140,7 +140,7 @@ import (
// }
// }
//
// processFiles := readerioresult.TailRec(processStep)
// processFiles := TailRec(processStep)
// ctx, cancel := context.WithCancel(t.Context())
//
// // Can be cancelled at any point during processing
@@ -158,7 +158,7 @@ import (
// still respecting context cancellation:
//
// // Safe for very large inputs with cancellation support
// largeCountdown := readerioresult.TailRec(countdownStep)
// largeCountdown := TailRec(countdownStep)
// ctx := t.Context()
// result := largeCountdown(1000000)(ctx)() // Safe, no stack overflow
//
@@ -171,11 +171,11 @@ import (
//
// # See Also
//
// - [readerioresult.TailRec]: Base tail recursion without automatic context checking
// - [WithContext]: Context cancellation wrapper used internally
// - [Chain]: For sequencing ReaderIOResult computations
// - [Ask]: For accessing the context
// - [Left]/[Right]: For creating error/success values
// - readerioresult.TailRec: Base tail recursion without automatic context checking
// - WithContext: Context cancellation wrapper used internally
// - Chain: For sequencing ReaderIOResult computations
// - Ask: For accessing the context
// - Left/Right: For creating error/success values
//
//go:inline
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {

View File

@@ -30,6 +30,16 @@ import (
"github.com/stretchr/testify/require"
)
// CustomError is a test error type
type CustomError struct {
Code int
Message string
}
func (e *CustomError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Message)
}
func TestTailRec_BasicRecursion(t *testing.T) {
// Test basic countdown recursion
countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
@@ -432,3 +442,237 @@ func TestTailRec_ContextWithValue(t *testing.T) {
assert.Equal(t, E.Of[error]("Done!"), result)
}
func TestTailRec_MultipleErrorTypes(t *testing.T) {
// Test that different error types are properly handled
errorStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
if n == 5 {
customErr := &CustomError{Code: 500, Message: "custom error"}
return E.Left[Trampoline[int, string]](error(customErr))
}
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
errorRecursion := TailRec(errorStep)
result := errorRecursion(10)(t.Context())()
assert.True(t, E.IsLeft(result))
err := E.ToError(result)
customErr, ok := err.(*CustomError)
require.True(t, ok, "Expected CustomError type")
assert.Equal(t, 500, customErr.Code)
assert.Equal(t, "custom error", customErr.Message)
}
func TestTailRec_ContextCancelDuringBounce(t *testing.T) {
// Test cancellation happens between bounces, not during computation
var iterationCount int32
ctx, cancel := context.WithCancel(t.Context())
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
count := atomic.AddInt32(&iterationCount, 1)
// Cancel after 3 iterations
if count == 3 {
cancel()
}
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
slowRecursion := TailRec(slowStep)
result := slowRecursion(10)(ctx)()
// Should be cancelled after a few iterations
assert.True(t, E.IsLeft(result))
iterations := atomic.LoadInt32(&iterationCount)
assert.Greater(t, iterations, int32(2))
assert.Less(t, iterations, int32(10))
}
func TestTailRec_EmptyState(t *testing.T) {
// Test with empty/zero-value state
type EmptyState struct{}
emptyStep := func(state EmptyState) ReaderIOResult[Trampoline[EmptyState, int]] {
return func(ctx context.Context) IOEither[Trampoline[EmptyState, int]] {
return func() Either[Trampoline[EmptyState, int]] {
return E.Right[error](tailrec.Land[EmptyState](42))
}
}
}
emptyRecursion := TailRec(emptyStep)
result := emptyRecursion(EmptyState{})(t.Context())()
assert.Equal(t, E.Of[error](42), result)
}
func TestTailRec_PointerState(t *testing.T) {
// Test with pointer state to ensure proper handling
type Node struct {
Value int
Next *Node
}
// Create a linked list: 1 -> 2 -> 3 -> nil
list := &Node{Value: 1, Next: &Node{Value: 2, Next: &Node{Value: 3, Next: nil}}}
sumStep := func(node *Node) ReaderIOResult[Trampoline[*Node, int]] {
return func(ctx context.Context) IOEither[Trampoline[*Node, int]] {
return func() Either[Trampoline[*Node, int]] {
if node == nil {
return E.Right[error](tailrec.Land[*Node](0))
}
if node.Next == nil {
return E.Right[error](tailrec.Land[*Node](node.Value))
}
// Accumulate value and continue
node.Next.Value += node.Value
return E.Right[error](tailrec.Bounce[int](node.Next))
}
}
}
sumList := TailRec(sumStep)
result := sumList(list)(t.Context())()
assert.Equal(t, E.Of[error](6), result) // 1 + 2 + 3 = 6
}
func TestTailRec_ConcurrentCancellation(t *testing.T) {
// Test that cancellation works correctly with concurrent operations
var iterationCount int32
ctx, cancel := context.WithCancel(t.Context())
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
atomic.AddInt32(&iterationCount, 1)
time.Sleep(10 * time.Millisecond)
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
slowRecursion := TailRec(slowStep)
// Cancel from another goroutine after 50ms
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
start := time.Now()
result := slowRecursion(20)(ctx)()
elapsed := time.Since(start)
// Should be cancelled
assert.True(t, E.IsLeft(result))
// Should complete quickly due to cancellation
assert.Less(t, elapsed, 100*time.Millisecond)
// Should have executed some but not all iterations
iterations := atomic.LoadInt32(&iterationCount)
assert.Greater(t, iterations, int32(0))
assert.Less(t, iterations, int32(20))
}
func TestTailRec_NestedContextValues(t *testing.T) {
// Test that nested context values are preserved
type contextKey string
const (
key1 contextKey = "key1"
key2 contextKey = "key2"
)
nestedStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
val1 := ctx.Value(key1)
val2 := ctx.Value(key2)
require.NotNil(t, val1)
require.NotNil(t, val2)
assert.Equal(t, "value1", val1.(string))
assert.Equal(t, "value2", val2.(string))
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
nestedRecursion := TailRec(nestedStep)
ctx := context.WithValue(t.Context(), key1, "value1")
ctx = context.WithValue(ctx, key2, "value2")
result := nestedRecursion(3)(ctx)()
assert.Equal(t, E.Of[error]("Done!"), result)
}
func BenchmarkTailRec_SimpleCountdown(b *testing.B) {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
return func() Either[Trampoline[int, int]] {
if n <= 0 {
return E.Right[error](tailrec.Land[int](0))
}
return E.Right[error](tailrec.Bounce[int](n - 1))
}
}
}
countdown := TailRec(countdownStep)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = countdown(1000)(ctx)()
}
}
func BenchmarkTailRec_WithCancellation(b *testing.B) {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
return func() Either[Trampoline[int, int]] {
if n <= 0 {
return E.Right[error](tailrec.Land[int](0))
}
return E.Right[error](tailrec.Bounce[int](n - 1))
}
}
}
countdown := TailRec(countdownStep)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = countdown(1000)(ctx)()
}
}

View File

@@ -354,3 +354,20 @@ func LocalEffectK[A, C1, C2 any](f Kleisli[C2, C2, C1]) func(Effect[C1, A]) Effe
func LocalReaderK[A, C1, C2 any](f reader.Kleisli[C2, C1]) func(Effect[C1, A]) Effect[C2, A] {
return readerreaderioresult.LocalReaderK[A](f)
}
// Ask returns an Effect that produces the context C as its success value.
// This is the fundamental operation of the reader/environment monad,
// allowing effects to access their own context.
//
// # Type Parameters
//
// - C: The context type (also the produced value type)
//
// # Returns
//
// - Effect[C, C]: An effect that succeeds with its own context value
//
//go:inline
func Ask[C any]() Effect[C, C] {
return readerreaderioresult.Ask[C]()
}

View File

@@ -19,8 +19,6 @@ import (
"context"
"fmt"
"testing"
"time"
"github.com/IBM/fp-go/v2/context/reader"
"github.com/IBM/fp-go/v2/context/readerreaderioresult"
"github.com/stretchr/testify/assert"
@@ -922,45 +920,77 @@ func TestLocalReaderK(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "result", result)
})
}
t.Run("runtime context deadline awareness", func(t *testing.T) {
type Config struct {
HasDeadline bool
}
// Reader that checks runtime context for deadline
checkContext := func(path string) reader.Reader[Config] {
return func(ctx context.Context) Config {
_, hasDeadline := ctx.Deadline()
return Config{HasDeadline: hasDeadline}
}
}
// Effect that uses the config
configEffect := Chain(func(cfg Config) Effect[Config, string] {
return Of[Config](fmt.Sprintf("Has deadline: %v", cfg.HasDeadline))
})(readerreaderioresult.Ask[Config]())
transform := LocalReaderK[string](checkContext)
pathEffect := transform(configEffect)
// Without deadline
ioResult := Provide[string]("config.json")(pathEffect)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
func TestAsk(t *testing.T) {
t.Run("returns context as value", func(t *testing.T) {
ctx := "my-context"
result, err := runEffect(Ask[string](), ctx)
assert.NoError(t, err)
assert.Equal(t, "Has deadline: false", result)
assert.Equal(t, ctx, result)
})
// With deadline
ctxWithDeadline, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
t.Run("works with struct context", func(t *testing.T) {
type Config struct {
Host string
Port int
}
ioResult2 := Provide[string]("config.json")(pathEffect)
readerResult2 := RunSync(ioResult2)
result2, err2 := readerResult2(ctxWithDeadline)
cfg := Config{Host: "localhost", Port: 8080}
result, err := runEffect(Ask[Config](), cfg)
assert.NoError(t, err)
assert.Equal(t, cfg, result)
})
t.Run("can be chained with Map to extract a field", func(t *testing.T) {
type Config struct {
Host string
Port int
}
hostEffect := Map[Config](func(cfg Config) string {
return cfg.Host
})(Ask[Config]())
result, err := runEffect(hostEffect, Config{Host: "example.com", Port: 443})
assert.NoError(t, err)
assert.Equal(t, "example.com", result)
})
t.Run("can be chained with Chain to produce a derived effect", func(t *testing.T) {
type Config struct {
APIKey string
}
derived := Chain(func(cfg Config) Effect[Config, string] {
if cfg.APIKey == "" {
return Fail[Config, string](assert.AnError)
}
return Of[Config]("authenticated: " + cfg.APIKey)
})(Ask[Config]())
// Valid key
result, err := runEffect(derived, Config{APIKey: "secret"})
assert.NoError(t, err)
assert.Equal(t, "authenticated: secret", result)
// Empty key
_, err = runEffect(derived, Config{APIKey: ""})
assert.Error(t, err)
assert.Equal(t, assert.AnError, err)
})
t.Run("is idempotent - multiple calls return same context", func(t *testing.T) {
ctx := TestContext{Value: "shared"}
r1, err1 := runEffect(Ask[TestContext](), ctx)
r2, err2 := runEffect(Ask[TestContext](), ctx)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.Equal(t, "Has deadline: true", result2)
assert.Equal(t, r1, r2)
})
}

View File

@@ -612,3 +612,50 @@ func ChainReaderIOK[C, A, B any](f readerio.Kleisli[C, A, B]) Operator[C, A, B]
func Read[A, C any](c C) func(Effect[C, A]) Thunk[A] {
return readerreaderioresult.Read[A](c)
}
// Asks creates an Effect that projects a value from the context using a Reader function.
// This is useful for extracting specific fields or computing derived values from the context.
// It's essentially a lifted version of the Reader pattern into the Effect context.
//
// # Type Parameters
//
// - C: The context type
// - A: The type of the projected value
//
// # Parameters
//
// - r: A Reader function that extracts or computes a value from the context
//
// # Returns
//
// - Effect[C, A]: An effect that succeeds with the projected value
//
// # Example
//
// type Config struct {
// Host string
// Port int
// }
//
// // Extract a specific field
// getHost := effect.Asks[Config](func(cfg Config) string {
// return cfg.Host
// })
//
// // Compute a derived value
// getURL := effect.Asks[Config](func(cfg Config) string {
// return fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
// })
//
// result, err := runEffect(getHost, Config{Host: "localhost", Port: 8080})
// // result == "localhost", err == nil
//
// # See Also
//
// - Ask: Returns the entire context as the value
// - Map: Transforms the value after extraction
//
//go:inline
func Asks[C, A any](r Reader[C, A]) Effect[C, A] {
return readerreaderioresult.Asks(r)
}

View File

@@ -677,3 +677,411 @@ func TestChainThunkK_Integration(t *testing.T) {
assert.Equal(t, result.Of("Value: 100"), outcome)
})
}
func TestAsks_Success(t *testing.T) {
t.Run("extracts a field from context", func(t *testing.T) {
type Config struct {
Host string
Port int
}
getHost := Asks[Config](func(cfg Config) string {
return cfg.Host
})
result, err := runEffect(getHost, Config{Host: "localhost", Port: 8080})
assert.NoError(t, err)
assert.Equal(t, "localhost", result)
})
t.Run("extracts multiple fields and computes derived value", func(t *testing.T) {
type Config struct {
Host string
Port int
}
getURL := Asks[Config](func(cfg Config) string {
return fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
})
result, err := runEffect(getURL, Config{Host: "example.com", Port: 443})
assert.NoError(t, err)
assert.Equal(t, "http://example.com:443", result)
})
t.Run("extracts numeric field", func(t *testing.T) {
getPort := Asks[TestConfig](func(cfg TestConfig) int {
return cfg.Multiplier
})
result, err := runEffect(getPort, testConfig)
assert.NoError(t, err)
assert.Equal(t, 3, result)
})
t.Run("computes value from context", func(t *testing.T) {
type Config struct {
Width int
Height int
}
getArea := Asks[Config](func(cfg Config) int {
return cfg.Width * cfg.Height
})
result, err := runEffect(getArea, Config{Width: 10, Height: 20})
assert.NoError(t, err)
assert.Equal(t, 200, result)
})
t.Run("transforms string field", func(t *testing.T) {
getUpperPrefix := Asks[TestConfig](func(cfg TestConfig) string {
return fmt.Sprintf("[%s]", cfg.Prefix)
})
result, err := runEffect(getUpperPrefix, testConfig)
assert.NoError(t, err)
assert.Equal(t, "[LOG]", result)
})
}
func TestAsks_EdgeCases(t *testing.T) {
t.Run("handles zero values", func(t *testing.T) {
type Config struct {
Value int
}
getValue := Asks[Config](func(cfg Config) int {
return cfg.Value
})
result, err := runEffect(getValue, Config{Value: 0})
assert.NoError(t, err)
assert.Equal(t, 0, result)
})
t.Run("handles empty string", func(t *testing.T) {
type Config struct {
Name string
}
getName := Asks[Config](func(cfg Config) string {
return cfg.Name
})
result, err := runEffect(getName, Config{Name: ""})
assert.NoError(t, err)
assert.Equal(t, "", result)
})
t.Run("handles nil pointer fields", func(t *testing.T) {
type Config struct {
Data *string
}
hasData := Asks[Config](func(cfg Config) bool {
return cfg.Data != nil
})
result, err := runEffect(hasData, Config{Data: nil})
assert.NoError(t, err)
assert.False(t, result)
})
t.Run("handles complex nested structures", func(t *testing.T) {
type Database struct {
Host string
Port int
}
type Config struct {
DB Database
}
getDBHost := Asks[Config](func(cfg Config) string {
return cfg.DB.Host
})
result, err := runEffect(getDBHost, Config{
DB: Database{Host: "db.example.com", Port: 5432},
})
assert.NoError(t, err)
assert.Equal(t, "db.example.com", result)
})
}
func TestAsks_Integration(t *testing.T) {
t.Run("composes with Map", func(t *testing.T) {
type Config struct {
Value int
}
computation := F.Pipe1(
Asks[Config](func(cfg Config) int {
return cfg.Value
}),
Map[Config](func(x int) int { return x * 2 }),
)
result, err := runEffect(computation, Config{Value: 21})
assert.NoError(t, err)
assert.Equal(t, 42, result)
})
t.Run("composes with Chain", func(t *testing.T) {
type Config struct {
Multiplier int
}
computation := F.Pipe1(
Asks[Config](func(cfg Config) int {
return cfg.Multiplier
}),
Chain(func(mult int) Effect[Config, int] {
return Of[Config](mult * 10)
}),
)
result, err := runEffect(computation, Config{Multiplier: 5})
assert.NoError(t, err)
assert.Equal(t, 50, result)
})
t.Run("composes with ChainReaderK", func(t *testing.T) {
computation := F.Pipe1(
Asks[TestConfig](func(cfg TestConfig) int {
return cfg.Multiplier
}),
ChainReaderK(func(mult int) reader.Reader[TestConfig, int] {
return func(cfg TestConfig) int {
return mult + len(cfg.Prefix)
}
}),
)
result, err := runEffect(computation, testConfig)
assert.NoError(t, err)
assert.Equal(t, 6, result) // 3 + len("LOG")
})
t.Run("composes with ChainReaderIOK", func(t *testing.T) {
log := []string{}
computation := F.Pipe1(
Asks[TestConfig](func(cfg TestConfig) string {
return cfg.Prefix
}),
ChainReaderIOK(func(prefix string) readerio.ReaderIO[TestConfig, string] {
return func(cfg TestConfig) io.IO[string] {
return func() string {
log = append(log, "executed")
return fmt.Sprintf("%s:%d", prefix, cfg.Multiplier)
}
}
}),
)
result, err := runEffect(computation, testConfig)
assert.NoError(t, err)
assert.Equal(t, "LOG:3", result)
assert.Equal(t, 1, len(log))
})
t.Run("multiple Asks in sequence", func(t *testing.T) {
type Config struct {
First string
Second string
}
computation := F.Pipe2(
Asks[Config](func(cfg Config) string {
return cfg.First
}),
Chain(func(_ string) Effect[Config, string] {
return Asks[Config](func(cfg Config) string {
return cfg.Second
})
}),
Map[Config](func(s string) string {
return "Result: " + s
}),
)
result, err := runEffect(computation, Config{First: "A", Second: "B"})
assert.NoError(t, err)
assert.Equal(t, "Result: B", result)
})
t.Run("Asks combined with Ask", func(t *testing.T) {
type Config struct {
Value int
}
computation := F.Pipe1(
Ask[Config](),
Chain(func(cfg Config) Effect[Config, int] {
return Asks[Config](func(c Config) int {
return c.Value * 2
})
}),
)
result, err := runEffect(computation, Config{Value: 15})
assert.NoError(t, err)
assert.Equal(t, 30, result)
})
}
func TestAsks_Comparison(t *testing.T) {
t.Run("Asks vs Ask with Map", func(t *testing.T) {
type Config struct {
Port int
}
// Using Asks
asksVersion := Asks[Config](func(cfg Config) int {
return cfg.Port
})
// Using Ask + Map
askMapVersion := F.Pipe1(
Ask[Config](),
Map[Config](func(cfg Config) int {
return cfg.Port
}),
)
cfg := Config{Port: 8080}
result1, err1 := runEffect(asksVersion, cfg)
result2, err2 := runEffect(askMapVersion, cfg)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.Equal(t, result1, result2)
assert.Equal(t, 8080, result1)
})
t.Run("Asks is more concise than Ask + Map", func(t *testing.T) {
type Config struct {
Host string
Port int
}
// Asks is more direct for field extraction
getHost := Asks[Config](func(cfg Config) string {
return cfg.Host
})
result, err := runEffect(getHost, Config{Host: "api.example.com", Port: 443})
assert.NoError(t, err)
assert.Equal(t, "api.example.com", result)
})
}
func TestAsks_RealWorldScenarios(t *testing.T) {
t.Run("extract database connection string", func(t *testing.T) {
type DatabaseConfig struct {
Host string
Port int
Database string
User string
}
getConnectionString := Asks[DatabaseConfig](func(cfg DatabaseConfig) string {
return fmt.Sprintf("postgres://%s@%s:%d/%s",
cfg.User, cfg.Host, cfg.Port, cfg.Database)
})
result, err := runEffect(getConnectionString, DatabaseConfig{
Host: "localhost",
Port: 5432,
Database: "myapp",
User: "admin",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://admin@localhost:5432/myapp", result)
})
t.Run("compute API endpoint from config", func(t *testing.T) {
type APIConfig struct {
Protocol string
Host string
Port int
BasePath string
}
getEndpoint := Asks[APIConfig](func(cfg APIConfig) string {
return fmt.Sprintf("%s://%s:%d%s",
cfg.Protocol, cfg.Host, cfg.Port, cfg.BasePath)
})
result, err := runEffect(getEndpoint, APIConfig{
Protocol: "https",
Host: "api.example.com",
Port: 443,
BasePath: "/v1",
})
assert.NoError(t, err)
assert.Equal(t, "https://api.example.com:443/v1", result)
})
t.Run("validate configuration", func(t *testing.T) {
type Config struct {
Timeout int
MaxRetries int
}
isValid := Asks[Config](func(cfg Config) bool {
return cfg.Timeout > 0 && cfg.MaxRetries >= 0
})
// Valid config
result1, err1 := runEffect(isValid, Config{Timeout: 30, MaxRetries: 3})
assert.NoError(t, err1)
assert.True(t, result1)
// Invalid config
result2, err2 := runEffect(isValid, Config{Timeout: 0, MaxRetries: 3})
assert.NoError(t, err2)
assert.False(t, result2)
})
t.Run("extract feature flags", func(t *testing.T) {
type FeatureFlags struct {
EnableNewUI bool
EnableBetaAPI bool
EnableAnalytics bool
}
hasNewUI := Asks[FeatureFlags](func(flags FeatureFlags) bool {
return flags.EnableNewUI
})
result, err := runEffect(hasNewUI, FeatureFlags{
EnableNewUI: true,
EnableBetaAPI: false,
EnableAnalytics: true,
})
assert.NoError(t, err)
assert.True(t, result)
})
}

86
v2/effect/profunctor.go Normal file
View File

@@ -0,0 +1,86 @@
// 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 effect
import (
F "github.com/IBM/fp-go/v2/function"
)
// Promap is the profunctor map operation that transforms both the input and output of an Effect.
// It applies f to the input context (contravariantly) and g to the output value (covariantly).
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// This operation allows you to:
// - Modify the context before passing it to the Effect (via f)
// - Transform the success value after the computation completes (via g)
//
// Promap is particularly useful for adapting effects to work with different context types
// while simultaneously transforming their output values.
//
// # Type Parameters
//
// - E: The original context type expected by the Effect
// - A: The original success type produced by the Effect
// - D: The new input context type
// - B: The new output success type
//
// # Parameters
//
// - f: Function to transform the input context from D to E (contravariant)
// - g: Function to transform the output success value from A to B (covariant)
//
// # Returns
//
// - A Kleisli arrow that takes an Effect[E, A] and returns a function from D to B
//
// # Example Usage
//
// type AppConfig struct {
// DatabaseURL string
// APIKey string
// }
//
// type DBConfig struct {
// URL string
// }
//
// // Effect that uses DBConfig and returns an int
// getUserCount := func(cfg DBConfig) effect.Effect[context.Context, int] {
// return effect.Succeed[context.Context](42)
// }
//
// // Transform AppConfig to DBConfig
// extractDBConfig := func(app AppConfig) DBConfig {
// return DBConfig{URL: app.DatabaseURL}
// }
//
// // Transform int to string
// formatCount := func(count int) string {
// return fmt.Sprintf("Users: %d", count)
// }
//
// // Adapt the effect to work with AppConfig and return string
// adapted := effect.Promap(extractDBConfig, formatCount)(getUserCount)
// result := adapted(AppConfig{DatabaseURL: "localhost:5432", APIKey: "secret"})
//
//go:inline
func Promap[E, A, D, B any](f Reader[D, E], g Reader[A, B]) Kleisli[D, Effect[E, A], B] {
return F.Flow2(
Local[A](f),
Map[D](g),
)
}

View File

@@ -0,0 +1,373 @@
// 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 effect
import (
"context"
"fmt"
"strconv"
"testing"
R "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
// Test types for profunctor tests
type AppConfig struct {
DatabaseURL string
APIKey string
Port int
}
type DBConfig struct {
URL string
}
type ServerConfig struct {
Host string
Port int
}
// TestPromapBasic tests basic Promap functionality
func TestPromapBasic(t *testing.T) {
t.Run("transform both context and output", func(t *testing.T) {
// Effect that uses DBConfig and returns an int
getUserCount := Succeed[DBConfig](42)
// Transform AppConfig to DBConfig
extractDBConfig := func(app AppConfig) DBConfig {
return DBConfig{URL: app.DatabaseURL}
}
// Transform int to string
formatCount := func(count int) string {
return fmt.Sprintf("Users: %d", count)
}
// Adapt the effect to work with AppConfig and return string
adapted := Promap(extractDBConfig, formatCount)(getUserCount)
result := adapted(AppConfig{
DatabaseURL: "localhost:5432",
APIKey: "secret",
Port: 8080,
})(context.Background())()
assert.Equal(t, R.Of("Users: 42"), result)
})
t.Run("identity transformations", func(t *testing.T) {
// Effect that returns a value
getValue := Succeed[DBConfig](100)
// Identity transformations
identity := func(x DBConfig) DBConfig { return x }
identityInt := func(x int) int { return x }
// Apply identity transformations
adapted := Promap(identity, identityInt)(getValue)
result := adapted(DBConfig{URL: "localhost"})(context.Background())()
assert.Equal(t, R.Of(100), result)
})
}
// TestPromapComposition tests that Promap composes correctly
func TestPromapComposition(t *testing.T) {
t.Run("compose multiple transformations", func(t *testing.T) {
// Effect that uses ServerConfig and returns the port
getPort := Map[ServerConfig](func(cfg ServerConfig) int {
return cfg.Port
})(Ask[ServerConfig]())
// First transformation: AppConfig -> ServerConfig
extractServerConfig := func(app AppConfig) ServerConfig {
return ServerConfig{Host: "localhost", Port: app.Port}
}
// Second transformation: int -> string
formatPort := func(port int) string {
return fmt.Sprintf(":%d", port)
}
// Apply transformations
adapted := Promap(extractServerConfig, formatPort)(getPort)
result := adapted(AppConfig{
DatabaseURL: "db.example.com",
APIKey: "key123",
Port: 9000,
})(context.Background())()
assert.Equal(t, R.Of(":9000"), result)
})
}
// TestPromapWithErrors tests Promap with effects that can fail
func TestPromapWithErrors(t *testing.T) {
t.Run("propagates errors correctly", func(t *testing.T) {
// Effect that fails
failingEffect := Fail[DBConfig, int](fmt.Errorf("database connection failed"))
// Transformations
extractDBConfig := func(app AppConfig) DBConfig {
return DBConfig{URL: app.DatabaseURL}
}
formatCount := func(count int) string {
return fmt.Sprintf("Count: %d", count)
}
// Apply transformations
adapted := Promap(extractDBConfig, formatCount)(failingEffect)
result := adapted(AppConfig{DatabaseURL: "localhost"})(context.Background())()
assert.True(t, R.IsLeft(result))
err := R.MonadFold(result,
func(e error) error { return e },
func(string) error { return nil },
)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database connection failed")
})
t.Run("output transformation not applied on error", func(t *testing.T) {
callCount := 0
// Effect that fails
failingEffect := Fail[DBConfig, int](fmt.Errorf("error"))
// Transformation that counts calls
countingTransform := func(x int) string {
callCount++
return strconv.Itoa(x)
}
// Apply transformations
adapted := Promap(
func(app AppConfig) DBConfig { return DBConfig{URL: app.DatabaseURL} },
countingTransform,
)(failingEffect)
result := adapted(AppConfig{DatabaseURL: "localhost"})(context.Background())()
assert.True(t, R.IsLeft(result))
assert.Equal(t, 0, callCount, "output transformation should not be called on error")
})
}
// TestPromapWithComplexTypes tests Promap with more complex type transformations
func TestPromapWithComplexTypes(t *testing.T) {
t.Run("transform struct to different struct", func(t *testing.T) {
type User struct {
ID int
Name string
}
type UserDTO struct {
UserID int
FullName string
}
// Effect that uses User and returns a string
getUserInfo := Map[User](func(user User) string {
return fmt.Sprintf("User %s (ID: %d)", user.Name, user.ID)
})(Ask[User]())
// Transform UserDTO to User
dtoToUser := func(dto UserDTO) User {
return User{ID: dto.UserID, Name: dto.FullName}
}
// Transform string to uppercase
toUpper := func(s string) string {
return fmt.Sprintf("INFO: %s", s)
}
// Apply transformations
adapted := Promap(dtoToUser, toUpper)(getUserInfo)
result := adapted(UserDTO{UserID: 123, FullName: "Alice"})(context.Background())()
assert.Equal(t, R.Of("INFO: User Alice (ID: 123)"), result)
})
}
// TestPromapChaining tests chaining multiple Promap operations
func TestPromapChaining(t *testing.T) {
t.Run("chain multiple Promap operations", func(t *testing.T) {
// Base effect that doubles the input
baseEffect := Map[int](func(x int) int {
return x * 2
})(Ask[int]())
// First Promap: string -> int, int -> string
step1 := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
strconv.Itoa,
)(baseEffect)
// Second Promap: float64 -> string, string -> float64
step2 := Promap(
func(f float64) string {
return fmt.Sprintf("%.0f", f)
},
func(s string) float64 {
f, _ := strconv.ParseFloat(s, 64)
return f
},
)(step1)
result := step2(21.0)(context.Background())()
assert.Equal(t, R.Of(42.0), result)
})
}
// TestPromapEdgeCases tests edge cases
func TestPromapEdgeCases(t *testing.T) {
t.Run("zero values", func(t *testing.T) {
effect := Map[int](func(x int) int {
return x
})(Ask[int]())
adapted := Promap(
func(s string) int { return 0 },
func(x int) string { return "" },
)(effect)
result := adapted("anything")(context.Background())()
assert.Equal(t, R.Of(""), result)
})
t.Run("nil context handling", func(t *testing.T) {
effect := Succeed[int]("success")
adapted := Promap(
func(s string) int { return 42 },
func(s string) string { return s + "!" },
)(effect)
// Using background context instead of nil
result := adapted("test")(context.Background())()
assert.Equal(t, R.Of("success!"), result)
})
}
// TestPromapIntegration tests integration with other effect operations
func TestPromapIntegration(t *testing.T) {
t.Run("Promap with Map", func(t *testing.T) {
// Base effect that adds 10
baseEffect := Map[int](func(x int) int {
return x + 10
})(Ask[int]())
// Apply Promap
promapped := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
func(x int) int { return x * 2 },
)(baseEffect)
// Apply Map on top
mapped := Map[string](func(x int) string {
return fmt.Sprintf("Result: %d", x)
})(promapped)
result := mapped("5")(context.Background())()
assert.Equal(t, R.Of("Result: 30"), result)
})
t.Run("Promap with Chain", func(t *testing.T) {
// Base effect
baseEffect := Ask[int]()
// Apply Promap
promapped := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
func(x int) int { return x * 2 },
)(baseEffect)
// Chain with another effect
chained := Chain(func(x int) Effect[string, string] {
return Succeed[string](fmt.Sprintf("Value: %d", x))
})(promapped)
result := chained("10")(context.Background())()
assert.Equal(t, R.Of("Value: 20"), result)
})
}
// BenchmarkPromap benchmarks the Promap operation
func BenchmarkPromap(b *testing.B) {
effect := Map[int](func(x int) int {
return x * 2
})(Ask[int]())
adapted := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
strconv.Itoa,
)(effect)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = adapted("42")(ctx)()
}
}
// BenchmarkPromapChained benchmarks chained Promap operations
func BenchmarkPromapChained(b *testing.B) {
baseEffect := Map[int](func(x int) int {
return x * 2
})(Ask[int]())
step1 := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
strconv.Itoa,
)(baseEffect)
step2 := Promap(
func(f float64) string {
return fmt.Sprintf("%.0f", f)
},
func(s string) float64 {
f, _ := strconv.ParseFloat(s, 64)
return f
},
)(step1)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = step2(21.0)(ctx)()
}
}

View File

@@ -69,6 +69,14 @@ func TestFormatterInterface(t *testing.T) {
result := fmt.Sprintf("%q", tramp)
assert.Equal(t, "\"Bounce(42)\"", result)
})
t.Run("unknown verb format", func(t *testing.T) {
tramp := Bounce[string](42)
result := fmt.Sprintf("%x", tramp)
assert.Contains(t, result, "%!x")
assert.Contains(t, result, "Trampoline[B, L]")
assert.Contains(t, result, "Bounce(42)")
})
}
// TestGoStringerInterface verifies fmt.GoStringer implementation

View File

@@ -22,14 +22,18 @@ import (
// LogValue implements the slog.LogValuer interface for Trampoline.
//
// This method allows Trampoline values to be logged using Go's structured logging
// (log/slog) with proper representation of their state:
// with proper representation of their state:
// - When Landed is true: returns a group with a single "landed" attribute containing the Land value
// - When Landed is false: returns a group with a single "bouncing" attribute containing the Bounce value
//
// The implementation ensures that Trampoline values are logged in a structured,
// readable format that clearly shows the current state of the tail-recursive computation.
//
// Example usage:
// # Returns
//
// - slog.Value: A structured log value representing the trampoline state
//
// # Example
//
// trampoline := tailrec.Bounce[int](42)
// slog.Info("Processing", "state", trampoline)

View File

@@ -8,17 +8,20 @@ import "fmt"
// This represents a recursive call in the original algorithm. The computation
// will continue by processing the provided state value in the next iteration.
//
// Type Parameters:
// - L: The final result type (land type)
// - B: The intermediate state type (bounce type)
// # Type Parameters
//
// - B: The intermediate state type (bounce type)
// - L: The final result type (land type)
//
// # Parameters
//
// Parameters:
// - b: The new intermediate state to process in the next step
//
// Returns:
// - A Trampoline in the "bounce" state containing the intermediate value
// # Returns
//
// Example:
// - Trampoline[B, L]: A Trampoline in the "bounce" state containing the intermediate value
//
// # Example
//
// // Countdown that bounces until reaching zero
// func countdownStep(n int) Trampoline[int, int] {
@@ -40,17 +43,20 @@ func Bounce[L, B any](b B) Trampoline[B, L] {
// a Land trampoline is encountered, the executor should stop iterating and
// return the final result.
//
// Type Parameters:
// # Type Parameters
//
// - B: The intermediate state type (bounce type)
// - L: The final result type (land type)
//
// Parameters:
// # Parameters
//
// - l: The final result value
//
// Returns:
// - A Trampoline in the "land" state containing the final result
// # Returns
//
// Example:
// - Trampoline[B, L]: A Trampoline in the "land" state containing the final result
//
// # Example
//
// // Factorial base case
// func factorialStep(state State) Trampoline[State, int] {
@@ -66,7 +72,13 @@ func Land[B, L any](l L) Trampoline[B, L] {
}
// String implements fmt.Stringer for Trampoline.
//
// Returns a human-readable string representation of the trampoline state.
// For bounce states, returns "Bounce(value)". For land states, returns "Land(value)".
//
// # Returns
//
// - string: A formatted string representation of the trampoline state
func (t Trampoline[B, L]) String() string {
if t.Landed {
return fmt.Sprintf("Land(%v)", t.Land)
@@ -75,7 +87,18 @@ func (t Trampoline[B, L]) String() string {
}
// Format implements fmt.Formatter for Trampoline.
// Supports various formatting verbs for detailed output.
//
// Supports various formatting verbs for detailed output:
// - %v: Default format (delegates to String)
// - %+v: Detailed format with type information
// - %#v: Go-syntax representation (delegates to GoString)
// - %s: String format
// - %q: Quoted string format
//
// # Parameters
//
// - f: The format state
// - verb: The formatting verb
func (t Trampoline[B, L]) Format(f fmt.State, verb rune) {
switch verb {
case 'v':
@@ -106,7 +129,13 @@ func (t Trampoline[B, L]) Format(f fmt.State, verb rune) {
}
// GoString implements fmt.GoStringer for Trampoline.
//
// Returns a Go-syntax representation that could be used to recreate the value.
// The output includes the package name, function name, type parameters, and value.
//
// # Returns
//
// - string: A Go-syntax representation of the trampoline
func (t Trampoline[B, L]) GoString() string {
if t.Landed {
return fmt.Sprintf("tailrec.Land[%T](%#v)", t.Bounce, t.Land)

View File

@@ -7,14 +7,21 @@ type (
// - Bounce: The computation should continue with a new intermediate state (type B)
// - Land: The computation is complete with a final result (type L)
//
// Type Parameters:
// # Type Parameters
//
// - B: The "bounce" type - intermediate state passed between recursive steps
// - L: The "land" type - the final result type when computation completes
//
// The trampoline pattern allows converting recursive algorithms into iterative ones,
// preventing stack overflow for deep recursion while maintaining code clarity.
//
// Example:
// # Design Note
//
// This type uses a struct with a boolean flag rather than the Either type to avoid
// a cyclic dependency. The either package depends on tailrec for its own tail-recursive
// operations, so using Either here would create a circular import.
//
// # Example
//
// // Factorial using trampolines
// type State struct { n, acc int }