Skip to main content

Command Palette

Search for a command to run...

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.

Updated
8 min readView as Markdown
M
Staff Software Engineer at Snowflake. Expert in scalable architecture, cloud infra, and security. Passionate problem-solver and mentor.

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 treatmentTry, 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:

  • Zip up to Zip10 on the product types, so also groups 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-position match dispatch 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.
  • bind ergonomics at the edges. Heterogeneous binds (binding an Option inside a Try block, say) need a resolvable Lift, 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.

State of GALA

Part 1 of 2

GALA is a statically typed, functional-first language that transpiles to Go. This series tracks its evolution — the features that shipped, the rough edges that remain, and where the language is heading, one release milestone at a time.

Up next

The State of GALA: May 2026

Eleven weeks, ~55 releases, and a new tagline: GALA hits 0.50.0 with cross-package generics, an LSP, and four new stdlib packages.