Skip to content

Latest commit

 

History

67 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Go2Funk

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.

Requirements

Go 1.27 or later. Generic methods are the whole point of the API, and they do not exist before that.

Install

go get github.com/glours/go2funk

Usage

Every snippet below is backed by a runnable Example test, so it compiles and its output is verified by go test ./....

Option

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 panic

Interop 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 None

None 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 the jsonv2 experiment. Building with GOEXPERIMENT=nojsonv2 therefore fails on the whole control package, 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

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: 10

Also available: Get, GetLeft, Swap, MapLeft, Or, OrElseGet, ForEach, ToOption.

Result

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 syntax

Ok, Err, Try and Unwrap are package-level functions rather than methods because a type alias cannot declare methods of its own.

Validation

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 syntax

The 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

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()  // true

Map 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.

Pair

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())                   // 10

See api/tuple/example_test.go.

List

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.

Tree

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.

Map

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]V

Len 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.

Set

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.

Development

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.yml

The 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 go2funk

Both run in CI, along with the build and test matrix.

Releases

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.

Contributing

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.

About

Simple Golang API to use functional types in Golang, such as immutable List, Options, Try, Either...

Topics

Resources

Stars

8 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages