If you have used IBM's fp-go library, you know the promise of functional programming in Go -- and the pain of Go's syntax fighting you every step of the way. Pipe3, Pipe5, Pipe7. Function adapters everywhere. Type parameters that stretch across the screen. You know the patterns work because you have seen them in Haskell or fp-ts, but the Go rendition feels like wearing a suit two sizes too small.
fp-go is a serious library. With nearly 2,000 GitHub stars and a design rooted in fp-ts, it brings real functional abstractions to Go: Option, Either, IO, Reader, State, and more. The engineering is impressive. But it also exposes a fundamental tension: Go's syntax was not designed for this style of programming, and no library can fully bridge that gap.
This article compares two approaches to the same problem: fp-go (the library approach) and GALA (the language approach). Both target developers who want FP patterns in Go-compatible code. They make very different trade-offs.
Disclosure: I am the author of GALA. I have tried to present both approaches fairly, but you should know where I stand. I have genuine respect for fp-go's engineering -- it pushed me to think about what a language-level solution should look like.
The Library Approach: fp-go
fp-go models functional abstractions as Go packages. Option lives in github.com/IBM/fp-go/option. Either lives in github.com/IBM/fp-go/either. Composition happens through Pipe functions that chain operations sequentially.
Here is a typical fp-go Option pipeline. You have a value that might be absent, and you want to transform it:
import (
O "github.com/IBM/fp-go/option"
"github.com/IBM/fp-go/function"
)
result := function.Pipe3(
O.Some(42),
O.Map(func(x int) int { return x * 2 }),
O.Chain(func(x int) O.Option[int] {
if x > 50 {
return O.Some(x)
}
return O.None[int]()
}),
O.GetOrElse(func() int { return 0 }),
)
This works. The types are correct, the behavior is well-defined. But look at what Go's syntax demands: explicit func keywords on every lambda, full type annotations on every parameter and return, separate imports for each module, and a Pipe3 function (not Pipe2, not Pipe4 -- the arity must match the number of operations).
Error handling with Either follows the same pattern:
import (
E "github.com/IBM/fp-go/either"
"github.com/IBM/fp-go/function"
)
result := function.Pipe2(
E.Right[string](25),
E.Chain(func(age int) E.Either[string, int] {
if age < 0 {
return E.Left[int]("age cannot be negative")
}
return E.Right[string](age)
}),
E.Fold(
func(err string) string { return "Error: " + err },
func(age int) string { return fmt.Sprintf("Valid age: %d", age) },
),
)
The signal-to-noise ratio is the issue. The business logic -- "validate that age is non-negative" -- occupies a fraction of the code. The rest is structural: type annotations, function wrappers, pipe plumbing.
This is not a criticism of fp-go's design. It is an observation about Go as a host language for FP patterns. Go lacks three things that FP-heavy code relies on: concise lambda syntax, type inference for function parameters, and method chaining on generic types. No library can add these to Go.
The Language Approach: GALA
GALA is a language that transpiles to Go. It generates standard Go code, produces native binaries, and interoperates with every Go library. But it has its own syntax designed around functional patterns.
Here is the same Option pipeline in GALA:
package main
func main() {
val result = Some(42)
.Map((x) => x * 2)
.FlatMap((x int) => if (x > 50) Some(x) else None[int]())
.GetOrElse(0)
Println(result)
}
No imports for Option -- it is a built-in type. No func keyword on lambdas. No Pipe function -- method chaining works directly. The lambda parameter type in Map is inferred from the Option's type parameter. The code reads as a description of the transformation, not a fight with the type system.
The difference is not cosmetic. It changes how much code you can absorb at a glance, which matters when you are reading a codebase you did not write.
Side-by-Side: Option Handling
fp-go:
result := function.Pipe3(
O.Some(42),
O.Map(func(x int) int { return x * 2 }),
O.Chain(func(x int) O.Option[int] {
if x > 50 { return O.Some(x) }
return O.None[int]()
}),
O.GetOrElse(func() int { return 0 }),
)
GALA:
val result = Some(42)
.Map((x) => x * 2)
.FlatMap((x int) => if (x > 50) Some(x) else None[int]())
.GetOrElse(0)
Both produce the same result: 84 (since 42 * 2 = 84, which is > 50). The GALA version is roughly a third of the line count. More importantly, the transformation pipeline is visible without mentally filtering out syntax.
Side-by-Side: Error Handling with Try
fp-go handles fallible operations through Either and IO. GALA provides a Try monad that wraps operations that might throw, similar to Scala's Try:
fp-go (Either-based):
import (
E "github.com/IBM/fp-go/either"
"github.com/IBM/fp-go/function"
"strconv"
)
result := function.Pipe2(
E.TryCatchError(func() (int, error) {
return strconv.Atoi("42")
}),
E.Map[error](func(n int) int { return n * 2 }),
E.GetOrElse(func(err error) int { return -1 }),
)
GALA:
package main
import "strconv"
func safeParse(s string) Try[int] = Try(() => strconv.Atoi(s))
func main() {
val result = safeParse("42")
.Map((n) => n * 2)
.Recover((e) => -1)
Println(result)
val failed = safeParse("not_a_number")
.Map((n) => n * 2)
.Recover((e) => -1)
Println(failed)
}
GALA's Try automatically handles Go's (T, error) multi-return convention. The lambda () => strconv.Atoi(s) returns (int, error), and Try converts a non-nil error into a Failure. No explicit error type parameters, no TryCatchError adapter.
Side-by-Side: Collections
Functional collection processing is where the verbosity gap becomes stark.
fp-go:
import (
A "github.com/IBM/fp-go/array"
"github.com/IBM/fp-go/function"
)
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := function.Pipe3(
nums,
A.Filter(func(x int) bool { return x%2 == 0 }),
A.Map(func(x int) int { return x * x }),
A.Reduce(func(acc int, x int) int { return acc + x }, 0),
)
GALA:
package main
import . "martianoff/gala/collection_immutable"
func main() {
val nums = ArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val result = nums
.Filter((x) => x % 2 == 0)
.Map((x) => x * x)
.FoldLeft(0, (acc, x) => acc + x)
Println(s"Sum of squares of evens: $result")
}
Both compute the same value: the sum of squares of even numbers from 1 to 10 (220). The GALA version chains methods on the collection itself. The fp-go version pipes the collection through free functions. Both are valid compositional styles, but method chaining eliminates the Pipe scaffolding and makes the data flow more immediately visible.
The Hidden Cost: Type Annotations
The examples above hint at it, but it is worth calling out explicitly: fp-go requires you to annotate types that GALA infers automatically.
In fp-go, every Map, Chain, and Fold call needs explicit type parameters because Go's generic inference does not propagate through free functions:
// fp-go: you must spell out every type
O.Map[int](func(x int) int { return x * 2 })
A.Filter(func(x int) bool { return x%2 == 0 })
A.Map(func(x int) int { return x * x })
A.Reduce(func(acc int, x int) int { return acc + x }, 0)
E.Chain(func(age int) E.Either[string, int] { ... })
In GALA, the transpiler infers lambda parameter types from the method's generic context. When you call .Map on an Array[int], the transpiler knows the lambda parameter is int. When you call .FoldLeft(0, ...), it infers the accumulator type from the zero value:
// GALA: types inferred from context
nums.Filter((x) => x % 2 == 0) // x is int (from Array[int])
nums.Map((x) => x * x) // x is int, return is int
nums.FoldLeft(0, (acc, x) => acc + x) // acc is int (from 0), x is int
This is not just fewer characters. It changes how you read code. In the fp-go version, your eyes parse type annotations before reaching the logic. In the GALA version, the logic is all there is. The types are still checked at compile time -- they are just inferred rather than written.
The inference extends to method type parameters too. list.Map((x) => x.Name) on a List[Person] infers that x is Person and the result is List[string], without writing Map[string] or annotating x.
Side-by-Side: Pattern Matching
This is where fp-go hits a hard wall. Go has no pattern matching syntax, so fp-go uses Fold functions as eliminators:
fp-go:
E.Fold(
func(err string) string { return "Error: " + err },
func(age int) string { return fmt.Sprintf("Valid age: %d", age) },
)(validateAge(25))
GALA:
package main
func validateAge(age int) Either[string, int] {
if (age < 0) {
return Left("age cannot be negative")
} else if (age > 150) {
return Left("age seems unrealistic")
} else {
return Right(age)
}
}
func main() {
val r1 = validateAge(25) match {
case Right(age) => s"Valid age: $age"
case Left(err) => s"Error: $err"
}
Println(r1)
val r2 = validateAge(-5) match {
case Right(age) => s"Valid age: $age"
case Left(err) => s"Error: $err"
}
Println(r2)
}
Fold is functionally equivalent to pattern matching. It is also harder to read at scale. When you have nested matches, guards, or multiple extracted values, the anonymous-function-per-branch style becomes difficult to follow. GALA's match expression reads like a conditional: each branch is a pattern, an optional guard, and a result.
GALA also provides sealed types -- something fp-go cannot offer at all:
package main
sealed type Shape {
case Circle(Radius float64)
case Rectangle(Width float64, Height float64)
case Point()
}
func describe(s Shape) string = s match {
case Circle(r) => f"circle with radius=$r%.2f"
case Rectangle(w, h) => s"rectangle ${w}x${h}"
case Point() => "a point"
}
func main() {
Println(describe(Circle(3.14)))
Println(describe(Rectangle(10, 5)))
Println(describe(Point()))
}
If you add a new variant to Shape and forget to handle it in a match expression, the compiler rejects your code. This is compile-time exhaustiveness checking -- something that no Go library can provide because Go's type system does not support closed type hierarchies.
When to Use Which
Choose fp-go when:
- You need to stay in pure Go. Your team, your CI, your tooling -- all Go. fp-go is a dependency, not a language change.
- You want specific abstractions (IO, Reader, State) without adopting a new syntax. fp-go's module coverage is broad.
- You are adding FP patterns to an existing Go codebase incrementally. Import one package, use it in one function, expand from there.
- You want a mature community around Haskell-style typeclasses in Go. fp-go's design is principled and well-documented.
Choose GALA when:
- You want FP to feel native, not bolted on. Lambdas, type inference, and method chaining are part of the syntax.
- You need sealed types and exhaustive pattern matching. No Go library can provide compile-time exhaustiveness.
- You are starting a new project and want Go's operational characteristics (native binaries, goroutines, the Go ecosystem) with more expressive syntax.
- You come from Scala, Kotlin, or Rust and want similar idioms without the JVM or borrow checker.
Honest Trade-offs
GALA adds a transpilation step. Your source files are .gala, not .go. This means:
-
Build tooling. GALA uses Bazel for its build system and provides
gala runfor quick iteration. This is an additional tool in your stack. - Debugging. You debug the generated Go code, not the GALA source. The generated code is readable, but it is an extra layer of indirection.
- Community size. fp-go has nearly 2,000 GitHub stars and an active community. GALA is newer and smaller. You will find fewer Stack Overflow answers and fewer blog posts.
- IDE support. GALA has an IntelliJ plugin with syntax highlighting and completion. It is not at the level of Go's LSP-powered tooling.
- Maturity. fp-go has been battle-tested in production at IBM. GALA is at v0.17.1 -- functional and improving, but younger.
On the other side:
- fp-go's verbosity is structural, not fixable. Go will likely never get concise lambda syntax or HKT. The Pipe/Map/Chain ceremony is permanent.
-
fp-go cannot add new syntax. Sealed types, pattern matching,
valimmutability, string interpolation -- these require language-level support. -
fp-go's learning curve is steep in its own way. Understanding
Pipe5(value, F.Map(...), F.Chain(...), F.Fold(...), ...)requires internalizing a convention that has no visual affordance in Go.
Try It
GALA has an online playground where you can write and run code in the browser, no installation required:
The playground includes examples for Option chaining, pattern matching, sealed types, collections, and Go interop. If the code samples in this article looked interesting, the playground is the fastest way to get a feel for the language.
Source code and documentation: github.com/martianoff/gala
fp-go and GALA represent two legitimate responses to the same observation: Go's syntax makes functional patterns unnecessarily verbose. fp-go works within Go's constraints, accepting the ceremony as the cost of staying in the ecosystem. GALA steps outside those constraints, accepting a transpilation step as the cost of cleaner syntax. Neither is wrong. The right choice depends on how much of your codebase is FP-heavy, how important compile-time exhaustiveness is to your domain, and whether your team is willing to adopt a new language versus a new library.
If you are already using fp-go and find yourself wishing Go's lambdas were shorter, its type inference were better, and its type system supported sum types -- that is exactly the gap GALA was built to fill.
Top comments (0)