Go2Funk is a pet project exploring what Go generics make possible: implementing
purely functional structures — Option, Either, Result, Validation, Lazy, List,
Tree, Map, Set, Pair — the way Vavr does for Java.
No runtime dependencies. The api/ packages import nothing outside the Go
standard library, and that is a hard constraint rather than a preference.
Go 1.27 added generic methods, so Map can change the type it carries and stay
chainable:
control.Some(user).Map(User.Name).Filter(nonEmpty).Map(strings.ToUpper).OrElse("anonymous")No other Go library does both today: Option is a plain struct, there is no
interface and no boxing, and the zero value is None rather than a nil panic.
Go 1.27 or later. Generic methods are the whole point of the API, and they do not exist before that.
go get github.com/glours/go2funkEvery snippet below is backed by a runnable Example test, so it compiles and its
output is verified by go test ./....
Option[T] is either Some, holding a value, or None. Its zero value is
None, so it needs no initialisation.
import "github.com/glours/go2funk/api/control"
some := control.Some(10)
none := control.None[int]()
fmt.Println(some.OrElse(5), none.OrElse(5)) // 10 5
// Map is a method and changes the type it carries.
fmt.Println(some.Map(strconv.Itoa).Map(strings.ToUpper).OrElse("none")) // 10
isEven := func(value int) bool { return value%2 == 0 }
fmt.Println(some.Filter(isEven).IsDefined()) // true
var zero control.Option[int]
fmt.Println(zero.IsEmpty()) // true — no panicInterop with the Go idioms it replaces:
value, ok := counts["ten"]
found := control.FromTuple(value, ok) // "comma ok" -> Option
value, err := found.OrElseError(errors.New("no value")) // Option -> (T, error)
found.ToSlice() // []int{10}
found.ToPointer() // *int, nil when empty
control.FromPointer(p)Also available: Get, OrElseGet, Or, FlatMap, Fold, ForEach.
Option also encodes to and from JSON, which the plain struct cannot do — an
Option field without it marshals to {} whether it holds a value or not:
type Profile struct {
Name string `json:"name"`
Nickname control.Option[string] `json:"nickname"`
Age control.Option[int] `json:"age,omitzero"`
}
// {"name":"ada","nickname":"countess"} — age is dropped, nickname is null when NoneNone encodes as null. Decoding an explicit null gives None; a missing key
leaves the field alone, which for a fresh value means None. Decoding into an
Option that already holds a value merges into it, as it would for a plain
field of the same type.
Use the omitzero tag (Go 1.24+) to leave the field out entirely — it behaves
the same with both encoders. Avoid omitempty here: encoding/json emits null
while encoding/json/v2 drops the field, so the shape would differ depending on
which one the caller uses.
Option implements both forms of the marshaling contract: the byte-slice one
(json.Marshaler, which is an alias for encoding/json/v2.Marshaler) and the
streaming one (MarshalJSONTo). Both encoders prefer the streaming form, which
is what lets the caller's options reach the value inside the Option. Code that
dispatches on json.Marshaler reaches the byte-slice form instead, and that form
cannot see those options — notably it always escapes HTML, as encoding/json
does by default.
This support imports
encoding/json/v2, which Go guards behind thejsonv2experiment. Building withGOEXPERIMENT=nojsonv2therefore fails on the wholecontrolpackage, not just its JSON methods. That flag is a transitional escape hatch for the v2 rollout and go2funk does not work around it.
Any value that encodes as null — a nil pointer, slice, map or interface, or a
nested None — decodes back to None, so the outer "a value is present" bit is
lost for those.
See api/control/example_test.go.
Either[L, R] holds one of two values. By convention Right carries the expected
one, so Map, FlatMap and FilterOrElse work on the right side and let a
Left through untouched, value intact.
right := control.Right[string](10)
left := control.Left[string, int]("nope")
fmt.Println(right.OrElse(20), left.OrElse(20)) // 10 20
fmt.Println(right.Map(strconv.Itoa).OrElse("none")) // 10
fmt.Println(left.Map(strconv.Itoa).LeftOrElse("?")) // nope
fmt.Println(right.Fold(
func(s string) string { return "left: " + s },
func(v int) string { return "right: " + strconv.Itoa(v) },
)) // right: 10Also available: Get, GetLeft, Swap, MapLeft, Or, OrElseGet, ForEach,
ToOption.
Result[T] is a type alias for Either[error, T], so it is an Either and
inherits every one of its methods.
parse := func(s string) control.Result[int] {
return control.Try(func() (int, error) { return strconv.Atoi(s) })
}
fmt.Println(parse("42").Map(func(v int) int { return v * 2 }).OrElse(-1)) // 84
// The cause survives Map; Unwrap hands it back to the Go idiom.
_, err := control.Unwrap(parse("nope").Map(strconv.Itoa))
fmt.Println(err) // strconv.Atoi: parsing "nope": invalid syntaxOk, Err, Try and Unwrap are package-level functions rather than methods
because a type alias cannot declare methods of its own.
Validation[E, T] holds either a valid value or every error found while
validating it. Where Either and Result stop at the first failure,
Validation runs every check and reports all the problems at once, which is
what a form, a config file or a request body needs.
// Several checks on one value: all of them run.
control.Check(signup, nameGiven, adult).Errors()
// [name is empty must be an adult]
// Values of different types, validated independently, then combined.
control.Zip(nonEmpty("ada"), parseAge("36")).Map(describe).OrElse("?") // ada is 36
// Both fail: ToResult joins every cause for the Go (T, error) idiom.
_, err := control.Unwrap(control.ToResult(control.Zip(nonEmpty(""), parseAge("x")).Map(describe)))
// name is empty
// strconv.Atoi: parsing "x": invalid syntaxThe errors are kept as a []E, in the order they were found, so the error type
needs no method and no combining function. Sequence gathers any number of
validations of the same type into one of a slice. The zero value is valid: no
error collected means nothing was found wrong.
Zip combines two validations, and zipping its result again combines any
number of them, at the cost of nesting the pairs. It is a function rather than a
method because Go cannot express it as one: a Zip method returning
Validation[E, Pair[T, U]] would give that type a Zip of its own, and so on
without end, an instantiation cycle the compiler rejects. FlatMap is there
too, but it is sequential by nature and stops at the first invalid step.
Also available: Valid, Invalid, Get, OrElse, Map, MapError, Fold,
ToOption, ToEither, and FromEither, which converts a Result as well.
Lazy[T] defers a computation until its result is first read, then remembers it.
The computation runs at most once, however many goroutines ask for it.
config := control.NewLazy(loadConfig) // nothing has run yet
config.Get() // runs loadConfig
config.Get() // returns the remembered result
config.IsEvaluated() // trueMap and FlatMap stay lazy — chaining them runs nothing until the result is
read:
report := control.NewLazy(fetchRows).Map(summarise).Map(render)
// still nothing has run
report.Get()Delay is an alias for NewLazy, under the name functional languages give it.
A Lazy is a value: copying one shares the memoised result rather than
restarting the computation. Its zero value has no computation attached and
yields the zero value of T, the way Rust's LazyCell::default() does.
Lazy carries no notion of absence or failure, and does not need to: use
Lazy[Option[T]] when "not computed yet" has to be told apart from "computed to
the zero value", and Lazy[Result[T]] when the computation can fail.
See api/control/example_test.go.
import "github.com/glours/go2funk/api/tuple"
pair := tuple.New("ten", 10)
fmt.Println(pair.Left(), pair.Right()) // ten 10
left, right := pair.Unpack()
fmt.Println(pair.MapRight(strconv.Itoa).Right()) // 10
fmt.Println(pair.Swap().Left()) // 10See api/tuple/example_test.go.
An immutable, persistent singly linked list. The zero value is the empty list.
Prepend, Head, Tail, Length and IsEmpty are O(1); everything that has
to walk the list is O(n) and says so in its godoc.
import "github.com/glours/go2funk/api/collection"
list := collection.Of(1, 2, 3, 4, 5)
fmt.Println(list.Length(), list.Head().OrElse(-1)) // 5 1
isEven := func(value int) bool { return value%2 == 0 }
fmt.Println(list.Filter(isEven).Map(strconv.Itoa)) // List(2, 4)
// Prepend is O(1) and the original list is untouched.
fmt.Println(list.Prepend(0), list) // List(0, 1, 2, 3, 4, 5) List(1, 2, 3, 4, 5)Head and Get return an Option, so reading an element out of range is a
value rather than a panic or a second return.
All returns an iter.Seq[T], which makes a List usable directly in a
for range loop and with anything else that speaks the Go iterator protocol:
for value := range list.All() {
fmt.Println(value)
}
collection.Collect(seq) // iter.Seq[T] -> List[T]Fold combines from the left and can change the type:
sum := list.Fold(0, func(acc, value int) int { return acc + value })
joined := list.Fold("", func(acc string, value int) string { return acc + strconv.Itoa(value) })Also available: Tail, Append, AppendAll, Reverse, Insert, FlatMap,
ForEach, ToSlice, String, and collection.Remove for lists of a
comparable type.
See api/collection/example_test.go.
An immutable, persistent ordered set, kept balanced by weight. Values stay
sorted, so iteration is in order and Min, Max and Range come for free.
import "github.com/glours/go2funk/api/collection"
tree := collection.TreeOf(5, 3, 8, 1, 9)
fmt.Println(tree) // Tree(1, 3, 5, 8, 9)
fmt.Println(tree.Contains(8), tree.Min().OrElse(-1)) // true 1
for value := range tree.Range(3, 8) { // 3, 5, 8 — subtrees that cannot hold
fmt.Println(value) // a value in the range are skipped
}
// Insert and Delete return new trees; the original is untouched.
fmt.Println(tree.Insert(4).Delete(9), tree)
// Tree(1, 3, 4, 5, 8) Tree(1, 3, 5, 8, 9)Len and IsEmpty are O(1); Insert, Delete, Contains, Min and Max are
O(log n). Insertion rebuilds only the path from the root to the new value and
shares everything else — inserting into a tree of a thousand values allocates
ten nodes, which the test suite measures rather than claims. Inserting a value
that is already there, or deleting one that is not, allocates nothing at all and
hands back the very same tree.
Ordering goes through cmp.Compare, not the < operator, so a float NaN has a
place in the order rather than comparing false against everything and swallowing
the values around it.
The balancing rule is stated at the top of
api/collection/tree.go and checked by a test that
replays thousands of random insertions and deletions, so the invariant is
executable documentation rather than a comment. doc.go explains what
structural sharing buys, with a diagram.
Also available: Backward, ToSlice, Map, Filter, Fold, String, and
collection.CollectTree to build one from an iter.Seq. Map may return a
smaller tree: values that map to the same result collapse, as they do for any set.
See api/collection/example_test.go.
An immutable, persistent hash map, built on a CHAMP trie. Keys only need to be
comparable, so structs, arrays and pointers work, hashed with hash/maphash.
import "github.com/glours/go2funk/api/collection"
ages := collection.EmptyMap[string, int]().Put("ada", 36).Put("alan", 41)
fmt.Println(ages) // Map[ada:36 alan:41]
fmt.Println(ages.Get("ada").OrElse(-1), ages.Get("grace").IsEmpty()) // 36 true
// Put and Delete return new maps; the original is untouched.
fmt.Println(ages.Put("grace", 85).Delete("alan"), ages)
// Map[ada:36 grace:85] Map[ada:36 alan:41]
// Map changes the value type and keeps every key.
fmt.Println(ages.Map(func(age int) string { return strconv.Itoa(age) + " years" }))
// Map[ada:36 years alan:41 years]Interop with Go maps goes through the standard iterators rather than through dedicated conversions:
ages := collection.CollectMap(maps.All(goMap)) // map[K]V -> Map[K, V]
goMap := maps.Collect(ages.All()) // Map[K, V] -> map[K]VLen and IsEmpty are O(1); Get, ContainsKey, Put and Delete are
O(log32 n). Put rebuilds only the path to the key and shares everything else:
putting into a map of ten thousand entries allocates seven times, which the test
suite measures, and the benchmarks show it barely moves as the map grows a
thousandfold. Deleting a key that is not there allocates nothing and hands back
the very same map.
Iteration order is unspecified, as it is for a Go map. String sorts by key the
way fmt does, so printing a Map is deterministic even though walking it is
not. A float NaN key behaves as it does in a Go map: it can be added but never
found again, since it is not equal to itself. Tree is the structure to reach
for when order matters.
Map maps the values and keeps the keys, which is what Vavr calls mapValues;
mapping whole entries could merge keys and would not be a functor.
Also available: All, Keys, Values, Filter, Fold, String.
See api/collection/example_test.go.
An immutable, persistent hash set: a Map whose values carry nothing, so it
shares the map's trie and its guarantees. Elements only need to be comparable.
import "github.com/glours/go2funk/api/collection"
team := collection.SetOf("ada", "alan", "grace")
reviewers := collection.SetOf("grace", "linus")
fmt.Println(team.Contains("ada"), team.Contains("linus")) // true false
fmt.Println(team.Union(reviewers)) // Set(ada, alan, grace, linus)
fmt.Println(team.Intersection(reviewers)) // Set(grace)
fmt.Println(team.Difference(reviewers)) // Set(ada, alan)
// Insert and Delete return new sets; the original is untouched.
fmt.Println(team.Insert("linus").Delete("alan"), team)
// Set(ada, grace, linus) Set(ada, alan, grace)Len and IsEmpty are O(1); Contains, Insert and Delete are O(log32 n).
Inserting a value that is already there, or deleting one that is not, allocates
nothing and hands back the very same set. Union, Intersection and
Difference walk the smaller of the two sets, so combining a handful of values
with a large set stays cheap whichever side it is on — a test measures it for
Union and Difference, a benchmark shows it for Intersection.
Iteration order is unspecified. String sorts the elements by their rendering,
so printing is deterministic, but since elements are only comparable there is no
other order to use: numbers sort as text, Set(1, 10, 2). When the elements are
ordered and the order matters, Tree is the set to use.
Also available: All, Map, Filter, Fold, String, and
collection.CollectSet to build one from an iter.Seq. Map may return a
smaller set: values that map to the same result collapse.
See api/collection/example_test.go.
go build ./... # build
go test ./... # run the test suite, examples included
go test -race -cover ./...
go vet ./... # static checks
gofmt -l . # must print nothing
golangci-lint run ./... # full lint, config in .golangci.ymlThe no-runtime-dependency rule is enforced two ways: by depguard in
.golangci.yml, which rejects any non-standard-library import under api/, and
by this command, which must print nothing:
go list -deps -f '{{if not .Standard}}{{.ImportPath}}{{end}}' ./... | grep -v go2funkBoth run in CI, along with the build and test matrix.
Pushing a v* tag triggers GoReleaser, which publishes the GitHub release, its
notes and the source archive. Config is in .goreleaser.yaml; check it with
goreleaser check and dry-run with goreleaser release --snapshot --clean.
AGENTS.md holds the development rules for this repository — no
runtime dependencies, test-driven development, black-box tests, and the API
design decisions. They apply to humans and coding agents alike. CLAUDE.md is a
symlink to it.