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

Compare commits

...

12 Commits

Author SHA1 Message Date
Dr. Carsten Leue
e4dd1169c4 fix: recursion in Errors()
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-04 11:12:22 +01:00
Dr. Carsten Leue
1657569f1d Merge branch 'main' of github.com:IBM/fp-go 2026-03-04 10:31:04 +01:00
Dr. Carsten Leue
545876d013 fix: add bool codec
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-04 10:30:55 +01:00
renovate[bot]
9492c5d994 chore(deps): update actions/setup-node action to v6.3.0 (#158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 09:20:04 +00:00
Dr. Carsten Leue
94b1ea30d1 fix: improved doc
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-02 13:23:59 +01:00
Dr. Carsten Leue
a77d61f632 fix: add Bind for Codec
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-02 13:01:21 +01:00
renovate[bot]
66b2f57d73 fix(deps): update module github.com/urfave/cli/v3 to v3.7.0 (#157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 08:40:21 +00:00
Dr. Carsten Leue
92eb2a50a2 fix: support MarshalJSON and MarshalText types
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-01 22:38:20 +01:00
Dr. Carsten Leue
3df1dca146 fix: better result type for Pipe
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 17:03:12 +01:00
Dr. Carsten Leue
a0132e2e92 fix: parameter order
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 15:08:11 +01:00
Dr. Carsten Leue
c6b342908d doc: better explanation for logger
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 13:52:08 +01:00
Dr. Carsten Leue
962237492f doc: explain Effect
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 13:27:20 +01:00
16 changed files with 2286 additions and 914 deletions

View File

@@ -134,7 +134,7 @@ jobs:
fetch-depth: 0
- name: Set up Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ env.NODE_VERSION }}

View File

@@ -1,6 +1,6 @@
# Functional I/O in Go: Context, Errors, and the Reader Pattern
This document explores how functional programming principles apply to I/O operations in Go, comparing traditional imperative approaches with functional patterns using the `context/readerioresult` and `idiomatic/context/readerresult` packages.
This document explores how functional programming principles apply to I/O operations in Go, comparing traditional imperative approaches with functional patterns using the `context/readerioresult`, `idiomatic/context/readerresult`, and `effect` packages.
## Table of Contents
@@ -10,6 +10,7 @@ This document explores how functional programming principles apply to I/O operat
- [Benefits of the Functional Approach](#benefits-of-the-functional-approach)
- [Side-by-Side Comparison](#side-by-side-comparison)
- [Advanced Patterns](#advanced-patterns)
- [The Effect Package: Higher-Level Abstraction](#the-effect-package-higher-level-abstraction)
- [When to Use Each Approach](#when-to-use-each-approach)
## Why Context in I/O Operations
@@ -775,6 +776,191 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
}
```
## The Effect Package: Higher-Level Abstraction
### What is Effect?
The `effect` package provides a higher-level abstraction over `ReaderReaderIOResult`, offering a complete effect system for managing dependencies, errors, and side effects in a composable way. It's inspired by [effect-ts](https://effect.website/) and provides a cleaner API for complex workflows.
### Core Type
```go
// Effect represents an effectful computation that:
// - Requires a context of type C (dependency injection)
// - Can perform I/O operations
// - Can fail with an error
// - Produces a value of type A on success
type Effect[C, A any] = readerreaderioresult.ReaderReaderIOResult[C, A]
```
**Key difference from ReaderIOResult**: Effect adds an additional layer of dependency injection (the `C` type parameter) on top of the runtime `context.Context`, enabling type-safe dependency management.
### When to Use Effect
Use the Effect package when you need:
1. **Type-Safe Dependency Injection**: Your application has typed dependencies (config, services, repositories) that need to be threaded through operations
2. **Complex Workflows**: Multiple services and dependencies need to be composed
3. **Testability**: You want to easily mock dependencies by providing different contexts
4. **Separation of Concerns**: Clear separation between business logic, dependencies, and I/O
### Effect vs ReaderIOResult
```go
// ReaderIOResult - depends only on runtime context
type ReaderIOResult[A any] = func(context.Context) (A, error)
// Effect - depends on typed context C AND runtime context
type Effect[C, A any] = func(C) func(context.Context) (A, error)
```
**ReaderIOResult** is simpler and suitable when you only need runtime context (cancellation, deadlines, request-scoped values).
**Effect** adds typed dependency injection, making it ideal for applications with complex service dependencies.
### Basic Usage
#### Creating Effects
```go
type AppConfig struct {
DatabaseURL string
APIKey string
}
// Create a successful effect
successEffect := effect.Succeed[AppConfig, string]("hello")
// Create a failed effect
failEffect := effect.Fail[AppConfig, string](errors.New("failed"))
// Lift a pure value
pureEffect := effect.Of[AppConfig, int](42)
```
#### Integrating Standard Go Functions
The `Eitherize` function makes it easy to integrate standard Go functions that return `(value, error)`:
```go
type Database struct {
conn *sql.DB
}
// Convert a standard Go function to an Effect using Eitherize
func fetchUser(id int) effect.Effect[Database, User] {
return effect.Eitherize(func(db Database, ctx context.Context) (User, error) {
var user User
err := db.conn.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user)
return user, err
})
}
// Use Eitherize1 for Kleisli arrows (functions with an additional parameter)
fetchUserKleisli := effect.Eitherize1(func(db Database, ctx context.Context, id int) (User, error) {
var user User
err := db.conn.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user)
return user, err
})
// fetchUserKleisli has type: func(int) Effect[Database, User]
```
#### Composing Effects
```go
type Services struct {
UserRepo UserRepository
EmailSvc EmailService
}
// Compose multiple effects with typed dependencies
func processUser(id int, newEmail string) effect.Effect[Services, User] {
return F.Pipe3(
// Fetch user from repository
effect.Eitherize(func(svc Services, ctx context.Context) (User, error) {
return svc.UserRepo.GetUser(ctx, id)
}),
// Validate user (pure function lifted into Effect)
effect.ChainEitherK[Services](validateUser),
// Update email
effect.Chain[Services](func(user User) effect.Effect[Services, User] {
return effect.Eitherize(func(svc Services, ctx context.Context) (User, error) {
if err := svc.EmailSvc.SendVerification(ctx, newEmail); err != nil {
return User{}, err
}
return svc.UserRepo.UpdateEmail(ctx, user.ID, newEmail)
})
}),
)
}
```
#### Running Effects
```go
func main() {
// Set up typed dependencies once
services := Services{
UserRepo: &PostgresUserRepo{db: db},
EmailSvc: &SMTPEmailService{host: "smtp.example.com"},
}
// Build the effect pipeline (no execution yet)
userEffect := processUser(42, "new@email.com")
// Provide dependencies - returns a Thunk (ReaderIOResult)
thunk := effect.Provide(services)(userEffect)
// Run synchronously - returns a func(context.Context) (User, error)
readerResult := effect.RunSync(thunk)
// Execute with runtime context
user, err := readerResult(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Printf("Updated user: %+v\n", user)
}
```
### Comparison: Traditional vs ReaderIOResult vs Effect
| Aspect | Traditional | ReaderIOResult | Effect |
|---|---|---|---|
| Error propagation | Manual | Automatic | Automatic |
| Dependency injection | Function parameters | Closure / `context.Context` | Typed `C` parameter |
| Testability | Requires mocking | Mock `ReaderIOResult` | Provide mock `C` |
| Composability | Low | High | High |
| Type-safe dependencies | No | No | Yes |
| Complexity | Low | Medium | Medium-High |
### Testing with Effect
One of the key benefits of Effect is easy testing through dependency substitution:
```go
func TestProcessUser(t *testing.T) {
// Create mock services
mockServices := Services{
UserRepo: &MockUserRepo{
users: map[int]User{42: {ID: 42, Age: 25, Email: "old@email.com"}},
},
EmailSvc: &MockEmailService{},
}
// Run the effect with mock dependencies
user, err := effect.RunSync(
effect.Provide(mockServices)(processUser(42, "new@email.com")),
)(context.Background())
assert.NoError(t, err)
assert.Equal(t, "new@email.com", user.Email)
}
```
No database or SMTP server needed — just provide mock implementations of the `Services` struct.
## When to Use Each Approach
### Use Traditional Go Style When:
@@ -794,6 +980,17 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
5. **Resource management**: Need guaranteed cleanup (Bracket)
6. **Parallel execution**: Need to parallelize operations easily
7. **Type safety**: Want the type system to track I/O effects
8. **Simple dependencies**: Only need runtime `context.Context`, no typed dependencies
### Use Effect When:
1. **Type-safe dependency injection**: Application has typed dependencies (config, services, repositories)
2. **Complex service architectures**: Multiple services need to be composed with clear dependency management
3. **Testability with mocks**: Want to easily substitute dependencies for testing
4. **Separation of concerns**: Need clear separation between business logic, dependencies, and I/O
5. **Large applications**: Building applications where dependency management is critical
6. **Team experience**: Team is comfortable with functional programming and effect systems
7. **Integration with standard Go**: Need to integrate many standard `(value, error)` functions using `Eitherize`
### Use Idiomatic Functional Style (idiomatic/context/readerresult) When:
@@ -803,6 +1000,7 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
4. **Go integration**: Want seamless integration with existing Go code
5. **Production services**: Building high-throughput services
6. **Best of both worlds**: Want functional composition with Go's native patterns
7. **Simple dependencies**: Only need runtime `context.Context`, no typed dependencies
## Summary
@@ -814,11 +1012,18 @@ The functional approach to I/O in Go offers significant advantages:
4. **Type Safety**: I/O effects visible in the type system
5. **Lazy Evaluation**: Build pipelines, execute when ready
6. **Context Propagation**: Automatic threading of context
7. **Performance**: Idiomatic version offers 2-10x speedup
7. **Dependency Injection**: Type-safe dependency management with Effect
8. **Performance**: Idiomatic version offers 2-10x speedup
The key insight is that **I/O operations return descriptions of effects** (ReaderIOResult) rather than performing effects immediately. This enables powerful composition patterns while maintaining Go's idiomatic error handling through the `(value, error)` tuple pattern.
The key insight is that **I/O operations return descriptions of effects** rather than performing effects immediately. This enables powerful composition patterns while maintaining Go's idiomatic error handling through the `(value, error)` tuple pattern.
For production Go services, the **idiomatic/context/readerresult** package provides the best balance: full functional programming capabilities with native Go performance and familiar error handling patterns.
### Choosing the Right Abstraction
- **ReaderIOResult**: Best for simple I/O pipelines that only need runtime `context.Context`
- **Effect**: Best for complex applications with typed dependencies and service architectures
- **idiomatic/context/readerresult**: Best for production services needing high performance with functional patterns
For production Go services, the **idiomatic/context/readerresult** package provides the best balance of performance and functional capabilities. For applications with complex dependency management, the **effect** package provides type-safe dependency injection with a clean, composable API.
## Further Reading
@@ -826,4 +1031,6 @@ For production Go services, the **idiomatic/context/readerresult** package provi
- [IDIOMATIC_COMPARISON.md](./IDIOMATIC_COMPARISON.md) - Performance comparison
- [idiomatic/doc.go](./idiomatic/doc.go) - Idiomatic package overview
- [context/readerioresult](./context/readerioresult/) - ReaderIOResult package
- [idiomatic/context/readerresult](./idiomatic/context/readerresult/) - Idiomatic ReaderResult package
- [idiomatic/context/readerresult](./idiomatic/context/readerresult/) - Idiomatic ReaderResult package
- [effect](./effect/) - Effect package for type-safe dependency injection
- [effect-ts](https://effect.website/) - TypeScript effect system that inspired this package

View File

@@ -461,7 +461,8 @@ func process() IOResult[string] {
- **Result** - Simplified Either with error as left type (recommended for error handling)
- **IO** - Lazy evaluation and side effect management
- **IOOption** - Combine IO with Option for optional values with side effects
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither)
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither when using standard `error` type)
- **Effect** - Composable effects with dependency injection and error handling
- **Reader** - Dependency injection pattern
- **ReaderOption** - Combine Reader with Option for optional values with dependency injection
- **ReaderIOOption** - Combine Reader, IO, and Option for optional values with dependency injection and side effects

View File

@@ -369,7 +369,7 @@ func onExitAny(
// )
func LogEntryExitWithCallback[A any](
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
cb Reader[context.Context, *slog.Logger],
name string) Operator[A, A] {
nameAttr := slog.String("name", name)
@@ -499,12 +499,12 @@ func LogEntryExit[A any](name string) Operator[A, A] {
func curriedLog(
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
message string) func(slog.Attr) func(context.Context) func() struct{} {
return F.Curry2(func(a slog.Attr, ctx context.Context) func() struct{} {
message string) func(slog.Attr) ReaderIO[Void] {
return F.Curry2(func(a slog.Attr, ctx context.Context) IO[Void] {
logger := cb(ctx)
return func() struct{} {
return func() Void {
logger.LogAttrs(ctx, logLevel, message, a)
return struct{}{}
return F.VOID
}
})
}
@@ -571,7 +571,7 @@ func curriedLog(
// - Conditional logging: Enable/disable logging based on logger configuration
func SLogWithCallback[A any](
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
cb Reader[context.Context, *slog.Logger],
message string) Kleisli[Result[A], A] {
return F.Pipe1(
@@ -582,18 +582,23 @@ func SLogWithCallback[A any](
curriedLog(logLevel, cb, message),
),
// preserve the original context
reader.Chain(reader.Sequence(readerio.MapTo[struct{}, Result[A]])),
reader.Chain(reader.Sequence(readerio.MapTo[Void, Result[A]])),
)
}
// SLog creates a Kleisli arrow that logs a Result value (success or error) with a message.
//
// This function logs both successful values and errors at Info level using the logger from the context.
// This function logs both successful values and errors at Info level. It retrieves the logger
// using logging.GetLoggerFromContext, which returns either:
// - The logger stored in the context via logging.WithLogger, or
// - The global logger (set via logging.SetLogger or slog.Default())
//
// It's a convenience wrapper around SLogWithCallback with standard settings.
//
// The logged output includes:
// - For success: The message with the value as a structured "value" attribute
// - For error: The message with the error as a structured "error" attribute
// The message parameter becomes the main log message text, and the Result value or error
// is attached as a structured logging attribute:
// - For success: Logs message with attribute value=<the actual value>
// - For error: Logs message with attribute error=<the error>
//
// The Result is passed through unchanged after logging, making this function transparent in the
// computation pipeline.
@@ -647,25 +652,47 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)
}
// TapSLog creates an operator that logs only successful values with a message and passes them through unchanged.
// TapSLog creates an operator that logs both successful values and errors with a message,
// and passes the ReaderIOResult through unchanged.
//
// This function is useful for debugging and monitoring values as they flow through a ReaderIOResult
// computation chain. Unlike SLog which logs both successes and errors, TapSLog only logs when the
// computation is successful. If the computation contains an error, no logging occurs and the error
// is propagated unchanged.
// computation chain. It logs both successful values and errors at Info level. It retrieves the logger
// using logging.GetLoggerFromContext, which returns either:
// - The logger stored in the context via logging.WithLogger, or
// - The global logger (set via logging.SetLogger or slog.Default())
//
// The logged output includes:
// - The provided message
// - The value being passed through (as a structured "value" attribute)
// The ReaderIOResult is returned unchanged after logging.
//
// The difference between TapSLog and SLog is their position in the pipeline:
// - SLog is a Kleisli[Result[A], A] used with Chain to intercept the raw Result
// - TapSLog is an Operator[A, A] used directly in a pipe on a ReaderIOResult[A]
//
// Both log the same information (success value or error), but TapSLog is more ergonomic
// when composing ReaderIOResult pipelines with F.Pipe.
//
// The message parameter becomes the main log message text, and the Result value or error
// is attached as a structured logging attribute:
// - For success: Logs message with attribute value=<the actual value>
// - For error: Logs message with attribute error=<the error>
//
// For example, TapSLog[User]("Fetched user") with a successful result produces:
//
// Log message: "Fetched user"
// Structured attribute: value={ID:123 Name:"Alice"}
//
// With an error result, it produces:
//
// Log message: "Fetched user"
// Structured attribute: error="user not found"
//
// Type Parameters:
// - A: The type of the value to log and pass through
// - A: The success type of the ReaderIOResult to log and pass through
//
// Parameters:
// - message: A descriptive message to include in the log entry
//
// Returns:
// - An Operator that logs successful values and returns them unchanged
// - An Operator that logs the Result (value or error) and returns the ReaderIOResult unchanged
//
// Example with simple value logging:
//
@@ -680,7 +707,7 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
// )
//
// result := pipeline(t.Context())()
// // Logs: "Fetched user" value={ID:123 Name:"Alice"}
// // If successful, logs: "Fetched user" value={ID:123 Name:"Alice"}
// // Returns: result.Of("Alice")
//
// Example in a processing pipeline:
@@ -695,36 +722,36 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
// )
//
// result := processOrder(t.Context())()
// // Logs each successful step with the intermediate values
// // If any step fails, subsequent TapSLog calls don't log
// // Logs each step with its value or error
//
// Example with error handling:
//
// pipeline := F.Pipe3(
// fetchData(id),
// TapSLog[Data]("Data fetched"),
// Chain(func(d Data) ReaderIOResult[Result] {
// Chain(func(d Data) ReaderIOResult[Data] {
// if d.IsValid() {
// return Of(processData(d))
// }
// return Left[Result](errors.New("invalid data"))
// return Left[Data](errors.New("invalid data"))
// }),
// TapSLog[Result]("Data processed"),
// TapSLog[Data]("Data processed"),
// )
//
// // If fetchData succeeds: logs "Data fetched" with the data
// // If processing succeeds: logs "Data processed" with the result
// // If processing fails: "Data processed" is NOT logged (error propagates)
// // If fetchData succeeds: logs "Data fetched" value={...}
// // If fetchData fails: logs "Data fetched" error="..."
// // If processing succeeds: logs "Data processed" value={...}
// // If processing fails: logs "Data processed" error="invalid data"
//
// Use Cases:
// - Debugging: Inspect intermediate successful values in a computation pipeline
// - Monitoring: Track successful data flow through complex operations
// - Troubleshooting: Identify where successful computations stop (last logged value before error)
// - Auditing: Log important successful values for compliance or security
// - Development: Understand data transformations during development
// - Debugging: Inspect intermediate values and errors in a computation pipeline
// - Monitoring: Track both successful and failed data flow through complex operations
// - Troubleshooting: Identify where errors are introduced or propagated
// - Auditing: Log important values and failures for compliance or security
// - Development: Understand data transformations and error paths during development
//
// Note: This function only logs successful values. Errors are silently propagated without logging.
// For logging both successes and errors, use SLog instead.
// Note: This function logs both successful values and errors. It is equivalent to SLog
// but expressed as an Operator for direct use in F.Pipe pipelines on ReaderIOResult values.
//
//go:inline
func TapSLog[A any](message string) Operator[A, A] {

View File

@@ -421,7 +421,7 @@ func TestEitherize1_Integration(t *testing.T) {
// Act
pipeline := F.Pipe1(
Of[TestConfig]("not-a-number"),
Chain[TestConfig](parseKleisli),
Chain(parseKleisli),
)
outcome := pipeline(testConfig)(context.Background())()

View File

@@ -4,7 +4,7 @@ go 1.24
require (
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.6.2
github.com/urfave/cli/v3 v3.7.0
)
require (

View File

@@ -6,6 +6,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -26,6 +26,83 @@ import (
"github.com/IBM/fp-go/v2/semigroup"
)
// Do creates the initial empty codec to be used as the starting point for
// do-notation style codec construction.
//
// This is the entry point for building up a struct codec field-by-field using
// the applicative and monadic sequencing operators ApSL, ApSO, and Bind.
// It wraps Empty and lifts a lazily-evaluated default Pair[O, A] into a
// Type[A, O, I] that ignores its input and always succeeds with the default value.
//
// # Type Parameters
//
// - I: The input type for decoding (what the codec reads from)
// - A: The target struct type being built up (what the codec decodes to)
// - O: The output type for encoding (what the codec writes to)
//
// # Parameters
//
// - e: A Lazy[Pair[O, A]] providing the initial default values:
// - pair.Head(e()): The default encoded output O (e.g. an empty monoid value)
// - pair.Tail(e()): The initial zero value of the struct A (e.g. MyStruct{})
//
// # Returns
//
// - A Type[A, O, I] that always decodes to the default A and encodes to the
// default O, regardless of input. This is then transformed by chaining
// ApSL, ApSO, or Bind operators to add fields one by one.
//
// # Example Usage
//
// Building a struct codec using do-notation style:
//
// import (
// "github.com/IBM/fp-go/v2/function"
// "github.com/IBM/fp-go/v2/lazy"
// "github.com/IBM/fp-go/v2/optics/codec"
// "github.com/IBM/fp-go/v2/optics/lens"
// "github.com/IBM/fp-go/v2/pair"
// S "github.com/IBM/fp-go/v2/string"
// )
//
// type Person struct {
// Name string
// Age int
// }
//
// nameLens := lens.MakeLens(
// func(p Person) string { return p.Name },
// func(p Person, name string) Person { p.Name = name; return p },
// )
// ageLens := lens.MakeLens(
// func(p Person) int { return p.Age },
// func(p Person, age int) Person { p.Age = age; return p },
// )
//
// personCodec := F.Pipe2(
// codec.Do[any, Person, string](lazy.Of(pair.MakePair("", Person{}))),
// codec.ApSL(S.Monoid, nameLens, codec.String()),
// codec.ApSL(S.Monoid, ageLens, codec.Int()),
// )
//
// # Notes
//
// - Do is typically the first call in a codec pipeline, followed by ApSL, ApSO, or Bind
// - The lazy pair should use the monoid's empty value for O and the zero value for A
// - For convenience, use Struct to create the initial codec for named struct types
//
// # See Also
//
// - Empty: The underlying codec constructor that Do delegates to
// - ApSL: Applicative sequencing for required struct fields via Lens
// - ApSO: Applicative sequencing for optional struct fields via Optional
// - Bind: Monadic sequencing for context-dependent field codecs
//
//go:inline
func Do[I, A, O any](e Lazy[Pair[O, A]]) Type[A, O, I] {
return Empty[I](e)
}
// ApSL creates an applicative sequencing operator for codecs using a lens.
//
// This function implements the "ApS" (Applicative Sequencing) pattern for codecs,
@@ -297,3 +374,132 @@ func ApSO[S, T, O, I any](
)
}
}
// Bind creates a monadic sequencing operator for codecs using a lens and a Kleisli arrow.
//
// This function implements the "Bind" (monadic bind / chain) pattern for codecs,
// allowing you to build up complex codecs where the codec for a field depends on
// the current decoded value of the struct. Unlike ApSL which uses a fixed field
// codec, Bind accepts a Kleisli arrow — a function from the current struct value S
// to a Type[T, O, I] — enabling context-sensitive codec construction.
//
// The function combines:
// - Encoding: Evaluates the Kleisli arrow f on the current struct value s to obtain
// the field codec, extracts the field T using the lens, encodes it with that codec,
// and combines it with the base encoding using the monoid.
// - Validation: Validates the base struct first (monadic sequencing), then uses the
// Kleisli arrow to obtain the field codec for the decoded struct value, and validates
// the field through the lens. Errors are propagated but NOT accumulated (fail-fast
// semantics, unlike ApSL which accumulates errors).
//
// # Type Parameters
//
// - S: The source struct type (what we're building a codec for)
// - T: The field type accessed by the lens
// - O: The output type for encoding (must have a monoid)
// - I: The input type for decoding
//
// # Parameters
//
// - m: A Monoid[O] for combining encoded outputs
// - l: A Lens[S, T] that focuses on a specific field in S
// - f: A Kleisli[S, T, O, I] — a function from S to Type[T, O, I] — that produces
// the field codec based on the current struct value
//
// # Returns
//
// An Operator[S, S, O, I] that transforms a base codec by adding the field
// specified by the lens, where the field codec is determined by the Kleisli arrow.
//
// # How It Works
//
// 1. **Encoding**: When encoding a value of type S:
// - Evaluate f(s) to obtain the field codec fa
// - Extract the field T using l.Get
// - Encode T to O using fa.Encode
// - Combine with the base encoding using the monoid
//
// 2. **Validation**: When validating input I:
// - Run the base validation to obtain a decoded S (fail-fast: stop on base failure)
// - For the decoded S, evaluate f(s) to obtain the field codec fa
// - Validate the input I using fa.Validate
// - Set the validated T into S using l.Set
//
// 3. **Type Checking**: Preserves the base type checker
//
// # Difference from ApSL
//
// Unlike ApSL which uses a fixed field codec:
// - ApSL: Field codec is fixed at construction time; errors are accumulated
// - Bind: Field codec depends on the current struct value (Kleisli arrow); validation
// uses monadic sequencing (fail-fast on base failure)
// - Bind is more powerful but less parallel than ApSL
//
// # Example
//
// import (
// "github.com/IBM/fp-go/v2/optics/codec"
// "github.com/IBM/fp-go/v2/optics/lens"
// S "github.com/IBM/fp-go/v2/string"
// )
//
// type Config struct {
// Mode string
// Value int
// }
//
// modeLens := lens.MakeLens(
// func(c Config) string { return c.Mode },
// func(c Config, mode string) Config { c.Mode = mode; return c },
// )
//
// // Build a Config codec where the Value codec depends on the Mode
// configCodec := F.Pipe1(
// codec.Struct[Config]("Config"),
// codec.Bind(S.Monoid, modeLens, func(c Config) codec.Type[string, string, any] {
// return codec.String()
// }),
// )
//
// # Use Cases
//
// - Building codecs where a field's codec depends on another field's value
// - Implementing discriminated unions or tagged variants
// - Context-sensitive validation (e.g., validate field B differently based on field A)
// - Dependent type-like patterns in codec construction
//
// # Notes
//
// - The monoid determines how encoded outputs are combined
// - The lens must be total (handle all cases safely)
// - Validation uses monadic (fail-fast) sequencing: if the base codec fails,
// the Kleisli arrow is never evaluated
// - The name is automatically generated for debugging purposes
//
// See also:
// - ApSL: Applicative sequencing with a fixed lens codec (error accumulation)
// - Kleisli: The function type from S to Type[T, O, I]
// - validate.Bind: The underlying validate-level bind combinator
func Bind[S, T, O, I any](
m Monoid[O],
l Lens[S, T],
f Kleisli[S, T, O, I],
) Operator[S, S, O, I] {
name := fmt.Sprintf("Bind[%s]", l)
val := F.Curry2(Type[T, O, I].Validate)
return func(t Type[S, O, I]) Type[S, O, I] {
return MakeType(
name,
t.Is,
F.Pipe1(
t.Validate,
validate.Bind(l.Set, F.Flow2(f, val)),
),
func(s S) O {
return m.Concat(t.Encode(s), f(s).Encode(l.Get(s)))
},
)
}
}

View File

@@ -814,3 +814,588 @@ func TestApSO_ErrorAccumulation(t *testing.T) {
assert.NotEmpty(t, errors, "Should have validation errors")
})
}
// TestBind_EncodingCombination verifies that Bind combines the base encoding with
// the field encoding produced by the Kleisli arrow using the monoid.
func TestBind_EncodingCombination(t *testing.T) {
t.Run("combines base and field encodings using monoid", func(t *testing.T) {
// Lens for Person.Name
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
// Base codec encodes to "Person:"
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
if p, ok := i.(Person); ok {
return validation.ToResult(validation.Success(p))
}
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
if p, ok := i.(Person); ok {
return validation.Success(p)
}
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
}
},
func(p Person) string { return "Person:" },
)
// Kleisli arrow: always returns a string identity codec regardless of struct value
kleisli := func(p Person) Type[string, string, any] {
return MakeType(
"Name",
func(i any) validation.Result[string] {
if s, ok := i.(string); ok {
return validation.ToResult(validation.Success(s))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected string"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if s, ok := i.(string); ok {
return validation.Success(s)
}
return validation.FailureWithMessage[string](i, "expected string")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
person := Person{Name: "Alice", Age: 30}
encoded := enhancedCodec.Encode(person)
// Encoding should include both the base prefix and the field value
assert.Contains(t, encoded, "Person:")
assert.Contains(t, encoded, "Alice")
})
}
// TestBind_KleisliArrowReceivesCurrentValue verifies that the Kleisli arrow f
// receives the current struct value when producing the field codec.
func TestBind_KleisliArrowReceivesCurrentValue(t *testing.T) {
t.Run("kleisli arrow receives current struct value during encoding", func(t *testing.T) {
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
if p, ok := i.(Person); ok {
return validation.ToResult(validation.Success(p))
}
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
if p, ok := i.(Person); ok {
return validation.Success(p)
}
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
}
},
func(p Person) string { return "" },
)
// Kleisli arrow that uses the struct value to produce a prefix in the encoding
var capturedPerson Person
kleisli := func(p Person) Type[string, string, any] {
capturedPerson = p
return MakeType(
"Name",
func(i any) validation.Result[string] {
if s, ok := i.(string); ok {
return validation.ToResult(validation.Success(s))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected string"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if s, ok := i.(string); ok {
return validation.Success(s)
}
return validation.FailureWithMessage[string](i, "expected string")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
person := Person{Name: "Bob", Age: 25}
enhancedCodec.Encode(person)
// The Kleisli arrow should have been called with the actual struct value
assert.Equal(t, person, capturedPerson)
})
}
// TestBind_ValidationSuccess verifies that Bind correctly validates and decodes
// a struct when both the base and field validations succeed.
func TestBind_ValidationSuccess(t *testing.T) {
t.Run("succeeds when base and field validations pass", func(t *testing.T) {
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
if p, ok := i.(Person); ok {
return validation.ToResult(validation.Success(p))
}
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
if p, ok := i.(Person); ok {
return validation.Success(p)
}
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
}
},
func(p Person) string { return "" },
)
// The field codec receives the same input I (any = Person struct).
// It must extract the Name field from the Person input.
kleisli := func(p Person) Type[string, string, any] {
return MakeType(
"Name",
func(i any) validation.Result[string] {
if person, ok := i.(Person); ok {
return validation.ToResult(validation.Success(person.Name))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if person, ok := i.(Person); ok {
return validation.Success(person.Name)
}
return validation.FailureWithMessage[string](i, "expected Person")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
person := Person{Name: "Carol", Age: 28}
result := enhancedCodec.Decode(person)
assert.True(t, either.IsRight(result), "Should succeed when both validations pass")
})
}
// TestBind_ValidationFailsOnBaseFailure verifies that Bind uses fail-fast (monadic)
// semantics: if the base codec fails, the Kleisli arrow is never evaluated.
func TestBind_ValidationFailsOnBaseFailure(t *testing.T) {
t.Run("fails fast when base validation fails", func(t *testing.T) {
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
// Base codec always fails
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "base always fails"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
return validation.FailureWithMessage[Person](i, "base always fails")(ctx)
}
},
func(p Person) string { return "" },
)
kleisliCalled := false
kleisli := func(p Person) Type[string, string, any] {
kleisliCalled = true
return MakeType(
"Name",
func(i any) validation.Result[string] {
if s, ok := i.(string); ok {
return validation.ToResult(validation.Success(s))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected string"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if s, ok := i.(string); ok {
return validation.Success(s)
}
return validation.FailureWithMessage[string](i, "expected string")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
person := Person{Name: "Dave", Age: 40}
result := enhancedCodec.Decode(person)
assert.True(t, either.IsLeft(result), "Should fail when base validation fails")
assert.False(t, kleisliCalled, "Kleisli arrow should NOT be called when base fails")
})
}
// TestBind_ValidationFailsOnFieldFailure verifies that Bind propagates field
// validation errors when the Kleisli arrow's codec fails.
func TestBind_ValidationFailsOnFieldFailure(t *testing.T) {
t.Run("fails when field validation from kleisli codec fails", func(t *testing.T) {
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
// Base codec succeeds
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
if p, ok := i.(Person); ok {
return validation.ToResult(validation.Success(p))
}
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
if p, ok := i.(Person); ok {
return validation.Success(p)
}
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
}
},
func(p Person) string { return "" },
)
// Kleisli arrow returns a codec that always fails regardless of input
kleisli := func(p Person) Type[string, string, any] {
return MakeType(
"Name",
func(i any) validation.Result[string] {
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "field always fails"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
return validation.FailureWithMessage[string](i, "field always fails")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
// The field codec receives the same input (Person) and always fails
person := Person{Name: "Eve", Age: 22}
result := enhancedCodec.Decode(person)
assert.True(t, either.IsLeft(result), "Should fail when field validation fails")
errors := either.MonadFold(result,
F.Identity[validation.Errors],
func(Person) validation.Errors { return nil },
)
assert.NotEmpty(t, errors, "Should have validation errors from field codec")
})
}
// TestBind_TypeCheckingPreserved verifies that Bind preserves the base type checker.
func TestBind_TypeCheckingPreserved(t *testing.T) {
t.Run("preserves base type checker", func(t *testing.T) {
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
if p, ok := i.(Person); ok {
return validation.ToResult(validation.Success(p))
}
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
if p, ok := i.(Person); ok {
return validation.Success(p)
}
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
}
},
func(p Person) string { return "" },
)
kleisli := func(p Person) Type[string, string, any] {
return MakeType(
"Name",
func(i any) validation.Result[string] {
if s, ok := i.(string); ok {
return validation.ToResult(validation.Success(s))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected string"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if s, ok := i.(string); ok {
return validation.Success(s)
}
return validation.FailureWithMessage[string](i, "expected string")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
// Valid type
person := Person{Name: "Frank", Age: 35}
isResult := enhancedCodec.Is(person)
assert.True(t, either.IsRight(isResult), "Should accept Person type")
// Invalid type
invalidResult := enhancedCodec.Is("not a person")
assert.True(t, either.IsLeft(invalidResult), "Should reject non-Person type")
})
}
// TestBind_Naming verifies that Bind generates a descriptive name for the codec.
func TestBind_Naming(t *testing.T) {
t.Run("generates descriptive name containing Bind and lens info", func(t *testing.T) {
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
if p, ok := i.(Person); ok {
return validation.ToResult(validation.Success(p))
}
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
if p, ok := i.(Person); ok {
return validation.Success(p)
}
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
}
},
func(p Person) string { return "" },
)
kleisli := func(p Person) Type[string, string, any] {
return MakeType(
"Name",
func(i any) validation.Result[string] {
if s, ok := i.(string); ok {
return validation.ToResult(validation.Success(s))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected string"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if s, ok := i.(string); ok {
return validation.Success(s)
}
return validation.FailureWithMessage[string](i, "expected string")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
name := enhancedCodec.Name()
assert.Contains(t, name, "Bind", "Name should contain 'Bind'")
})
}
// TestBind_DependentFieldCodec verifies that the Kleisli arrow can produce
// different codecs based on the current struct value (the key differentiator
// from ApSL).
//
// The field codec Type[T, O, I] receives the same input I as the base codec.
// It must extract the field value from that input. The Kleisli arrow f(s)
// produces a different codec depending on the already-decoded struct value s.
func TestBind_DependentFieldCodec(t *testing.T) {
t.Run("kleisli arrow produces different codecs based on struct value", func(t *testing.T) {
// Lens for Person.Name
nameLens := lens.MakeLens(
func(p Person) string { return p.Name },
func(p Person, name string) Person {
return Person{Name: name, Age: p.Age}
},
)
// Base codec succeeds for any Person
baseCodec := MakeType(
"Person",
func(i any) validation.Result[Person] {
if p, ok := i.(Person); ok {
return validation.ToResult(validation.Success(p))
}
return validation.ToResult(validation.Failures[Person](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, Person] {
return func(ctx Context) validation.Validation[Person] {
if p, ok := i.(Person); ok {
return validation.Success(p)
}
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
}
},
func(p Person) string { return "" },
)
// Kleisli arrow: the field codec receives the same input I (any = Person).
// It extracts the Name from the Person input.
// If the decoded struct's Age > 18, accept any name (including empty).
// If Age <= 18, reject empty names.
kleisli := func(p Person) Type[string, string, any] {
if p.Age > 18 {
// Adult: accept any name extracted from the Person input
return MakeType(
"AnyName",
func(i any) validation.Result[string] {
if person, ok := i.(Person); ok {
return validation.ToResult(validation.Success(person.Name))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if person, ok := i.(Person); ok {
return validation.Success(person.Name)
}
return validation.FailureWithMessage[string](i, "expected Person")(ctx)
}
},
F.Identity[string],
)
}
// Minor: reject empty names
return MakeType(
"NonEmptyName",
func(i any) validation.Result[string] {
if person, ok := i.(Person); ok {
if person.Name != "" {
return validation.ToResult(validation.Success(person.Name))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: person.Name, Messsage: "name must not be empty for minors"},
}))
}
return validation.ToResult(validation.Failures[string](validation.Errors{
&validation.ValidationError{Value: i, Messsage: "expected Person"},
}))
},
func(i any) Decode[Context, string] {
return func(ctx Context) validation.Validation[string] {
if person, ok := i.(Person); ok {
if person.Name != "" {
return validation.Success(person.Name)
}
return validation.FailureWithMessage[string](person.Name, "name must not be empty for minors")(ctx)
}
return validation.FailureWithMessage[string](i, "expected Person")(ctx)
}
},
F.Identity[string],
)
}
operator := Bind(S.Monoid, nameLens, kleisli)
enhancedCodec := operator(baseCodec)
// Adult (Age=30) with empty name: should succeed (adult codec accepts any name)
adultPerson := Person{Name: "", Age: 30}
adultResult := enhancedCodec.Decode(adultPerson)
assert.True(t, either.IsRight(adultResult), "Adult should accept empty name")
// Minor (Age=15) with empty name: should fail (minor codec rejects empty names)
minorPerson := Person{Name: "", Age: 15}
minorResult := enhancedCodec.Decode(minorPerson)
assert.True(t, either.IsLeft(minorResult), "Minor with empty name should fail")
// Minor (Age=15) with non-empty name: should succeed
minorWithName := Person{Name: "Junior", Age: 15}
minorWithNameResult := enhancedCodec.Decode(minorWithName)
assert.True(t, either.IsRight(minorWithNameResult), "Minor with non-empty name should succeed")
})
}

View File

@@ -101,7 +101,7 @@ func (t *typeImpl[A, O, I]) Is(i any) Result[A] {
// stringToInt := codec.MakeType(...) // Type[int, string, string]
// intToPositive := codec.MakeType(...) // Type[PositiveInt, int, int]
// composed := codec.Pipe(intToPositive)(stringToInt) // Type[PositiveInt, string, string]
func Pipe[O, I, A, B any](ab Type[B, A, A]) func(Type[A, O, I]) Type[B, O, I] {
func Pipe[O, I, A, B any](ab Type[B, A, A]) Operator[A, B, O, I] {
return func(this Type[A, O, I]) Type[B, O, I] {
return MakeType(
fmt.Sprintf("Pipe(%s, %s)", this.Name(), ab.Name()),
@@ -851,7 +851,7 @@ func FromRefinement[A, B any](refinement Refinement[A, B]) Type[B, A, A] {
// See also:
// - Id: For identity codecs that preserve values
// - MakeType: For creating custom codecs with validation logic
func Empty[A, O, I any](e Lazy[Pair[O, A]]) Type[A, O, I] {
func Empty[I, A, O any](e Lazy[Pair[O, A]]) Type[A, O, I] {
return MakeType(
"Empty",
Is[A](),

View File

@@ -1742,7 +1742,7 @@ func TestFromRefinementValidationContext(t *testing.T) {
// TestEmpty_Success tests that Empty always succeeds during decoding
func TestEmpty_Success(t *testing.T) {
t.Run("decodes any input to default value", func(t *testing.T) {
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default", 42)))
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default", 42)))
// Test with various input types
testCases := []struct {
@@ -1766,7 +1766,7 @@ func TestEmpty_Success(t *testing.T) {
})
t.Run("always returns same default value", func(t *testing.T) {
defaultCodec := Empty[string, string, any](lazy.Of(pair.MakePair("output", "default")))
defaultCodec := Empty[any, string, string](lazy.Of(pair.MakePair("output", "default")))
result1 := defaultCodec.Decode(123)
result2 := defaultCodec.Decode("different")
@@ -1789,7 +1789,7 @@ func TestEmpty_Success(t *testing.T) {
// TestEmpty_Encoding tests that Empty always uses default output during encoding
func TestEmpty_Encoding(t *testing.T) {
t.Run("encodes any value to default output", func(t *testing.T) {
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default", 42)))
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default", 42)))
// Test with various input values
testCases := []struct {
@@ -1811,7 +1811,7 @@ func TestEmpty_Encoding(t *testing.T) {
})
t.Run("always returns same default output", func(t *testing.T) {
defaultCodec := Empty[string, int, any](lazy.Of(pair.MakePair(999, "ignored")))
defaultCodec := Empty[any, string, int](lazy.Of(pair.MakePair(999, "ignored")))
encoded1 := defaultCodec.Encode("value1")
encoded2 := defaultCodec.Encode("value2")
@@ -1826,7 +1826,7 @@ func TestEmpty_Encoding(t *testing.T) {
// TestEmpty_Name tests that Empty has correct name
func TestEmpty_Name(t *testing.T) {
t.Run("has name 'Empty'", func(t *testing.T) {
defaultCodec := Empty[int, int, any](lazy.Of(pair.MakePair(0, 0)))
defaultCodec := Empty[any, int, int](lazy.Of(pair.MakePair(0, 0)))
assert.Equal(t, "Empty", defaultCodec.Name())
})
}
@@ -1834,7 +1834,7 @@ func TestEmpty_Name(t *testing.T) {
// TestEmpty_TypeChecking tests that Empty performs standard type checking
func TestEmpty_TypeChecking(t *testing.T) {
t.Run("Is checks for correct type", func(t *testing.T) {
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default", 42)))
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default", 42)))
// Should succeed for int
result := defaultCodec.Is(100)
@@ -1846,7 +1846,7 @@ func TestEmpty_TypeChecking(t *testing.T) {
})
t.Run("Is checks for string type", func(t *testing.T) {
defaultCodec := Empty[string, string, any](lazy.Of(pair.MakePair("out", "in")))
defaultCodec := Empty[any, string, string](lazy.Of(pair.MakePair("out", "in")))
// Should succeed for string
result := defaultCodec.Is("hello")
@@ -1867,7 +1867,7 @@ func TestEmpty_LazyEvaluation(t *testing.T) {
return pair.MakePair(counter, counter*10)
}
defaultCodec := Empty[int, int, any](lazyPair)
defaultCodec := Empty[any, int, int](lazyPair)
// Each decode can get a different value if the lazy function is dynamic
result1 := defaultCodec.Decode("input1")
@@ -1897,7 +1897,7 @@ func TestEmpty_WithStructs(t *testing.T) {
t.Run("provides default struct value", func(t *testing.T) {
defaultConfig := Config{Timeout: 30, Retries: 3}
defaultCodec := Empty[Config, Config, any](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
defaultCodec := Empty[any, Config, Config](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
result := defaultCodec.Decode("anything")
assert.True(t, either.IsRight(result))
@@ -1914,7 +1914,7 @@ func TestEmpty_WithStructs(t *testing.T) {
defaultConfig := Config{Timeout: 30, Retries: 3}
inputConfig := Config{Timeout: 60, Retries: 5}
defaultCodec := Empty[Config, Config, any](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
defaultCodec := Empty[any, Config, Config](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
encoded := defaultCodec.Encode(inputConfig)
assert.Equal(t, 30, encoded.Timeout)
@@ -1926,7 +1926,7 @@ func TestEmpty_WithStructs(t *testing.T) {
func TestEmpty_WithPointers(t *testing.T) {
t.Run("provides default pointer value", func(t *testing.T) {
defaultValue := 42
defaultCodec := Empty[*int, *int, any](lazy.Of(pair.MakePair(&defaultValue, &defaultValue)))
defaultCodec := Empty[any, *int, *int](lazy.Of(pair.MakePair(&defaultValue, &defaultValue)))
result := defaultCodec.Decode("anything")
assert.True(t, either.IsRight(result))
@@ -1941,7 +1941,7 @@ func TestEmpty_WithPointers(t *testing.T) {
t.Run("provides nil pointer as default", func(t *testing.T) {
var nilPtr *int
defaultCodec := Empty[*int, *int, any](lazy.Of(pair.MakePair(nilPtr, nilPtr)))
defaultCodec := Empty[any, *int, *int](lazy.Of(pair.MakePair(nilPtr, nilPtr)))
result := defaultCodec.Decode("anything")
assert.True(t, either.IsRight(result))
@@ -1958,7 +1958,7 @@ func TestEmpty_WithPointers(t *testing.T) {
func TestEmpty_WithSlices(t *testing.T) {
t.Run("provides default slice value", func(t *testing.T) {
defaultSlice := []int{1, 2, 3}
defaultCodec := Empty[[]int, []int, any](lazy.Of(pair.MakePair(defaultSlice, defaultSlice)))
defaultCodec := Empty[any, []int, []int](lazy.Of(pair.MakePair(defaultSlice, defaultSlice)))
result := defaultCodec.Decode("anything")
assert.True(t, either.IsRight(result))
@@ -1972,7 +1972,7 @@ func TestEmpty_WithSlices(t *testing.T) {
t.Run("provides empty slice as default", func(t *testing.T) {
emptySlice := []int{}
defaultCodec := Empty[[]int, []int, any](lazy.Of(pair.MakePair(emptySlice, emptySlice)))
defaultCodec := Empty[any, []int, []int](lazy.Of(pair.MakePair(emptySlice, emptySlice)))
result := defaultCodec.Decode("anything")
assert.True(t, either.IsRight(result))
@@ -1988,7 +1988,7 @@ func TestEmpty_WithSlices(t *testing.T) {
// TestEmpty_DifferentInputOutput tests Empty with different input and output types
func TestEmpty_DifferentInputOutput(t *testing.T) {
t.Run("decodes to int, encodes to string", func(t *testing.T) {
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default-output", 42)))
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default-output", 42)))
// Decode always returns 42
result := defaultCodec.Decode("any input")
@@ -2000,7 +2000,7 @@ func TestEmpty_DifferentInputOutput(t *testing.T) {
})
t.Run("decodes to string, encodes to int", func(t *testing.T) {
defaultCodec := Empty[string, int, any](lazy.Of(pair.MakePair(999, "default-value")))
defaultCodec := Empty[any, string, int](lazy.Of(pair.MakePair(999, "default-value")))
// Decode always returns "default-value"
result := defaultCodec.Decode(123)
@@ -2020,7 +2020,7 @@ func TestEmpty_DifferentInputOutput(t *testing.T) {
// TestEmpty_EdgeCases tests edge cases for Empty
func TestEmpty_EdgeCases(t *testing.T) {
t.Run("with zero values", func(t *testing.T) {
defaultCodec := Empty[int, int, any](lazy.Of(pair.MakePair(0, 0)))
defaultCodec := Empty[any, int, int](lazy.Of(pair.MakePair(0, 0)))
result := defaultCodec.Decode("anything")
assert.True(t, either.IsRight(result))
@@ -2035,7 +2035,7 @@ func TestEmpty_EdgeCases(t *testing.T) {
})
t.Run("with empty string", func(t *testing.T) {
defaultCodec := Empty[string, string, any](lazy.Of(pair.MakePair("", "")))
defaultCodec := Empty[any, string, string](lazy.Of(pair.MakePair("", "")))
result := defaultCodec.Decode("non-empty")
assert.True(t, either.IsRight(result))
@@ -2050,7 +2050,7 @@ func TestEmpty_EdgeCases(t *testing.T) {
})
t.Run("with false boolean", func(t *testing.T) {
defaultCodec := Empty[bool, bool, any](lazy.Of(pair.MakePair(false, false)))
defaultCodec := Empty[any, bool, bool](lazy.Of(pair.MakePair(false, false)))
result := defaultCodec.Decode(true)
assert.Equal(t, validation.Of(false), result)
@@ -2064,7 +2064,7 @@ func TestEmpty_EdgeCases(t *testing.T) {
func TestEmpty_Integration(t *testing.T) {
t.Run("composes with other codecs using Pipe", func(t *testing.T) {
// Create a codec that always provides a default int
defaultIntCodec := Empty[int, int, any](lazy.Of(pair.MakePair(42, 42)))
defaultIntCodec := Empty[any, int, int](lazy.Of(pair.MakePair(42, 42)))
// Create a refinement that only accepts positive integers
positiveIntPrism := prism.MakePrismWithName(
@@ -2090,7 +2090,7 @@ func TestEmpty_Integration(t *testing.T) {
t.Run("used as placeholder in generic contexts", func(t *testing.T) {
// Empty can be used where a codec is required but not actually used
unitCodec := Empty[Void, Void, any](
unitCodec := Empty[any, Void, Void](
lazy.Of(pair.MakePair(F.VOID, F.VOID)),
)
@@ -2105,7 +2105,7 @@ func TestEmpty_Integration(t *testing.T) {
// TestEmpty_RoundTrip tests that Empty maintains consistency
func TestEmpty_RoundTrip(t *testing.T) {
t.Run("decode then encode returns default output", func(t *testing.T) {
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("output", 42)))
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("output", 42)))
// Decode
result := defaultCodec.Decode("input")
@@ -2124,7 +2124,7 @@ func TestEmpty_RoundTrip(t *testing.T) {
})
t.Run("multiple round trips are consistent", func(t *testing.T) {
defaultCodec := Empty[int, int, any](lazy.Of(pair.MakePair(100, 50)))
defaultCodec := Empty[any, int, int](lazy.Of(pair.MakePair(100, 50)))
// First round trip
result1 := defaultCodec.Decode("input1")

View File

@@ -22,15 +22,19 @@
package codec
import (
"encoding"
"encoding/json"
"net/url"
"regexp"
"strconv"
"time"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/optics/codec/validate"
"github.com/IBM/fp-go/v2/optics/codec/validation"
"github.com/IBM/fp-go/v2/optics/prism"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
)
// validateFromParser creates a validation function from a parser that may fail.
@@ -338,3 +342,183 @@ func Int64FromString() Type[int64, string, string] {
prism.ParseInt64().ReverseGet,
)
}
// BoolFromString creates a bidirectional codec for parsing boolean values from strings.
// This codec converts string representations of booleans to bool values and vice versa.
//
// The codec:
// - Decodes: Parses a string to a bool using strconv.ParseBool
// - Encodes: Converts a bool to its string representation using strconv.FormatBool
// - Validates: Ensures the string contains a valid boolean value
//
// The codec accepts the following string values (case-insensitive):
// - true: "1", "t", "T", "true", "TRUE", "True"
// - false: "0", "f", "F", "false", "FALSE", "False"
//
// Returns:
// - A Type[bool, string, string] codec that handles bool/string conversions
//
// Example:
//
// boolCodec := BoolFromString()
//
// // Decode valid boolean strings
// validation := boolCodec.Decode("true")
// // validation is Right(true)
//
// validation := boolCodec.Decode("1")
// // validation is Right(true)
//
// validation := boolCodec.Decode("false")
// // validation is Right(false)
//
// validation := boolCodec.Decode("0")
// // validation is Right(false)
//
// // Encode a boolean to string
// str := boolCodec.Encode(true)
// // str is "true"
//
// str := boolCodec.Encode(false)
// // str is "false"
//
// // Invalid boolean string fails validation
// validation := boolCodec.Decode("yes")
// // validation is Left(ValidationError{...})
//
// // Case variations are accepted
// validation := boolCodec.Decode("TRUE")
// // validation is Right(true)
func BoolFromString() Type[bool, string, string] {
return MakeType(
"BoolFromString",
Is[bool](),
validateFromParser(strconv.ParseBool),
strconv.FormatBool,
)
}
func decodeJSON[T any](dec json.Unmarshaler) ReaderResult[[]byte, T] {
return func(b []byte) Result[T] {
var t T
err := dec.UnmarshalJSON(b)
return result.TryCatchError(t, err)
}
}
func decodeText[T any](dec encoding.TextUnmarshaler) ReaderResult[[]byte, T] {
return func(b []byte) Result[T] {
var t T
err := dec.UnmarshalText(b)
return result.TryCatchError(t, err)
}
}
// MarshalText creates a bidirectional codec for types that implement encoding.TextMarshaler
// and encoding.TextUnmarshaler. This codec handles binary text serialization formats.
//
// The codec:
// - Decodes: Calls dec.UnmarshalText(b) to deserialize []byte into the target type T
// - Encodes: Calls enc.MarshalText() to serialize the value to []byte
// - Validates: Returns a failure if UnmarshalText returns an error
//
// Note: The enc and dec parameters are external marshaler/unmarshaler instances. The
// decoded value is the zero value of T after UnmarshalText has been called on dec
// (the caller is responsible for ensuring dec holds the decoded state).
//
// Type Parameters:
// - T: The Go type to encode/decode
//
// Parameters:
// - enc: An encoding.TextMarshaler used for encoding values to []byte
// - dec: An encoding.TextUnmarshaler used for decoding []byte to the target type
//
// Returns:
// - A Type[T, []byte, []byte] codec that handles text marshaling/unmarshaling
//
// Example:
//
// type MyType struct{ Value string }
//
// var instance MyType
// codec := MarshalText[MyType](instance, &instance)
//
// // Decode bytes to MyType
// result := codec.Decode([]byte(`some text`))
//
// // Encode MyType to bytes
// encoded := codec.Encode(instance)
func MarshalText[T any](
enc encoding.TextMarshaler,
dec encoding.TextUnmarshaler,
) Type[T, []byte, []byte] {
return MakeType(
"UnmarshalText",
Is[T](),
F.Pipe2(
dec,
decodeText[T],
validate.FromReaderResult,
),
func(t T) []byte {
b, _ := enc.MarshalText()
return b
},
)
}
// MarshalJSON creates a bidirectional codec for types that implement encoding/json's
// json.Marshaler and json.Unmarshaler interfaces. This codec handles JSON serialization.
//
// The codec:
// - Decodes: Calls dec.UnmarshalJSON(b) to deserialize []byte JSON into the target type T
// - Encodes: Calls enc.MarshalJSON() to serialize the value to []byte JSON
// - Validates: Returns a failure if UnmarshalJSON returns an error
//
// Note: The enc and dec parameters are external marshaler/unmarshaler instances. The
// decoded value is the zero value of T after UnmarshalJSON has been called on dec
// (the caller is responsible for ensuring dec holds the decoded state).
//
// Type Parameters:
// - T: The Go type to encode/decode
//
// Parameters:
// - enc: A json.Marshaler used for encoding values to JSON []byte
// - dec: A json.Unmarshaler used for decoding JSON []byte to the target type
//
// Returns:
// - A Type[T, []byte, []byte] codec that handles JSON marshaling/unmarshaling
//
// Example:
//
// type MyData struct {
// Name string `json:"name"`
// Value int `json:"value"`
// }
//
// var instance MyData
// codec := MarshalJSON[MyData](&instance, &instance)
//
// // Decode JSON bytes to MyData
// result := codec.Decode([]byte(`{"name":"test","value":42}`))
//
// // Encode MyData to JSON bytes
// encoded := codec.Encode(instance)
func MarshalJSON[T any](
enc json.Marshaler,
dec json.Unmarshaler,
) Type[T, []byte, []byte] {
return MakeType(
"UnmarshalJSON",
Is[T](),
F.Pipe2(
dec,
decodeJSON[T],
validate.FromReaderResult,
),
func(t T) []byte {
b, _ := enc.MarshalJSON()
return b
},
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -259,5 +259,42 @@ type (
// result := LetL(lens, double)(Success(21)) // Success(42)
Endomorphism[A any] = endomorphism.Endomorphism[A]
// Lazy represents a lazily-evaluated value of type A.
// This is an alias for lazy.Lazy[A], which defers computation until the value is needed.
//
// In the validation context, Lazy is used to defer expensive validation operations
// or to break circular dependencies in validation logic.
//
// Example:
//
// lazyValidation := lazy.Of(func() Validation[int] {
// // Expensive validation logic here
// return Success(42)
// })
// // Validation is not executed until lazyValidation() is called
Lazy[A any] = lazy.Lazy[A]
// ErrorsProvider is an interface for types that can provide a collection of errors.
// This interface allows validation errors to be extracted from various error types
// in a uniform way, supporting error aggregation and reporting.
//
// Types implementing this interface can be unwrapped to access their underlying
// error collection, enabling consistent error handling across different error types.
//
// Example:
//
// type MyErrors struct {
// errs []error
// }
//
// func (e *MyErrors) Errors() []error {
// return e.errs
// }
//
// // Usage
// var provider ErrorsProvider = &MyErrors{errs: []error{...}}
// allErrors := provider.Errors()
ErrorsProvider interface {
Errors() []error
}
)

View File

@@ -3,6 +3,7 @@ package validation
import (
"fmt"
"log/slog"
"strings"
A "github.com/IBM/fp-go/v2/array"
"github.com/IBM/fp-go/v2/either"
@@ -12,6 +13,11 @@ import (
// Returns a generic error message indicating this is a validation error.
// For detailed error information, use String() or Format() methods.
// toError converts the validation error to the error interface
func toError(v *ValidationError) error {
return v
}
// Error implements the error interface for ValidationError.
// Returns a generic error message.
func (v *ValidationError) Error() string {
@@ -34,44 +40,45 @@ func (v *ValidationError) String() string {
// It includes the context path, message, and optionally the cause error.
// Supports verbs: %s, %v, %+v (with additional details)
func (v *ValidationError) Format(s fmt.State, verb rune) {
// Build the context path
path := ""
for i, entry := range v.Context {
if i > 0 {
path += "."
}
if entry.Key != "" {
path += entry.Key
} else {
path += entry.Type
}
}
var result strings.Builder
// Start with the path if available
result := ""
if path != "" {
result = fmt.Sprintf("at %s: ", path)
// Build the context path
if len(v.Context) > 0 {
var path strings.Builder
for i, entry := range v.Context {
if i > 0 {
path.WriteString(".")
}
if entry.Key != "" {
path.WriteString(entry.Key)
} else {
path.WriteString(entry.Type)
}
}
result.WriteString("at ")
result.WriteString(path.String())
result.WriteString(": ")
}
// Add the message
result += v.Messsage
result.WriteString(v.Messsage)
// Add the cause if present
if v.Cause != nil {
if s.Flag('+') && verb == 'v' {
// Verbose format with detailed cause
result += fmt.Sprintf("\n caused by: %+v", v.Cause)
fmt.Fprintf(&result, "\n caused by: %+v", v.Cause)
} else {
result += fmt.Sprintf(" (caused by: %v)", v.Cause)
fmt.Fprintf(&result, " (caused by: %v)", v.Cause)
}
}
// Add value information for verbose format
if s.Flag('+') && verb == 'v' {
result += fmt.Sprintf("\n value: %#v", v.Value)
fmt.Fprintf(&result, "\n value: %#v", v.Value)
}
fmt.Fprint(s, result)
fmt.Fprint(s, result.String())
}
// LogValue implements the slog.LogValuer interface for ValidationError.
@@ -94,18 +101,18 @@ func (v *ValidationError) LogValue() slog.Value {
// Add context path if available
if len(v.Context) > 0 {
path := ""
var path strings.Builder
for i, entry := range v.Context {
if i > 0 {
path += "."
path.WriteString(".")
}
if entry.Key != "" {
path += entry.Key
path.WriteString(entry.Key)
} else {
path += entry.Type
path.WriteString(entry.Type)
}
}
attrs = append(attrs, slog.String("path", path))
attrs = append(attrs, slog.String("path", path.String()))
}
// Add cause if present
@@ -119,13 +126,14 @@ func (v *ValidationError) LogValue() slog.Value {
// Error implements the error interface for ValidationErrors.
// Returns a generic error message indicating validation errors occurred.
func (ve *validationErrors) Error() string {
if len(ve.errors) == 0 {
switch len(ve.errors) {
case 0:
return "ValidationErrors: no errors"
}
if len(ve.errors) == 1 {
case 1:
return "ValidationErrors: 1 error"
default:
return fmt.Sprintf("ValidationErrors: %d errors", len(ve.errors))
}
return fmt.Sprintf("ValidationErrors: %d errors", len(ve.errors))
}
// Unwrap returns the underlying cause error if present.
@@ -134,6 +142,33 @@ func (ve *validationErrors) Unwrap() error {
return ve.cause
}
// Errors implements the ErrorsProvider interface for validationErrors.
// It converts the internal collection of ValidationError pointers to a slice of error interfaces.
// This method enables uniform error extraction from validation error collections.
//
// The returned slice contains the same errors as the internal errors field,
// but typed as error interface values for compatibility with standard Go error handling.
//
// Returns:
// - A slice of error interfaces, one for each ValidationError in the collection
//
// Example:
//
// ve := &validationErrors{
// errors: Errors{
// &ValidationError{Messsage: "invalid email"},
// &ValidationError{Messsage: "age must be positive"},
// },
// }
// errs := ve.Errors()
// // errs is []error with 2 elements, each implementing the error interface
// for _, err := range errs {
// fmt.Println(err.Error()) // "ValidationError"
// }
func (ve *validationErrors) Errors() []error {
return A.MonadMap(ve.errors, toError)
}
// String returns a simple string representation of all validation errors.
// Each error is listed on a separate line with its index.
func (ve *validationErrors) String() string {
@@ -141,16 +176,17 @@ func (ve *validationErrors) String() string {
return "ValidationErrors: no errors"
}
result := fmt.Sprintf("ValidationErrors (%d):\n", len(ve.errors))
var result strings.Builder
fmt.Fprintf(&result, "ValidationErrors (%d):\n", len(ve.errors))
for i, err := range ve.errors {
result += fmt.Sprintf(" [%d] %s\n", i, err.String())
fmt.Fprintf(&result, " [%d] %s\n", i, err.String())
}
if ve.cause != nil {
result += fmt.Sprintf(" caused by: %v\n", ve.cause)
fmt.Fprintf(&result, " caused by: %v\n", ve.cause)
}
return result
return result.String()
}
// Format implements fmt.Formatter for custom formatting of ValidationErrors.

View File

@@ -846,3 +846,142 @@ func TestLogValuerInterface(t *testing.T) {
var _ slog.LogValuer = (*validationErrors)(nil)
})
}
// TestValidationErrors_Errors tests the Errors() method implementation
func TestValidationErrors_Errors(t *testing.T) {
t.Run("returns empty slice for no errors", func(t *testing.T) {
ve := &validationErrors{
errors: Errors{},
}
errs := ve.Errors()
assert.Empty(t, errs)
assert.NotNil(t, errs)
})
t.Run("converts single ValidationError to error interface", func(t *testing.T) {
ve := &validationErrors{
errors: Errors{
&ValidationError{Value: "test", Messsage: "invalid value"},
},
}
errs := ve.Errors()
require.Len(t, errs, 1)
assert.Equal(t, "ValidationError", errs[0].Error())
})
t.Run("converts multiple ValidationErrors to error interfaces", func(t *testing.T) {
ve := &validationErrors{
errors: Errors{
&ValidationError{Value: "test1", Messsage: "error 1"},
&ValidationError{Value: "test2", Messsage: "error 2"},
&ValidationError{Value: "test3", Messsage: "error 3"},
},
}
errs := ve.Errors()
require.Len(t, errs, 3)
for _, err := range errs {
assert.Equal(t, "ValidationError", err.Error())
}
})
t.Run("preserves error details in converted errors", func(t *testing.T) {
originalErr := &ValidationError{
Value: "abc",
Context: []ContextEntry{{Key: "field"}},
Messsage: "invalid format",
Cause: errors.New("parse error"),
}
ve := &validationErrors{
errors: Errors{originalErr},
}
errs := ve.Errors()
require.Len(t, errs, 1)
// Verify the error can be type-asserted back to ValidationError
validationErr, ok := errs[0].(*ValidationError)
require.True(t, ok)
assert.Equal(t, "abc", validationErr.Value)
assert.Equal(t, "invalid format", validationErr.Messsage)
assert.NotNil(t, validationErr.Cause)
assert.Len(t, validationErr.Context, 1)
})
t.Run("implements ErrorsProvider interface", func(t *testing.T) {
ve := &validationErrors{
errors: Errors{
&ValidationError{Messsage: "error 1"},
&ValidationError{Messsage: "error 2"},
},
}
// Verify it implements ErrorsProvider
var provider ErrorsProvider = ve
errs := provider.Errors()
assert.Len(t, errs, 2)
})
t.Run("returned errors are usable with standard error handling", func(t *testing.T) {
cause := errors.New("underlying error")
ve := &validationErrors{
errors: Errors{
&ValidationError{
Value: "test",
Messsage: "validation failed",
Cause: cause,
},
},
}
errs := ve.Errors()
require.Len(t, errs, 1)
// Test with errors.Is
assert.True(t, errors.Is(errs[0], cause))
// Test with errors.As
var validationErr *ValidationError
assert.True(t, errors.As(errs[0], &validationErr))
assert.Equal(t, "validation failed", validationErr.Messsage)
})
t.Run("does not modify original errors slice", func(t *testing.T) {
originalErrors := Errors{
&ValidationError{Value: "test1", Messsage: "error 1"},
&ValidationError{Value: "test2", Messsage: "error 2"},
}
ve := &validationErrors{
errors: originalErrors,
}
errs := ve.Errors()
require.Len(t, errs, 2)
// Original should be unchanged
assert.Len(t, ve.errors, 2)
assert.Equal(t, originalErrors, ve.errors)
})
t.Run("each error in slice is independent", func(t *testing.T) {
ve := &validationErrors{
errors: Errors{
&ValidationError{Value: "test1", Messsage: "error 1"},
&ValidationError{Value: "test2", Messsage: "error 2"},
},
}
errs := ve.Errors()
require.Len(t, errs, 2)
// Verify each error is distinct
err1, ok1 := errs[0].(*ValidationError)
err2, ok2 := errs[1].(*ValidationError)
require.True(t, ok1)
require.True(t, ok2)
assert.NotEqual(t, err1.Messsage, err2.Messsage)
assert.NotEqual(t, err1.Value, err2.Value)
})
}