GALA in June: do-notation, applicative validation, and concurrent binds (0.56 -> 0.62)
June's 0.56 -> 0.62 releases: bind/also monadic do-notation, applicative validation with Validated, and concurrent binds over Future.
A follow-up to The State of GALA: May 2026.
In May, GALA hit 0.50.0 with sealed types, exhaustive pattern matching, a real monad stack (Option, Either, Try, Future, IO), and a functional standard library — all transpiling to plain Go. The one thing that stack was still missing was a decent way to write against it.
You had Map and FlatMap, which are great for a single linear pipeline and get ugly the moment a step needs a value from two steps back. Every intermediate value is trapped inside a closure, so the "graph" shape — where a later step reads an earlier one — pushes you into a staircase of nested FlatMap calls.
The headline of the June work (0.56 → 0.62) fixes exactly that. GALA now has bind / also — monadic do-notation, the piece that makes "Scala on Go" actually feel like writing Scala. Here's what shipped.
bind: the flat version of a FlatMap chain
Take a small order pipeline. Fetch an order, validate it, charge it, build a receipt — where the receipt needs both the original order and the payment. With combinators, that last cross-reference forces nesting:
fetchOrder(id).FlatMap((o) =>
validateOrder(o).FlatMap((valid) =>
chargePayment(valid).FlatMap((payment) =>
Success(Receipt(o.Id, payment))))) // `o` survives only via nesting
With bind, every binding is a normal immutable local that stays in scope for the rest of the block:
func processOrder(id int) Try[Receipt] {
bind o = fetchOrder(id)
bind valid = validateOrder(o)
bind payment = chargePayment(valid)
Success(Receipt(o.Id, payment)) // `o` still in scope — no nesting
}
bind name = expr unwraps the monad, binds the success value, and short-circuits the whole block on the first Failure — so processOrder(0) stops at the first bind and returns that Failure unchanged. This isn't Rust's ? or Zig's try: it's not a hidden return that hijacks the enclosing function. The block is an ordinary expression of type Try[Receipt] — you can name it, return it, or pass it around. It just desugars, mechanically, to the nested FlatMap chain above.
If you've written a Scala for-comprehension or a Haskell do block, this is the same idea with a keyword that tells you it can short-circuit, instead of a <- you have to learn to read.
also: independent binds, and why the keyword earns its keep
Not every step depends on the one before it. Validating a name, an email, and an age are three independent checks. Sequencing them with bind is a lie about the data flow — and it throws away everything except the first error.
also marks a bind as independent of its group:
func sum2(x string, y string) Option[int] {
bind a = lookup(x)
also b = lookup(y) // independent of `a`
Some(a + b)
}
A leading bind plus one or more also clauses form a product group. The clauses may not reference each other — and that non-dependence is exactly what licenses the compiler to do something smarter than sequencing. What "smarter" means is decided by the type:
| Type | What an also group does |
|---|---|
Try / Option / Either |
sequential short-circuit on the first failure |
Validated |
accumulates every error |
Future |
runs the clauses concurrently |
One keyword, three behaviors, no annotations — the block's type picks the semantics.
Validated: collect all the errors, not just the first
Fail-fast is wrong for form validation. If a user submits a blank name, a blank email, and a negative age, you want to tell them all three things at once, not make them fix them one round-trip at a time. That's what the new Validated[E, A] type is for — it's a distinct sealed type (Valid / Invalid), kept deliberately separate from Either's fail-fast semantics.
import . "martianoff/gala/validation"
func vName(s string) Validated[string, string] = if (s != "") Valid(s) else InvalidOf("name required")
func vEmail(s string) Validated[string, string] = if (s != "") Valid(s) else InvalidOf("email required")
func vAge(n int) Validated[string, int] = if (n >= 0) Valid(n) else InvalidOf("age negative")
func makePerson(name string, email string, age int) Validated[string, Person] {
bind n = vName(name)
also e = vEmail(email)
also a = vAge(age)
Valid(Person(n, e, a))
}
Because the clauses are also (independent), the group lowers to Validated.Zip3(...).FlatMap(...), and Validated's Zip pre-collects every clause's errors before combining. So makePerson("", "", -1) doesn't stop at the blank name — it reports all three:
val bad = makePerson("", "", -1)
Println(s"errors: ${bad.GetErrors().Size()}") // 3
Note there isn't a single explicit type argument in that code. Valid / Invalid fix their phantom type parameter from the declared return type, and InvalidOf infers its instantiation from context — implicit typing all the way down, the way GALA code is supposed to read.
also over Future: structured concurrency for free
The same also over Future runs the clauses concurrently:
import . "martianoff/gala/concurrent"
func total() Future[int] {
bind a = compute(2)
also b = compute(3) // these three
also c = compute(4) // run in parallel
Future[int](a + b + c)
}
The group lowers to Future.Zip3(...), which starts every future and joins them — so the block waits on all three in parallel instead of threading each through the next. Concurrency is visible in the source: sequential binds stay sequential, only an also group runs in parallel. The transpiler never auto-parallelizes consecutive binds behind your back.
(While we were in Future, the async constructor got simpler too: Future(...) is now the way to start an async computation, and the old FutureApply spelling is gone.)
The part I'm proud of: it works over your monads
Here's the design constraint that made this interesting. GALA's standing rule is that the standard library gets no special treatment — Try, Option, Either, Future have to resolve through the same mechanism a third-party type would. And Go's generics can't express a higher-kinded Bindable[F[_]] interface, so GALA doesn't fake one.
Instead, bind/also are resolved structurally, at transpile time, on the block's concrete type — before Go ever sees the code. A type becomes bindable by providing exactly one method:
func (m M[T]) FlatMap[U any](f func(T) M[U]) M[U]
That's the whole contract. Here's a user-defined Step monad with no relationship to the standard library, and bind works over it identically to Try:
sealed type Step[T any] {
case Go(Value T)
case Stop(Reason string)
}
func (s Step[T]) FlatMap[U any](f func(T) Step[U]) Step[U] = s match {
case Go(v) => f(v)
case Stop(r) => Stop[U](r)
}
func pipeline(n int) Step[int] {
bind a = start(n)
bind b = twice(a)
Go(a + b) // `a` still in scope; a `Stop` anywhere short-circuits
}
The short-circuit behavior lives entirely inside the author's FlatMap — the desugaring never inspects, and never hardcodes, which variant is the "zero." Supply a Pure/constructor and you also get auto-lift of trailing plain values and sequential also; supply a custom Zip and you get concurrency or error accumulation. A brand-new user monad gets bind and sequential also for free. This is the OCaml let* / and* model, resolved structurally instead of lexically. There is no built-in list of "blessed" monads.
The rest of June
Beyond bind/also, 0.56 → 0.62 was mostly the unglamorous work that a compiler needs to actually be trusted:
Zipup toZip10on the product types, soalsogroups can be wide.- By-name thunk sugar for zero-argument function parameters — pass an expression where a
func()is expected and it's wrapped for you. - Race-free concurrent parsing. Multi-file packages now parse in parallel with a per-parse prediction cache, so the parser is safe under concurrency.
- A stack of type-inference fixes: inferring a generic method's result type from a lambda's return under type-parameter name collisions; inferring "phantom" return-only type parameters from the expected type; inferring lambda parameter types in typed contexts (and rejecting truly-untyped ones instead of silently widening to
any); statement-positionmatchdispatch with a guarded wildcard before the default. - A standard-library cleanup pass applying GALA's own best practices across
std.
Every one of those inference fixes shipped with a permanent example test, per the project rule that a transpiler bug becomes a repro before it becomes a fix.
What's still rough
Same honest list as always:
- Package registry. Dependencies resolve — Go and GALA, including third-party Go modules — but there's still no first-class public registry for publishing and discovering GALA libraries. It remains the top open roadmap item.
bindergonomics at the edges. Heterogeneous binds (binding anOptioninside aTryblock, say) need a resolvableLift, and lifting a "zero" type into an error type makes you supply the error explicitly. That's by design — no silent coercions — but it's a sharp edge you'll meet.- Maturity. GALA is six months old. It generates readable Go and runs real software — a TUI framework, a multi-agent orchestrator, this project's own tooling — but it hasn't been load-bearing in a large team's production system for a year. Use it with eyes open.
Try it
bind/also is the feature I most wanted GALA to have, because it's the one that turns a pile of good combinators into something that reads like the functional languages it's borrowing from. If you've missed for-comprehensions or do-notation in Go, this is the part to try first.
Try it in your browser — no install — or grab a binary from Releases and gala run main.gala.