Go2Funk is a pet project exploring what Go generics make possible: implementing
purely functional structures — Option, Either, Result, Lazy, List,
Tree, 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.
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.
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.