gofunctionalish

package module
v1.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 26, 2026 License: MIT Imports: 0 Imported by: 0

README

go-functionalish

CI Go Reference Contributing

A cohesive, opinionated, type-safe functional programming library for Go 1.24+. No reflection. No interface{}. Pure generics and lazy by default.

[!NOTE] Unapologetically vibe-coded with Claude Opus 4.6.

Unapologetically not "idiomatic Go."

Go gave us generics 17 years after C# and 18 after Java (the latter still erases them at runtime). We're using them for Option[T], Result[T,E], and lazy pipelines instead of if err != nil sixty times per file.

Packages

Package Description
seq Lazy Seq[T]: F#-style sequence pipelines
pseq Parallel Seq[T]: goroutine-per-chunk Map, Filter, Reduce, ...
option Option[T]: explicit presence/absence, no nil
result Result[T,E]: railway-oriented error handling
validation Validation[T,E]: applicative error accumulation
pipe Pipe2...Pipe8: F#-style |> operator equivalent
kv Lazy Seq2[K,V]: functional pipelines over iter.Seq2 / maps

Quick start

import (
"github.com/natalie-o-perret/go-functionalish/seq"
"github.com/natalie-o-perret/go-functionalish/pseq"
"github.com/natalie-o-perret/go-functionalish/option"
"github.com/natalie-o-perret/go-functionalish/result"
"github.com/natalie-o-perret/go-functionalish/validation"
"github.com/natalie-o-perret/go-functionalish/pipe"
"github.com/natalie-o-perret/go-functionalish/kv"
)
seq: lazy sequences
type Car struct { Year int; Owner, Model string }

cars := []Car{
{2012, "Alice", "Toyota"}, {2016, "Bob", "Honda"},
{2018, "Charlie", "Ford"}, {2015, "Diana", "BMW"},
}

// Filter + Map (Map is pkg-level due to Go type system)
owners := seq.Map(
seq.OfSlice(cars).Filter(func (c Car) bool { return c.Year >= 2015 }),
func (c Car) string { return c.Owner },
).ToSlice()
// => ["Bob", "Charlie", "Diana"]

// Sort, group, distinct
byCar := seq.GroupBy(seq.OfSlice(cars), func (c Car) string { return c.Model })

unique := seq.DistinctBy(
seq.OfSlice(cars).SortWith(func (a, b Car) int { return cmp.Compare(a.Model, b.Model) }),
func (c Car) string { return c.Model },
).ToSlice()

// Short-circuiting terminals
first := seq.OfSlice(cars).Filter(...).TryHead() // => option.Option[Car]
count := seq.OfSlice(cars).CountBy(func (c Car) bool { return c.Year >= 2015 })

// Generators
squares := seq.Map(seq.Range(1, 6), func (n int) int { return n * n }).ToSlice()
// => [1 4 9 16 25]

// RangeStep: custom step, supports descending
evens := seq.RangeStep(0, 10, 2).ToSlice() // => [0 2 4 6 8]
countdown := seq.RangeStep(5, 0, -1).ToSlice() // => [5 4 3 2 1]

// Zip / Interleave
pairs := seq.Zip(seq.OfSlice([]int{1, 2, 3}), seq.OfSlice([]string{"a", "b", "c"})).ToSlice()
// => [{1 a} {2 b} {3 c}]

merged := seq.Interleave(seq.OfSlice([]int{1, 3, 5}), seq.OfSlice([]int{2, 4, 6})).ToSlice()
// => [1 2 3 4 5 6]

// Cycle + Truncate (infinite sequences)
pattern := seq.OfSlice([]string{"ping", "pong"}).Cycle().Truncate(5).ToSlice()
// => ["ping", "pong", "ping", "pong", "ping"]

// Unfold: generate from a seed state (e.g. Fibonacci)
fibs := seq.Unfold([2]int{0, 1}, func (s [2]int) option.Option[seq.Pair[int, [2]int]] {
if s[0] > 20 {
return option.None[seq.Pair[int, [2]int]]()
}
return option.Some(seq.Pair[int, [2]int]{First: s[0], Second: [2]int{s[1], s[0] + s[1]}})
}).ToSlice()
// => [0 1 1 2 3 5 8 13]

// Partition: one pass, two slices
evens, odds := seq.Partition(seq.Range(1, 7), func (n int) bool { return n%2 == 0 })
// evens => [2 4 6],  odds => [1 3 5]

// OfMap: iterate over a map
counts := seq.CountByKey(seq.OfMap(map[string]int{"a": 1, "b": 2}),
func (p seq.Pair[string, int]) string { return p.First })
// => map[a:1 b:1]

// CountByKey: occurrence counts
freq := seq.CountByKey(seq.OfSlice([]string{"a", "b", "a", "c", "a", "b"}),
func (s string) string { return s })
// => map[a:3 b:2 c:1]

// OfOption: lift an Option into a Seq
seq.OfOption(option.Some(42)).ToSlice() // => [42]
seq.OfOption(option.None[int]()).ToSlice() // => []

// OfResult: lift a Result into a Seq (Ok => singleton, Err => empty)
seq.OfResult(result.Ok[int, string](7)).ToSlice()  // => [7]
seq.OfResult(result.Err[int, string]("e")).ToSlice() // => []

// Intersperse: insert separator between elements
seq.OfSlice([]int{1, 2, 3}).Intersperse(0).ToSlice() // => [1 0 2 0 3]

// StepBy: yield every n-th element starting from the first
seq.Range(0, 10).StepBy(3).ToSlice() // => [0 3 6 9]

// ToMap / ToMapBy: materialise into a map
m := seq.ToMap(seq.OfSlice([]seq.Pair[string, int]{{"a", 1}, {"b", 2}}))
// => map[a:1 b:2]

type Car struct { Year int; Owner, Model string }
byOwner := seq.ToMapBy(seq.OfSlice(cars),
    func(c Car) string { return c.Owner },
    func(c Car) int    { return c.Year },
)
// => map[Alice:2012 Bob:2016 ...]
pseq: parallel sequences

Parallel counterparts to the most parallelism-friendly seq operations, inspired by FSharp.Collections.ParallelSeq and Go's lo/lop parallel helpers.

Every function materialises the input Seq[T], partitions it into chunks, dispatches one goroutine per chunk, and collects results. Order is always preserved. Parallelism defaults to runtime.GOMAXPROCS(0) and is tunable via WithWorkers.

data := seq.OfSlice([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})

// Parallel Map (order preserved)
doubled := pseq.Map(data, func (n int) int { return n * 2 }).ToSlice()
// => [2 4 6 8 10 12 14 16 18 20]

// Parallel Filter
evens := pseq.Filter(data, func (n int) bool { return n%2 == 0 }).ToSlice()
// => [2 4 6 8 10]

// Parallel Reduce (fn must be associative)
sum, _ := pseq.Reduce(data, func (a, b int) int { return a + b })
// => 55

// Parallel GroupBy
groups := pseq.GroupBy(data, func (n int) string {
if n%2 == 0 { return "even" }
return "odd"
})
// => map[even:[2 4 6 8 10] odd:[1 3 5 7 9]]

// Configure workers
pseq.Map(data, heavyFn, pseq.WithWorkers(8))

// Parallel Exists / ForAll (short-circuit across goroutines)
pseq.Exists(data, func (n int) bool { return n > 9 }) // => true
pseq.ForAll(data, func (n int) bool { return n > 0 }) // => true

// Parallel Sum / SumBy
pseq.Sum(data) // => 55

// Parallel Partition
yes, no := pseq.Partition(data, func(n int) bool { return n <= 5 })
// yes => [1 2 3 4 5], no => [6 7 8 9 10]

// Parallel Choose (filter+map with Option)
pseq.Choose(data, func (n int) option.Option[string] {
if n%3 == 0 { return option.Some(fmt.Sprintf("fizz:%d", n)) }
return option.None[string]()
}).ToSlice()
// => ["fizz:3" "fizz:6" "fizz:9"]

// Pipe integration via curried helpers
pipe.Pipe3(
seq.OfSlice(bigData),
pseq.FilterFn(isValid, pseq.WithWorkers(8)),
pseq.MapFn(transform, pseq.WithWorkers(8)),
seq.ToSliceFn[Result](),
)

When to use pseq vs seq: parallel execution pays off when the per-element work is CPU-heavy (parsing, math, serialisation). For lightweight lambdas (n*2, field access), the goroutine overhead dominates, so stick with seq.

option: explicit optionality
// Instead of (T, bool) or *T
name := option.Some("Alice")
none := option.None[string]()

upper := option.Map(name, strings.ToUpper) // => Some("ALICE")
option.Map(none, strings.ToUpper) // => None

name.UnwrapOr("anonymous") // => "Alice"
none.UnwrapOr("anonymous") // => "anonymous"

// DefaultWith: lazy default - fn is only called when None
val := option.DefaultWith(none, func () string { return expensiveDefault() })

// Contains: value equality check
option.Contains(option.Some(42), 42) // => true
option.Contains(option.None[int](), 42) // => false

// Tee / TeeNone: side-effects without breaking the chain
opt := option.Tee(option.Some(42), func(v int) { log.Println("got", v) }) // => Some(42)
opt  = option.TeeNone(option.None[int](), func() { log.Println("missing") }) // => None

// Map2: combine two Options
option.Map2(option.Some(2), option.Some(3), func (a, b int) int { return a + b }) // => Some(5)
option.Map2(option.None[int](), option.Some(3), func (a, b int) int { return a + b }) // => None

// OrElse: fallback if None
resolved := option.OrElse(lookupCache(key), func () option.Option[string] {
return lookupDB(key)
})

// Flatten: unwrap Option[Option[T]]
option.Flatten(option.Some(option.Some(42))) // => Some(42)
option.Flatten(option.None[option.Option[int]]()) // => None

// Chain optional lookups with Bind
profile := option.Bind(findUser(id), func(u User) option.Option[Profile] {
return findProfile(u.ProfileID)
})

// Integrates with seq
firstModern := seq.OfSlice(cars).
Filter(func (c Car) bool { return c.Year >= 2015 }).
TryHead() // => option.Option[Car]
result : railway-oriented error handling
// Wrap Go's (T, error) convention
res := result.Try(func () (User, error) { return db.FindUser(id) })

// Railway pipeline: chain Bind (may fail) and Map (pure transforms).
// Once on the error track, every subsequent step is skipped.
r1 := parseRequest(raw) // step 1: Bind
r2 := result.Bind(r1, authenticate) // step 2: Bind
r3 := result.Map(r2, normalize)               // step 3: Map (pure)
r4 := result.Bind(r3, save) // step 4: Bind
r5 := result.Map(r4, formatResponse) // step 5: Map (pure)
// r5 is either Ok(response) or Err from whichever step failed first.

// Tee / TeeErr: side-effects without breaking the chain - great for logging
r := result.Tee(r3, func (v Request) { log.Printf("normalised: %v", v) })
r = result.TeeErr(r, func (e string) { log.Printf("failed: %s", e) })

// OrElse: try a fallback on Err
user := result.OrElse(lookupPrimary(id), func (e error) result.Result[User, error] {
return lookupReplica(id)
})

// Flatten: unwrap Result[Result[T,E],E]
result.Flatten(result.Ok[result.Result[int, string], string](result.Ok[int, string](42)))
// => Ok(42)

// Zip: combine two Results into a pair (first Err wins)
result.Zip(result.Ok[int, string](1), result.Ok[string, string]("hi"))
// => Ok({1, "hi"})

// Map2: combine two Results (short-circuits on first Err)
result.Map2(
    result.Ok[int, string](3),
    result.Ok[int, string](4),
    func(a, b int) int { return a + b },
) // => Ok(7)

// Contains: value equality check
result.Contains(result.Ok[int, string](42), 42) // => true

// Sequence: []Result => Result[[]T] (short-circuits on first Err)
result.Sequence([]result.Result[int, string]{
    result.Ok[int, string](1),
    result.Ok[int, string](2),
}) // => Ok([1 2])

// Traverse: map + sequence in one pass (short-circuits on first Err)
result.Traverse([]string{"1", "2", "3"}, func(s string) result.Result[int, string] {
    n, err := strconv.Atoi(s)
    if err != nil { return result.Err[int, string](err.Error()) }
    return result.Ok[int, string](n)
}) // => Ok([1 2 3])

// MapErr adds context to errors
wrapped := result.MapErr(r5, func (e string) string {
return "request failed: " + e
})

// Interop with option
opt := res.ToOption() // Ok => Some, Err => None
res2 := result.FromOption(opt, errors.New("not found"))
kv: key-value pipelines

Functional operations over iter.Seq2[K,V] - the lazy, composable counterpart to Go's maps package.

inventory := map[string]int{
    "apple": 50, "banana": 3, "cherry": 120, "date": 0,
}

// Of wraps a map into a lazy Seq2
s := kv.Of(inventory)

// Filter: keep only non-zero stock
inStock := kv.Filter(s, func(_ string, qty int) bool { return qty > 0 })

// MapValues: apply a discount
discounted := kv.MapValues(inStock, func(qty int) int { return qty * 9 / 10 })

// Collect: materialise back to a map
result := kv.Collect(discounted)
// => map[apple:45 cherry:108]

// Keys / Values: extract as seq.Seq
keys := kv.Keys(s).SortWith(cmp.Compare).ToSlice()
// => [apple banana cherry date]

// MapKeys: transform keys
upper := kv.Collect(kv.MapKeys(s, strings.ToUpper))
// => map[APPLE:50 BANANA:3 ...]

// Fold: reduce to a single value
total := kv.Fold(s, 0, func(acc int, _ string, qty int) int { return acc + qty })
// => 173

// ContainsKey: short-circuiting membership test
kv.ContainsKey(s, "apple") // => true
kv.ContainsKey(s, "mango") // => false

// ToSeq / FromSeq: bridge to seq.Seq[seq.Pair[K,V]]
pairs := kv.ToSeq(s).Filter(func(p seq.Pair[string, int]) bool { return p.Second > 10 }).ToSlice()
back  := kv.Collect(kv.FromSeq(seq.OfSlice(pairs)))

Design notes

Why are Map, Collect, GroupBy package-level functions?

Go does not allow methods to introduce new type parameters. A method on Seq[Car] cannot return Seq[string] because that would require func (s Seq[T]) Map[R any](fn func(T) R) Seq[R] : which the compiler rejects.

The workaround: type-transforming operations are package-level functions, same-type operations are methods:

//  method : stays Seq[Car]
OfSlice(cars).Filter(fn).SortWith(less).Truncate(10)

//  package-level : changes type
seq.Map(OfSlice(cars).Filter(fn), Car.Owner)
//      ^ sub-chain               ^ transform
Lazy vs eager

All pipeline operations (Filter, Map, Truncate, ...) are lazy : they wrap the previous iterator and produce no output until a terminal is called. Only SortWith, SortBy, Rev must materialise (you can't sort a stream you haven't fully read).

Why does pseq use chunking instead of per-element goroutines?

Spawning one goroutine per element (as lo/parallel does) is simple but scales poorly: 100k items = 100k goroutines = ~300k allocations and ~200–400 ms of pure scheduler overhead before any real work begins.

pseq splits the input into GOMAXPROCS chunks (default 8 on a typical machine) and runs one goroutine per chunk. This is the same strategy .NET's PLINQ uses under the hood (which F#'s PSeq wraps). It means:

  • 8 goroutines instead of 100k → ~300× fewer allocs
  • Each goroutine processes a contiguous slice → cache-friendly sequential access
  • Overhead is constant regardless of input size → O(workers), not O(n)
  • Users can tune it via WithWorkers(n) when the default doesn't fit

Performance

Direct method chaining vs pipe + compose

The *Fn curried helpers and pipe.Compose add thin closure wrappers around the same underlying methods. Benchmarks on a 10,000-element []int pipeline (Intel Core Ultra 7):

Pipeline Style ns/op B/op allocs
Small (filter => map => take 100) Direct ~1,780 2,064 9
Pipe ~1,620 2,064 9
Medium (7 steps incl. sort) Direct ~13,100 8,376 26
Pipe ~13,900 8,912 44
Large (10 steps incl. rev+sort) Direct ~27,100 12,784 46
Pipe ~30,400 13,592 73

~6-13 % wall-clock overhead, all from one-time closure allocations when the pipeline is built, not per element. The hot iteration loop is identical either way. For any real workload (I/O, serialisation, business logic in the lambdas) this is noise: choose whichever style reads better.

pseq vs lo/parallel

lo/parallel spawns one goroutine per element — simple, but O(n) scheduling overhead. pseq partitions into GOMAXPROCS chunks and runs one goroutine per chunk (the same strategy as .NET's PLINQ / F#'s PSeq).

Benchmarks on []int pipelines (Intel Core Ultra 7, 8 cores):

CPU-heavy workload (500 sqrt iterations per element)
Operation seq (sequential) pseq (chunked) lo/parallel (per-element) pseq vs lo
Map 1k 1,968 µs 717 µs 724 µs ≈ tied
Map 10k 19,243 µs 5,509 µs 6,815 µs 1.24× faster
Map 100k 192,687 µs 44,555 µs 57,882 µs 1.30× faster
ForEach 10k 328 µs 2,904 µs 8.9× faster
GroupBy 10k 4,587 µs 5,170 µs 1.13× faster
Lightweight workload (n*3+1 - exposes overhead)
Operation seq (sequential) pseq (chunked) lo/parallel (per-element) pseq vs lo
Map 1k 6 µs 25 µs 316 µs 12.7× faster
Map 10k 122 µs 357 µs 3,473 µs 9.7× faster
Map 100k 1,427 µs 3,635 µs 34,398 µs 9.5× faster
Memory (10k Map)
pseq lo/parallel ratio
B/op 798 KB 1,067 KB lo uses 1.3× more memory
allocs/op 66 20,051 lo allocates 303× more objects

Why? lo does go func(...) inside a for i, item := range, spawning 10k goroutines for 10k items. pseq splits into ~8 chunks. Goroutine spawn+schedule is ~2-4 µs each, so lo pays ~20-40 ms in scheduling alone for 10k items, while pseq pays ~16-32 µs. When the per-element work is heavy enough, both approaches saturate the CPUs and converge. When it isn't, lo is 10-13x slower than pseq, and even slower than sequential seq.

Rule of thumb: for lightweight lambdas, don't parallelize at all - use seq. For CPU-heavy work (parsing, crypto, compression, complex transforms), pseq gives the parallel speedup with a fraction of the scheduling cost.

Run the benchmarks yourself:

# seq pipeline benchmarks
go test ./seq/ -bench=. -benchmem

# pseq benchmarks (includes vs-lo comparison)
go test ./pseq/ -bench=. -benchmem

Dependency graph

pipe       =>  (none)
option     =>  (none)
result     =>  option
validation =>  option, result
seq        =>  option, result
pseq       =>  seq, option
kv         =>  seq

Documentation

Overview

Package gofunctionalish is a cohesive, type-safe functional programming library for Go 1.24+.

No reflection. No interface{}. Pure generics and lazy by default.

It provides seven packages:

Directories

Path Synopsis
Package kv provides functional operations over lazy key-value sequences (iter.Seq2[K,V]), complementing the seq package for map-shaped data.
Package kv provides functional operations over lazy key-value sequences (iter.Seq2[K,V]), complementing the seq package for map-shaped data.
Package option provides a type-safe Optional value inspired by F#'s Option type.
Package option provides a type-safe Optional value inspired by F#'s Option type.
Package pipe provides F#-style pipe operators for Go.
Package pipe provides F#-style pipe operators for Go.
Package pseq provides parallel counterparts to the most parallelism-friendly operations from github.com/natalie-o-perret/go-functionalish/seq.
Package pseq provides parallel counterparts to the most parallelism-friendly operations from github.com/natalie-o-perret/go-functionalish/seq.
Package result provides a type-safe Result type inspired by F# and Rust.
Package result provides a type-safe Result type inspired by F# and Rust.
Package seq provides a lazy, generic Seq[T] sequence type inspired by F# sequences.
Package seq provides a lazy, generic Seq[T] sequence type inspired by F# sequences.
Package tuple provides generic immutable tuple types T2, T3 and T4, inspired by F# native tuple syntax.
Package tuple provides generic immutable tuple types T2, T3 and T4, inspired by F# native tuple syntax.
Package validation provides a type-safe Validation type for applicative error accumulation.
Package validation provides a type-safe Validation type for applicative error accumulation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL