DEV Community

Maksim Martianov
Maksim Martianov

Posted on

Library vs Language: Two Approaches to Functional Programming in Go

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 }),
)
Enter fullscreen mode Exit fullscreen mode

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) },
    ),
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 }),
)
Enter fullscreen mode Exit fullscreen mode

GALA:

val result = Some(42)
    .Map((x) => x * 2)
    .FlatMap((x int) => if (x > 50) Some(x) else None[int]())
    .GetOrElse(0)
Enter fullscreen mode Exit fullscreen mode

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 }),
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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] { ... })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()))
}
Enter fullscreen mode Exit fullscreen mode

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 run for 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, val immutability, 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:

gala-playground.fly.dev

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)