samber/lo Has 21K Stars. Here's What It Would Look Like as a Language Feature.

samber/lo has 21,000 stars. samber/mo has 3,300. Together, they represent one of the loudest feature requests the Go community has ever filed -- not through proposals or GitHub issues, but through adoption. Tens of thousands of Go developers have looked at for i, v := range slice and said: there has to be a better way.
They are right. And lo and mo are genuinely good solutions within the constraints of Go's type system. But those constraints are real, and they shape every API in ways that become visible the moment you compare them to what a language-level implementation looks like.
I am the author of GALA, a language that transpiles to Go. This is not a "lo is bad" post. This is a post about what happens when you move functional patterns from library space into language space -- and what you gain and lose in the process.
What lo and mo solve
lo gives Go developers Lodash-style collection operations: Map, Filter, Reduce, GroupBy, Chunk, Flatten, and dozens more. Before generics landed in Go 1.18, this was impossible without code generation or interface{}. lo was one of the first libraries to prove that Go generics could deliver real ergonomic gains.
mo goes further. It brings monadic types to Go: Option[T], Result[T], Either[L, R], Future[T]. These are types that encode success/failure, presence/absence, and either/or semantics directly in the type system, enabling method chaining instead of if err != nil waterfalls.
Both libraries are well-designed. Both solve real problems. But both also bump into limitations that no Go library can work around.
Side by side: Collection operations
Here is a common pattern -- filter a list of users, extract names, aggregate a result.
samber/lo:
evens := lo.Filter(users, func(u User, _ int) bool {
return u.Active && u.Age >= 18
})
names := lo.Map(evens, func(u User, _ int) string {
return u.Name
})
result := strings.Join(names, ", ")
totalAge := lo.Reduce(users, func(acc int, u User, _ int) int {
return acc + u.Age
}, 0)
GALA:
val result = users
.Filter((u) => u.Active && u.Age >= 18)
.Map((u) => u.Name)
.MkString(", ")
val totalAge = users.FoldLeft(0, (acc, u) => acc + u.Age)
Three differences stand out.
The unused index parameter. Every lo callback takes an extra _ int index parameter, even when you do not need it. This is a design compromise -- lo provides a single API for both indexed and non-indexed access. In GALA, lambdas take only the parameters the operation requires.
Method chaining vs function wrapping. lo uses free functions: lo.Filter(lo.Map(xs, f), g). To chain operations, you nest calls or use intermediate variables. GALA collections are types with methods, so operations chain naturally: xs.Map(f).Filter(g). The pipeline reads left to right, top to bottom.
Lambda syntax. Go requires func, explicit parameter types, explicit return type, and curly braces. GALA infers parameter types from the method signature: (u) => u.Name instead of func(u User, _ int) string { return u.Name }. The type information is still checked -- you just do not have to write it.
Here is a numeric example to make the contrast sharper:
samber/lo:
evens := lo.Filter(nums, func(x int, _ int) bool {
return x%2 == 0
})
squares := lo.Map(evens, func(x int, _ int) int {
return x * x
})
sum := lo.Reduce(squares, func(acc int, x int, _ int) int {
return acc + x
}, 0)
GALA:
val result = nums
.Filter((x) => x % 2 == 0)
.Map((x) => x * x)
.FoldLeft(0, (acc, x) => acc + x)
Same logic. One reads as a pipeline. The other reads as a sequence of transformations with ceremony around each step.
Side by side: Option types
mo brings Option[T] to Go, which is a genuine improvement over nil pointers.
samber/mo:
opt := mo.Some(42)
result := opt.FlatMap(func(value int) mo.Option[int] {
if value > 50 {
return mo.Some(value * 2)
}
return mo.None[int]()
}).OrElse(0)
GALA:
val result = Some(42)
.Map((x) => x * 2)
.FlatMap((x int) => if (x > 50) Some(x) else None[int]())
.GetOrElse(0)
The API shapes are similar -- both support Map, FlatMap, and a default value fallback. But GALA's version benefits from shorter lambda syntax and the ability to use if/else as an expression (it returns a value, so no curly braces or explicit return needed).
The real difference shows up when you pattern match on the result:
samber/mo:
either := mo.Right[string, int](42)
either.Match(
func(err string) { fmt.Println("error:", err) },
func(val int) { fmt.Println("value:", val) },
)
GALA:
val r1 = validateAge(25) match {
case Right(age) => s"Valid age: $age"
case Left(err) => s"Error: $err"
}
mo's Match takes two function callbacks. GALA's match is a language construct that destructures the value, binds variables, and returns a result -- all in one expression. The GALA version is also exhaustive: if Either had a third variant, the compiler would reject incomplete matches.
The three things Go lacks that no library can fix
lo and mo are pushing against the boundaries of what Go's type system allows. Three specific limitations shape every API decision:
1. Lambda syntax. Go's anonymous functions require func, parameter types, return type, and braces. This is fine for callbacks that span multiple lines, but painful for one-liners. func(x int, _ int) bool { return x%2 == 0 } is a lot of syntax for "is this number even?" GALA's (x) => x % 2 == 0 says the same thing with less noise, because parameter types are inferred from context.
2. Type inference for generics. Go can infer type parameters in some cases, but not all. mo requires mo.None[int]() -- you cannot write mo.None() and have the compiler figure out T=int from context. GALA's type inference resolves generic parameters from usage: Some(42) is Option[int] without annotation. FoldLeft(0, (acc, x) => acc + x) infers that the accumulator is int from the zero value.
3. Algebraic data types. This is the big one. Go has no sum types. mo implements Option as a struct with a boolean flag and a value field. Either is a struct with a flag indicating left or right. These work, but the compiler cannot enforce exhaustive handling. You can forget to check IsPresent() before calling MustGet(), and the compiler will not stop you. GALA's sealed types are true algebraic data types with compiler-enforced exhaustiveness:
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"
}
Add a new variant to Shape and every match expression that does not handle it becomes a compile error. No library in Go can provide this guarantee, because Go's type system does not support closed type hierarchies.
What GALA adds beyond lo and mo
Beyond cleaner syntax for the same operations, GALA provides constructs that have no library equivalent:
Immutability by default. Variables declared with val cannot be reassigned. Struct fields are immutable unless explicitly marked var. Every struct gets an auto-generated Copy method for creating modified versions. This is not a convention -- it is compiler-enforced.
String interpolation. s"Hello \(name" and f"\)value%.2f" replace fmt.Sprintf calls. Format verbs are inferred from the variable type. No import needed.
Expression-oriented syntax. if/else, match, and single-expression functions all return values. func square(x int) int = x * x -- no braces, no return keyword. This removes an entire category of "forgot to return" bugs.
Full Go interop. GALA transpiles to Go. Every Go package is importable. Every Go type is usable. There is no FFI boundary, no wrapper generation, no runtime overhead beyond what the transpiler generates. If you use net/http, database/sql, or any Go library, it works directly.
When to use lo/mo vs GALA
Use lo/mo when:
You have an existing Go codebase and want incremental improvements
Your team is comfortable with Go and does not want to learn a new syntax
You need a few specific utilities (lo's
Chunk,GroupBy,Uniq) without adopting a new languageYou want to stay within the official Go toolchain
Consider GALA when:
You are starting a new Go-targeted project and want stronger type guarantees from day one
Your domain has complex state machines, protocol types, or AST-like structures that benefit from sealed types
You want immutability enforced by the compiler, not by convention
You come from Scala, Kotlin, or Rust and want similar expressiveness with Go's deployment model
You are building data transformation pipelines where functional collection operations dominate the code
Honest tradeoffs
GALA is not a free upgrade. Here is what you give up:
Ecosystem maturity. Go has world-class tooling: debuggers, profilers, IDE support, error messages refined over 15 years. GALA has an IntelliJ plugin with syntax highlighting and completion, but it is not at the same level. You will hit rough edges.
Team familiarity. lo is Go code. Any Go developer can read it, debug it, and contribute to it. GALA requires learning a new syntax. The concepts (sealed types, pattern matching, FoldLeft) are well-established in other languages, but they require investment from developers who have not used them before.
Compilation step. GALA adds a transpilation step before Go compilation. With Bazel, this is seamless. Without it, you are running gala build before go build.
Community size. lo has 21,000 stars and a large contributor base. GALA is a younger project. The standard library is solid -- Option, Either, Try, Future, six collection types -- but the ecosystem around it is smaller.
These are real costs. Whether the expressiveness gains justify them depends on your team, your domain, and your tolerance for early-adopter friction.
Try it
GALA has a browser-based playground at gala-playground.fly.dev with built-in examples. No installation required.
For local development: download a binary from GitHub releases, or build from source with Bazel. GALA is at v0.17.1, with binaries for Linux, macOS, and Windows.
The 21,000 stars on samber/lo are not just a success story for a library. They are evidence that a significant segment of Go developers wants functional programming patterns -- and is willing to adopt third-party dependencies to get them. GALA asks: what if those patterns were not bolted on, but built in?
I am the author of GALA. Source: github.com/martianoff/gala