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 read•View 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 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:

  • 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.