25 KiB
Idiomatic vs Standard Package Comparison
Latest Update: 2025-11-18 - Updated with fresh benchmarks after
eitherpackage optimizations
This document provides a comprehensive comparison between the idiomatic packages and the standard fp-go packages (result and option).
See also: BENCHMARK_COMPARISON.md for detailed performance analysis.
Table of Contents
Overview
The fp-go library provides two approaches to functional programming patterns in Go:
- Standard Packages (
result,either,option): Use struct wrappers for algebraic data types - Idiomatic Packages (
idiomatic/result,idiomatic/option): Use native Go tuples for the same patterns
Key Insight
After recent optimizations to the either package, both approaches now offer excellent performance:
- Simple operations (~1-5 ns/op): Both packages perform comparably
- Core transformations: Idiomatic is 1.2-2.3x faster
- Complex operations: Idiomatic is 2-32x faster with significantly fewer allocations
- Real-world pipelines: Idiomatic shows 2-3.4x speedup
The idiomatic packages provide:
- Consistently better performance across most operations
- Zero allocations for complex operations (ChainFirst: 72 B → 0 B)
- More familiar Go idioms
- Seamless integration with existing Go code
Design Differences
Data Representation
Standard Result Package
// Uses Either[error, A] which is a struct wrapper
type Result[A any] = Either[error, A]
type Either[E, A any] struct {
r A
l E
isLeft bool
}
// Creating values - ZERO heap allocations (struct returned by value)
success := result.Right[error](42) // Returns Either struct by value (0 B/op)
failure := result.Left[int](err) // Returns Either struct by value (0 B/op)
// Benchmarks confirm:
// BenchmarkRight-16 871258489 1.384 ns/op 0 B/op 0 allocs/op
// BenchmarkLeft-16 683089270 1.761 ns/op 0 B/op 0 allocs/op
Idiomatic Result Package
// Uses native Go tuples (value, error)
type Kleisli[A, B any] = func(A) (B, error)
type Operator[A, B any] = func(A, error) (B, error)
// Creating values - ZERO allocations (tuples on stack)
success := result.Right(42) // Returns (42, nil) - 0 B/op
failure := result.Left[int](err) // Returns (0, err) - 0 B/op
// Benchmarks confirm:
// BenchmarkRight-16 789879016 1.427 ns/op 0 B/op 0 allocs/op
// BenchmarkLeft-16 895412131 1.349 ns/op 0 B/op 0 allocs/op
Type Signatures
Standard Result
// Functions take and return Result[T] structs
func Map[A, B any](f func(A) B) func(Result[A]) Result[B]
func Chain[A, B any](f Kleisli[A, B]) func(Result[A]) Result[B]
func Fold[A, B any](onLeft func(error) B, onRight func(A) B) func(Result[A]) B
// Usage requires wrapping/unwrapping
result := result.Right[error](42)
mapped := result.Map(double)(result)
value, err := result.UnwrapError(mapped)
Idiomatic Result
// Functions work directly with tuples
func Map[A, B any](f func(A) B) func(A, error) (B, error)
func Chain[A, B any](f Kleisli[A, B]) func(A, error) (B, error)
func Fold[A, B any](onLeft func(error) B, onRight func(A) B) func(A, error) B
// Usage works naturally with Go's error handling
value, err := result.Right(42)
value, err = result.Map(double)(value, err)
// Can use directly: if err != nil { ... }
Memory Layout
Standard Result (struct-based)
Either[error, int] struct (returned by value):
┌─────────────────────┐
│ r: int (8B) │ Stack allocation: 24 bytes
│ l: error (8B) │ NO heap allocation when returned by value
│ isLeft: bool (1B) │ Benchmarks show 0 B/op, 0 allocs/op
│ padding (7B) │
└─────────────────────┘
Key insight: Go returns small structs (<= ~64 bytes) by value on the stack.
The Either struct (24 bytes) does NOT escape to heap in normal usage.
Idiomatic Result (tuple-based)
(int, error) tuple:
┌─────────────────────┐
│ int: 8 bytes │ Stack allocation: 16 bytes
│ error: 8 bytes │ NO heap allocation
└─────────────────────┘
Both approaches achieve zero heap allocations for constructor operations!
Why Both Have Zero Allocations
Both packages avoid heap allocations for simple operations:
Standard Either/Result:
Eitherstruct is small (24 bytes)- Go returns by value on the stack
- Inlining eliminates function call overhead
- Result:
0 B/op, 0 allocs/op
Idiomatic Result:
- Tuples are native Go multi-value returns
- Always on stack, never heap
- Even simpler than structs
- Result:
0 B/op, 0 allocs/op
When Either WOULD escape to heap:
// Taking address of local Either
func bad1() *Either[error, int] {
e := Right[error](42)
return &e // ESCAPES: pointer to local
}
// Storing in interface
func bad2() interface{} {
return Right[error](42) // ESCAPES: interface boxing
}
// Closure capture with pointer receiver
func bad3() func() Either[error, int] {
e := Right[error](42)
return func() Either[error, int] {
return e // May escape depending on usage
}
}
In normal functional composition (Map, Chain, Fold), neither package causes heap allocations for simple operations.
Performance Comparison
Latest benchmarks: 2025-11-18 after
eitherpackage optimizationsFor detailed analysis, see BENCHMARK_COMPARISON.md
Quick Summary (Either vs Idiomatic)
Both packages now show excellent performance after optimizations:
| Category | Either | Idiomatic | Winner | Speedup |
|---|---|---|---|---|
| Constructors | 1.4-1.8 ns/op | 1.2-1.4 ns/op | TIE | ~1.0-1.3x |
| Predicates | 1.5 ns/op | 1.3-1.5 ns/op | TIE | ~1.0x |
| Map Operations | 4.2-7.2 ns/op | 2.5-4.3 ns/op | Idiomatic | 1.2-2.1x |
| Chain Operations | 4.4-5.4 ns/op | 2.3-2.5 ns/op | Idiomatic | 1.8-2.3x |
| ChainFirst | 87.6 ns/op (72 B) | 2.7 ns/op (0 B) | Idiomatic | 32.4x ✓✓✓ |
| BiMap | 11.5-16.8 ns/op | 3.5-3.8 ns/op | Idiomatic | 3.3-4.4x |
| Alt/OrElse | 4.0-5.7 ns/op | 2.4 ns/op | Idiomatic | 1.6-2.4x |
| GetOrElse | 6.3-9.0 ns/op | 1.5-2.1 ns/op | Idiomatic | 3.1-6.1x |
| Pipelines | 75-280 ns/op | 26-116 ns/op | Idiomatic | 2.4-3.4x |
Constructor Operations
| Operation | Either (ns/op) | Idiomatic (ns/op) | Speedup | Winner |
|---|---|---|---|---|
| Left | 1.76 | 1.35 | 1.3x | Idiomatic ✓ |
| Right | 1.38 | 1.43 | ~1.0x | Tie |
| Of | 1.68 | 1.22 | 1.4x | Idiomatic ✓ |
Analysis: After optimizations, both packages have comparable constructor performance.
Core Transformation Operations
| Operation | Either (ns/op) | Idiomatic (ns/op) | Speedup | Winner |
|---|---|---|---|---|
| Map (Right) | 5.13 | 4.34 | 1.2x | Idiomatic ✓ |
| Map (Left) | 4.19 | 2.48 | 1.7x | Idiomatic ✓ |
| MapLeft (Right) | 3.93 | 2.22 | 1.8x | Idiomatic ✓ |
| MapLeft (Left) | 7.22 | 3.51 | 2.1x | Idiomatic ✓ |
| Chain (Right) | 5.44 | 2.34 | 2.3x | Idiomatic ✓ |
| Chain (Left) | 4.44 | 2.53 | 1.8x | Idiomatic ✓ |
Complex Operations - The Big Difference
| Operation | Either (ns/op) | Idiomatic (ns/op) | Speedup | Either Allocs | Idio Allocs |
|---|---|---|---|---|---|
| ChainFirst (Right) | 87.62 | 2.71 | 32.4x ✓✓✓ | 72 B, 3 allocs | 0 B, 0 allocs |
| ChainFirst (Left) | 3.94 | 2.48 | 1.6x | 0 B | 0 B |
| BiMap (Right) | 16.79 | 3.82 | 4.4x | 0 B | 0 B |
| BiMap (Left) | 11.47 | 3.47 | 3.3x | 0 B | 0 B |
Critical Insight: ChainFirst shows the most dramatic difference - 32x faster with zero allocations in idiomatic.
Pipeline Benchmarks (Real-World Scenarios)
| Operation | Either (ns/op) | Idiomatic (ns/op) | Speedup | Either Allocs | Idio Allocs |
|---|---|---|---|---|---|
| Pipeline Map (Right) | 112.7 | 46.5 | 2.4x ✓ | 72 B, 3 allocs | 48 B, 2 allocs |
| Pipeline Chain (Right) | 74.4 | 26.1 | 2.9x ✓ | 48 B, 2 allocs | 24 B, 1 alloc |
| Pipeline Complex (Right) | 279.8 | 116.3 | 2.4x ✓ | 192 B, 8 allocs | 120 B, 5 allocs |
Analysis: In realistic composition scenarios, idiomatic is consistently 2-3x faster with fewer allocations.
Extraction Operations
| Operation | Either (ns/op) | Idiomatic (ns/op) | Speedup | Winner |
|---|---|---|---|---|
| GetOrElse (Right) | 9.01 | 1.49 | 6.1x ✓✓ | Idiomatic |
| GetOrElse (Left) | 6.35 | 2.08 | 3.1x ✓✓ | Idiomatic |
| Alt (Right) | 5.72 | 2.40 | 2.4x ✓ | Idiomatic |
| Alt (Left) | 4.89 | 2.39 | 2.0x ✓ | Idiomatic |
| Fold (Right) | 4.03 | 2.75 | 1.5x ✓ | Idiomatic |
| Fold (Left) | 3.69 | 2.40 | 1.5x ✓ | Idiomatic |
Analysis: Idiomatic shows significant advantages (1.5-6x) for value extraction operations.
Key Findings After Optimizations
- Both packages are now fast - Simple operations are in the 1-5 ns/op range for both
- Idiomatic leads in most operations - 1.2-2.3x faster for common transformations
- ChainFirst is the standout - 32x faster with zero allocations in idiomatic
- Pipelines favor idiomatic - 2-3.4x faster in realistic composition scenarios
- Memory efficiency - Idiomatic consistently uses fewer allocations
Performance Summary
Idiomatic Advantages:
- Core operations: 1.2-2.3x faster for Map, Chain, Fold
- Complex operations: 3-32x faster with zero allocations
- Pipelines: 2-3.4x faster with significantly fewer allocations
- Extraction: 1.5-6x faster for GetOrElse, Alt, Fold
- Consistency: Predictable, fast performance across all operations
Either Advantages:
- Comparable performance: After optimizations, matches idiomatic for simple operations
- Feature richness: More operations (Do-notation, Bind, Let, Flatten, Swap)
- Type flexibility: Full Either[E, A] with custom error types
- Zero allocations: Most simple operations have zero allocations
API Comparison
Creating Values
Standard Result
import "github.com/IBM/fp-go/v2/result"
// Create success/failure
success := result.Right[error](42)
failure := result.Left[int](errors.New("oops"))
// Type annotation required
var r result.Result[int] = result.Right[error](42)
Idiomatic Result
import "github.com/IBM/fp-go/v2/idiomatic/result"
// Create success/failure (more concise)
success := result.Right(42) // (42, nil)
failure := result.Left[int](errors.New("oops")) // (0, error)
// Native Go pattern
value, err := result.Right(42)
if err != nil {
// handle error
}
Transforming Values
Standard Result
// Map transforms the success value
double := result.Map(func(x int) int { return x * 2 })
result := double(result.Right[error](21)) // Right(42)
// Chain sequences operations
validate := result.Chain(func(x int) result.Result[int] {
if x > 0 {
return result.Right[error](x * 2)
}
return result.Left[int](errors.New("negative"))
})
Idiomatic Result
// Map transforms the success value
double := result.Map(func(x int) int { return x * 2 })
value, err := double(21, nil) // (42, nil)
// Chain sequences operations
validate := result.Chain(func(x int) (int, error) {
if x > 0 {
return x * 2, nil
}
return 0, errors.New("negative")
})
Pattern Matching
Standard Result
// Fold extracts the value
output := result.Fold(
func(err error) string { return "Error: " + err.Error() },
func(n int) string { return fmt.Sprintf("Value: %d", n) },
)(myResult)
// GetOrElse with default
value := result.GetOrElse(func(err error) int { return 0 })(myResult)
Idiomatic Result
// Fold extracts the value (same API, different input)
output := result.Fold(
func(err error) string { return "Error: " + err.Error() },
func(n int) string { return fmt.Sprintf("Value: %d", n) },
)(value, err)
// GetOrElse with default
value := result.GetOrElse(func(err error) int { return 0 })(value, err)
// Or use native Go pattern
if err != nil {
value = 0
}
Integration with Existing Code
Standard Result
// Converting from (value, error) to Result
func doSomething() (int, error) {
return 42, nil
}
result := result.TryCatchError(doSomething())
// Converting back to (value, error)
value, err := result.UnwrapError(result)
Idiomatic Result
// Direct compatibility with (value, error)
func doSomething() (int, error) {
return 42, nil
}
// No conversion needed!
value, err := doSomething()
value, err = result.Map(double)(value, err)
Pipeline Composition
Standard Result
import F "github.com/IBM/fp-go/v2/function"
output := F.Pipe3(
result.Right[error](10),
result.Map(double),
result.Chain(validate),
result.Map(format),
)
// Need to unwrap at the end
value, err := result.UnwrapError(output)
Idiomatic Result
import F "github.com/IBM/fp-go/v2/function"
value, err := F.Pipe3(
result.Right(10),
result.Map(double),
result.Chain(validate),
result.Map(format),
)
// Already in (value, error) form
if err != nil {
// handle error
}
Detailed Design Comparison
Type System
Standard Result
Strengths:
- Full algebraic data type semantics
- Explicit Either[E, A] allows custom error types
- Type-safe by construction
- Clear separation of error and success channels
Weaknesses:
- Requires wrapper structs (memory overhead)
- Less familiar to Go developers
- Needs conversion functions for Go's standard library
- More verbose type annotations
Idiomatic Result
Strengths:
- Native Go idioms (value, error) pattern
- Zero wrapper overhead
- Seamless stdlib integration
- Familiar to all Go developers
- Terser syntax
Weaknesses:
- Error type fixed to
error - Less explicit about Either semantics
- Cannot use custom error types without conversion
- Slightly less type-safe (can accidentally ignore bool/error)
Monad Laws
Both packages satisfy the monad laws, but enforce them differently:
Standard Result
// Left identity: return a >>= f ≡ f a
assert.Equal(
result.Chain(f)(result.Of(a)),
f(a),
)
// Right identity: m >>= return ≡ m
assert.Equal(
result.Chain(result.Of[int])(m),
m,
)
// Associativity: (m >>= f) >>= g ≡ m >>= (\x -> f x >>= g)
assert.Equal(
result.Chain(g)(result.Chain(f)(m)),
result.Chain(func(x int) result.Result[int] {
return result.Chain(g)(f(x))
})(m),
)
Idiomatic Result
// Same laws, different syntax
// Left identity
a, aerr := result.Of(val)
b, berr := result.Chain(f)(a, aerr)
c, cerr := f(val)
assert.Equal((b, berr), (c, cerr))
// Right identity
value, err := m()
identity := result.Chain(result.Of[int])
assert.Equal(identity(value, err), (value, err))
// Associativity (same structure, tuple-based)
Error Handling Philosophy
Standard Result
// Explicit error handling through types
func processUser(id int) result.Result[User] {
user := fetchUser(id) // Returns Result[User]
return F.Pipe2(
user,
result.Chain(validateUser),
result.Chain(enrichUser),
)
}
// Must explicitly unwrap
user, err := result.UnwrapError(processUser(42))
if err != nil {
log.Error(err)
}
Idiomatic Result
// Natural Go error handling
func processUser(id int) (User, error) {
user, err := fetchUser(id) // Returns (User, error)
return F.Pipe2(
(user, err),
result.Chain(validateUser),
result.Chain(enrichUser),
)
}
// Already in Go form
user, err := processUser(42)
if err != nil {
log.Error(err)
}
Composition Patterns
Standard Result
// Applicative composition
import A "github.com/IBM/fp-go/v2/apply"
type Config struct {
Host string
Port int
DB string
}
config := A.SequenceT3(
result.FromPredicate(validHost, hostError)(host),
result.FromPredicate(validPort, portError)(port),
result.FromPredicate(validDB, dbError)(db),
)(func(h string, p int, d string) Config {
return Config{h, p, d}
})
Idiomatic Result
// Direct tuple composition
config, err := func() (Config, error) {
host, err := result.FromPredicate(validHost, hostError)(host)
if err != nil {
return Config{}, err
}
port, err := result.FromPredicate(validPort, portError)(port)
if err != nil {
return Config{}, err
}
db, err := result.FromPredicate(validDB, dbError)(db)
if err != nil {
return Config{}, err
}
return Config{host, port, db}, nil
}()
When to Use Each
Use Idiomatic Result When (Recommended for Most Cases):
-
Performance Matters ⭐
- Any production service (web servers, APIs, microservices)
- Hot paths and high-throughput scenarios (>1000 req/s)
- Complex operation chains (32x faster ChainFirst)
- Real-world pipelines (2-3x faster)
- Memory-constrained environments (zero allocations)
- Want 1.2-6x speedup across most operations
-
Go Integration ⭐⭐
- Working with existing Go codebases
- Interfacing with standard library (native (value, error))
- Team familiar with Go, new to FP
- Want zero-cost functional abstractions
- Seamless error handling patterns
-
Pragmatic Functional Programming
- Value performance AND functional patterns
- Prefer Go idioms over FP terminology
- Simpler function signatures
- Lower cognitive overhead
- Production-ready patterns
-
Real-World Applications
- Web servers, REST APIs, gRPC services
- CLI tools and command-line applications
- Data processing pipelines
- Any latency-sensitive application
- Systems with tight performance budgets
Performance Gains: Use idiomatic for 1.2-32x speedup depending on operation, with consistently lower allocations.
Use Standard Either/Result When:
-
Type Safety & Flexibility
- Need explicit Either[E, A] with custom error types
- Building domain-specific error hierarchies
- Want to distinguish different error categories at type level
- Type system enforcement is critical
-
Advanced FP Features
- Using Do-notation for complex monadic compositions
- Need operations like Flatten, Swap, Bind, Let
- Leveraging advanced type classes (Semigroup, Monoid)
- Want the complete FP toolkit
-
FP Expertise & Education
- Porting code from other FP languages (Scala, Haskell)
- Teaching functional programming concepts
- Team has strong FP background
- Explicit algebraic data types preferred
- Code review benefits from FP terminology
-
Performance is Acceptable
- After optimizations, Either is quite fast (1-5 ns/op for simple operations)
- Difference matters mainly at high scale (millions of operations)
- Code clarity > micro-optimizations
- Simple operations dominate your workload
Note: Either package is now performant enough for most use cases. Choose it for features, not performance concerns.
Hybrid Approach
You can use both packages together:
import (
stdResult "github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/idiomatic/result"
)
// Use standard for complex types
type ValidationError struct {
Field string
Error string
}
func validateInput(input string) stdResult.Either[ValidationError, Input] {
// ... validation logic
}
// Convert to idiomatic for performance
func processInput(input string) (Output, error) {
validated := validateInput(input)
value, err := stdResult.UnwrapError(
stdResult.MapLeft(toError)(validated),
)
// Use idiomatic for hot path
return result.Chain(heavyProcessing)(value, err)
}
Migration Guide
From Standard to Idiomatic
// Before (standard)
import "github.com/IBM/fp-go/v2/result"
func process(x int) result.Result[int] {
return F.Pipe2(
result.Right[error](x),
result.Map(double),
result.Chain(validate),
)
}
// After (idiomatic)
import "github.com/IBM/fp-go/v2/idiomatic/result"
func process(x int) (int, error) {
return F.Pipe2(
result.Right(x),
result.Map(double),
result.Chain(validate),
)
}
Key Changes
- Type signatures:
Result[T]→(T, error) - Kleisli:
func(A) Result[B]→func(A) (B, error) - Operator:
func(Result[A]) Result[B]→func(A, error) (B, error) - Return values: Function calls return tuples, not wrapped values
- Pattern matching: Same Fold/GetOrElse API, different inputs
Conclusion
Performance Summary (After Either Optimizations)
The latest benchmark results show a clear pattern:
Both packages are now fast, but idiomatic consistently leads:
- Constructors & Predicates: Both ~1-2 ns/op (essentially tied)
- Core transformations: Idiomatic 1.2-2.3x faster (Map, Chain, Fold)
- Complex operations: Idiomatic 3-32x faster (BiMap, ChainFirst)
- Pipelines: Idiomatic 2-3.4x faster with fewer allocations
- Extraction: Idiomatic 1.5-6x faster (GetOrElse, Alt)
Key Insight: The idiomatic package delivers consistently better performance across the board while maintaining zero-cost abstractions. The Either package is now fast enough for most use cases, but idiomatic is the performance winner.
Updated Recommendation Matrix
| Scenario | Recommendation | Reason |
|---|---|---|
| New Go project | Idiomatic ⭐ | Natural Go patterns, 1.2-6x faster, better integration |
| Production services | Idiomatic ⭐⭐ | 2-3x faster pipelines, zero allocations, proven performance |
| Performance critical | Idiomatic ⭐⭐⭐ | 32x faster complex ops, minimal allocations |
| Microservices/APIs | Idiomatic ⭐⭐ | High throughput, familiar patterns, better performance |
| CLI Tools | Idiomatic ⭐ | Low overhead, Go idioms, fast |
| Custom error types | Standard/Either | Need Either[E, A] with domain types |
| Learning FP | Standard/Either | Clearer ADT semantics, educational |
| FP-heavy codebase | Standard/Either | Consistency, Do-notation, full FP toolkit |
| Library/Framework | Either way | Both are good; choose based on API style |
Real-World Impact
For a service handling 10,000 requests/second with typical pipeline operations:
Either package: 280 ns/op × 10M req/day = 2,800 seconds = 46.7 minutes
Idiomatic package: 116 ns/op × 10M req/day = 1,160 seconds = 19.3 minutes
Time saved: 27.4 minutes of CPU time per day
At scale, this translates to:
- Lower latency (2-3x faster response times for FP operations)
- Reduced CPU usage (fewer cores needed)
- Lower memory pressure (significantly fewer allocations)
- Better resource utilization
Final Recommendation
For most Go projects: Use idiomatic packages
- 1.2-32x faster across operations
- Native Go idioms
- Zero-cost abstractions
- Production-proven performance
- Easier integration
For specialized needs: Use standard Either/Result
- Need custom error types Either[E, A]
- Want Do-notation and advanced FP features
- Porting from FP languages
- Educational/learning context
- FP-heavy existing codebase
Bottom Line
After optimizations, both packages are excellent:
- Either/Result: Fast enough for most use cases, feature-rich, type-safe
- Idiomatic: Faster in practice (1.2-32x), native Go, zero-cost FP
The idiomatic packages now represent the best of both worlds: full functional programming capabilities with Go's native performance and idioms. Unless you specifically need Either[E, A]'s custom error types or advanced FP features, idiomatic is the recommended choice for production Go services.
Both maintain the core benefits of functional programming—choose based on whether you prioritize performance & Go integration (idiomatic) or type flexibility & FP features (either).