Go Can Call GALA Now: GALA in October (0.85 → 0.92)
Go programs can now use GALA libraries with go get. Plus pointers that can't be nil, richer pattern matching, and interface literals.
Here's a Go program. Nothing in it mentions GALA:
package main
import (
"fmt"
"example.com/billing"
)
func main() {
team := billing.NewPlanPro(billing.WithPlanProSeats(3))
fmt.Println(team, team.IsPro(), billing.MonthlyCents(team))
inv := billing.NewInvoice("acme", billing.NewPlan())
fmt.Println(inv.Customer.Get(), inv.Month.Get(), billing.Total(inv))
plan := billing.ParsePlan("team").GetOrElse(billing.NewPlan())
fmt.Println(plan, billing.ParsePlan("gold").IsDefined())
}
Pro(3) true 3600
acme 1 0
Pro(5) false
billing is written in GALA. The Go program got it with go get and built
it with go build. It needed no GALA toolchain, no ~/.gala, and no replace
directives.
That's the headline of the nine releases since the last of these posts, 0.85.0 through 0.92.0. Until now, interop went one way: GALA called Go. Now the other direction works too. The rest of this stretch was about making GALA code say more with less. There's a pointer type that can't be nil, pattern matching with alternatives and field names, and defaults you declare instead of zero values Go makes up. Interface values can be written inline, and opaque types keep IDs apart. Code also got a little faster, and builds use less memory.
Go can call GALA
Here's the library behind that program:
package billing
// Plan is a subscription plan. Free is its default.
sealed type Plan {
default case Free()
case Pro(Seats int = 5)
}
// Invoice is one month's bill for a customer.
struct Invoice(Customer string, Plan Plan, Month int = 1)
// MonthlyCents returns the price of a plan for one month.
func MonthlyCents(p Plan) int64 = p match {
case Free() => int64(0)
case Pro(seats) => int64(seats) * 1200
}
// ParsePlan reads a plan name; it returns None for a name it does not know.
func ParsePlan(name string) Option[Plan] = name match {
case "free" => Some(Free())
case "pro" | "team" => Some(Pro())
case _ => None()
}
// Total returns what an invoice charges.
func Total(inv Invoice) int64 = MonthlyCents(inv.Plan)
Three pieces make it usable from Go.
The standard library is a Go module (0.87). Every GALA release now
publishes its standard library as go.gala.fyi/stdlib, at the same version.
It's fetched through proxy.golang.org and pinned by the checksum database,
so go mod vendor and offline Docker builds work.
gala export (0.87) writes a GALA project as an ordinary Go module that
requires it:
gala export --out ../billing-go
The output has a go.mod that requires go.gala.fyi/stdlib v0.92.0, plus
the generated Go. Commit it to a repository, tag it, and Go programs can
go get it. --go-module sets the import path, and --proxy writes a local
module proxy, which is how I tested the program above before publishing
anything.
Go-shaped generated code (0.85 to 0.90). A Go caller can't run GALA's
constructors or apply its defaults, so exported types now come with Go
constructors. NewPlan() returns the default value. NewPlanPro(...) builds
a case, and WithPlanProSeats overrides a field default. NewInvoice(customer, plan, ...) takes the fields that have no default. Each case also gets a
predicate such as IsPro(). Doc comments carry over into the generated Go
too, so go doc, gopls and pkg.go.dev show them.
The same release line made GALA types fit Go's interfaces. Some Go interface
methods return two results, such as io.Writer's Write with (int, error).
A GALA function can now declare Go's result list and give the results as one
GALA value: a Try for (T, error), a Tuple for (A, B) (0.86):
package main
import (
"io"
"strings"
. "martianoff/gala/go_interop"
)
// LineCounter counts the newlines written to it.
struct LineCounter(var Lines int)
// Write makes a LineCounter an io.Writer.
func (c Ptr[LineCounter]) Write(p []byte) (int, error) {
c.Lines = c.Lines + strings.Count(ToString(p), "\n")
Success(p.Size())
}
func main() {
var c = LineCounter(0)
io.Copy(&c, strings.NewReader("one\ntwo\nthree\n")) match {
case Success(n) => Println(s"\(n bytes, \){c.Lines} lines")
case Failure(e) => Println(s"copy failed: $e")
}
}
This prints 14 bytes, 3 lines. In the generated Go, Write has exactly the
signature io.Writer asks for. io.Copy's own (int64, error) arrives in
GALA as a Try[int64], as any Go call does. The same works for io.Reader,
json.Marshaler and driver.Valuer.
The Go side isn't fully idiomatic yet. Immutable fields are read with
.Get(), as in inv.Customer.Get() above. Generic GALA methods become free
functions in Go (std.Option_Map(o, f)), because Go methods can't take type
parameters. And a project that imports another GALA module can't be exported
yet. The Go interop guide covers
publishing, versions, and the rest.
A pointer that can't be nil
In 0.92, &x has a new type: Ptr[T], a pointer that's never nil.
The generated Go spells it *T, so the type itself costs nothing at run
time, and it has the fields and methods of what it points to. The type checker
enforces one rule: nil, or a *T that might be nil, can't fill a Ptr. Go
APIs return nullable pointers all the time. flag.Lookup, for example,
returns nil for a flag that isn't defined, so this doesn't compile:
package main
import "flag"
func usage(f Ptr[flag.Flag]) string = f.Usage
func main() {
flag.String("mode", "fast", "run mode")
Println(usage(flag.Lookup("mode")))
}
error[GALA-E0080]: cannot use flag.Lookup("mode") (*flag.Flag) as Ptr[flag.Flag]: a pointer that may be nil cannot fill a Ptr
--> main.gala:9:19
|
9 | Println(usage(flag.Lookup("mode")))
| ^^^^^^^^^^^^^^^^^^^ pass RequirePtr(flag.Lookup("mode"))
|
= hint: pass RequirePtr(flag.Lookup("mode")); RequirePtr panics on nil, and a pointer that may be nil is kept as OptionPtrOf(flag.Lookup("mode"))
The error gives both fixes. You convert once, where the pointer comes in from
Go. Use RequirePtr(p) when nil would be a bug, or OptionPtrOf(p) when nil
means "absent." An OptionPtr matches with Option's variants directly:
package main
import "flag"
func describe(name string) string = OptionPtrOf(flag.Lookup(name)) match {
case Some(f) => s"\(name = \){f.Value.String()}"
case None() => s"$name is not defined"
}
func main() {
flag.String("mode", "fast", "run mode")
flag.Parse()
Println(describe("mode"))
Println(describe("verbose"))
}
mode = fast
verbose is not defined
Past the boundary, nothing needs a nil check. A function that takes a
Ptr[T] gets a value, and GALA code can't hand it anything else.
Go code can still pass nil, because Go doesn't know about Ptr. So exported
functions and methods check their Ptr parameters on entry, the way Kotlin
checks the parameters of its public functions. Unexported functions get no
check, since only GALA code can call them. This GALA:
package main
import "flag"
// Usage returns a flag's help text.
func Usage(f Ptr[flag.Flag]) string = f.Usage
func main() {
flag.String("mode", "fast", "run mode")
Println(Usage(RequirePtr(flag.Lookup("mode"))))
}
generates this Go (with the //line directives removed):
// Usage returns a flag's help text.
func Usage(f *flag.Flag) string {
if f == nil {
panic("Usage: parameter f is a Ptr[flag.Flag], but the caller passed nil")
}
return f.Usage
}
OptionPtr, not Option[Ptr[T]]. Option carries a tag next to its
value. OptionPtr is a std struct holding a single *T, and its zero value is
empty. unsafe.Sizeof on amd64 gives 8 bytes for an OptionPtr[Node] and 16
for an Option[Ptr[Node]]. For one value that doesn't matter, but for every
link in a tree or a list it does. To let OptionPtr match as Some/None
without converting, 0.92 added sealed views. Any type with a
MatchView() method that returns a sealed type can be matched against that
type's variants, with exhaustiveness checking. Your own types can use it the
same way.
The standard library uses both. Every exported receiver, parameter and
return in std that used a GALA-created *T is now a Ptr[T], and the links
in the collections are OptionPtr. Code that relies on inference doesn't
notice the change.
Faster code, and less memory to build it. Two changes came with the
pointer work. First, a local val now compiles to a plain Go variable instead
of an Immutable box. GALA already rejects every write to it, so the box only
cost a wrapper call per read and some of Go's inlining budget. With the box
gone, the tree collection benchmarks got 2 to 5 percent faster. Second, the
generator stopped emitting closures in a couple of common places. One is a
function that returns an if-expression directly, which is now a plain if.
Together with leaner collection links, that cut Go's compile memory for a large
downstream app by about 1.2 GB, with no runtime cost.
Pattern matching that says more
Alternatives (0.88). | in a pattern means "or," at the top of a case
and nested inside an extractor or a tuple:
package main
func size(o Option[int]) string = o match {
case Some(1 | 2 | 3) => "small"
case Some(_) | None() => "other"
}
func main() {
Println(size(Some(2)), size(Some(9)), size(None()))
}
This prints small other other. In a pattern, | is always an alternative.
To match a bitwise OR of two flags, write it as a guard:
case n if n == (FlagA | FlagB). Alternatives can't bind names, because in
case Some(n) | None() the name n would have no value when None()
matched.
Named sub-patterns (0.88). A pattern can pick the fields it cares about
by name, in any order. A field you leave out counts as _:
package main
sealed type Shape {
case Circle(Radius int)
case Rect(Width int, Height int)
}
func height(s Shape) int = s match {
case Circle(Radius = r) => 2 * r
case Rect(Height = h) => h
}
func main() {
Println(height(Rect(Width = 10, Height = 2)))
}
This prints 2. Naming a field that doesn't exist, or matching one twice, is
a compile error.
Exhaustiveness that understands guards and literals (0.88). The check now works the way Maranget describes for ML compilers. A variant counts as covered only when its cases match every value of its fields, recursively. So a guarded case doesn't cover its variant, since its guard may be false, and the compiler says which case is missing:
package main
func describe(o Option[int]) string = o match {
case Some(n) if n > 0 => "positive"
case None() => "missing"
}
func main() {
Println(describe(Some(3)))
Println(describe(Some(-1)))
}
error[GALA-E0002]: non-exhaustive match: missing cases: Some
--> main.gala:4:5
|
4 | case Some(n) if n > 0 => "positive"
| ^^^^ add the missing variant cases, or add a `case _ => ...` defa…
|
= hint: add the missing variant cases, or add a `case _ => ...` default to cover them; a case with an `if` guard does not count, since its guard may be false
Before 0.88 this match compiled, and describe(Some(-1)) panicked at run
time. Combinations that do cover everything are accepted without a
case _: Some(Some(_)) + Some(None()) + None(), Some(true | false) +
None(), and boolean fields split across cases. Wide variants stay fast. A
probe with a 22-field variant and 23 arms is checked in 0.3 seconds.
Exact-length sequences (0.91). A sequence pattern without a rest matches exactly the number of elements it lists:
package main
import . "martianoff/gala/collection_immutable"
func shape(xs Array[int]) string = xs match {
case Array() => "empty"
case Array(x, y) => s"pair \(x,\)y"
case _ => s"${xs.Size()} elements"
}
func main() {
Println(shape(ArrayOf(1, 2)), "/", shape(ArrayOf(1, 2, 3)), "/", shape(EmptyArray[int]()))
}
This prints pair 1,2 / 3 elements / empty. To match "at least N", end the
pattern with _... or rest....
Defaults you declare
A sealed type compiles to one Go struct with a tag. Before 0.88, Go's zero
value for that struct read as whichever case was declared first. An
uninitialized Option, for example, was Some(0). Now you say what the zero
value is.
A default case is the zero value, and case fields take defaults,
like function parameters:
package main
sealed type Light {
case Off()
default case On(Power int = 30, Room string = "Living")
}
struct Home(Name string, L Light, Rooms int = 1)
func main() {
var l Light
val h = Home{Name: "flat"}
Println(l, h.L, h.Rooms)
Println(On(Room = "Kitchen"))
}
On(30, Living) On(30, Living) 1
On(30, Kitchen)
Option declares default case None(), so an Option nobody set is None.
These defaults are also what the generated Go constructors apply, which is
how NewPlan() in the first section knows to return Free().
A type with no default has to be given a value. Try and Either
deliberately have no default case, because there's no honest empty value for
"the result of something that ran." Leave one out, and the compiler names
the field:
package main
struct Job(Name string, Result Try[int])
func main() {
val j = Job{Name: "sync"}
Println(j.Name, j.Result)
}
error[GALA-E0075]: field "Result" of "Job" is left out, and Try has no default value: it is a sealed type without a default case
--> main.gala:6:13
|
6 | val j = Job{Name: "sync"}
| ^^^ give Result a value
|
= hint: give Result a value
Only a var has an address. A pointer can write, so & of a val, a
parameter or an immutable field is an error that suggests passing the value
itself, or declaring it var. That retired ConstPtr, the read-only pointer
wrapper &val used to produce. It also left &x with a single meaning,
which is what made Ptr[T] possible.
Interface literals
Interface literals (0.89) build an interface value inline, with one function per method, each given by name. The lambda types come from the method signatures. This is how GALA does test doubles and dependency injection. There's no container and no implicit parameters, just constructor parameters faked with a literal:
package main
import "fmt"
struct User(Id int, Name string)
type UserRepo interface {
Find(id int) Option[User]
Count() int
}
struct Greeter(repo UserRepo)
func (g Greeter) Greet(id int) string = g.repo.Find(id) match {
case Some(u) => s"hello, ${u.Name}"
case None() => s"no user \(id (of \){g.repo.Count()})"
}
func main() {
val fake = UserRepo(
Find = (id) => When(id == 1, User(1, "Ada")),
Count = () => 1,
)
val g = Greeter(fake)
Println(g.Greet(1), "/", g.Greet(2))
val label = fmt.Stringer(String = () => "a Stringer")
Println(label)
}
hello, Ada / no user 2 (of 1)
a Stringer
Go interfaces work the same way, as the fmt.Stringer shows. Leaving out a
method is a compile error that names it. The new
Dependency Injection guide is
built on this.
Opaque types
type UserID int64 in GALA is an alias: the same type under another name.
opaque type UserID int64 (0.85) is a distinct type. It compiles to a Go
defined type, so it costs nothing at run time. It also takes methods, works as
a HashMap key, and is encoded by the JSON and YAML codecs as the bare
number. It never converts implicitly:
package main
opaque type UserID int64
opaque type OrderID int64
func cancel(id OrderID) string = s"cancelled ${int64(id)}"
func main() {
val u = UserID(42)
Println(cancel(u))
}
error[GALA-E0064]: cannot use u (UserID) as OrderID: an opaque type never converts implicitly
--> main.gala:10:20
|
10 | Println(cancel(u))
| ^ convert through the underlying type if this is intended: Ord…
|
= hint: convert through the underlying type if this is intended: OrderID(int64(u))
An opaque type is for keeping IDs and units apart, not for encapsulation: any package can convert one explicitly. A value that has to be validated is still a struct with a private field and a constructor.
Under the hood
Type inference is local and bidirectional (0.89). A type is built up
from an expression's parts, and the slot a value fills pushes an expected
type into it. GALA used to keep a Hindley-Milner engine as a fallback. I
measured what it did: on examples/ it decided 449 of about 24,700 type
queries, almost all of them lookups the main pass had already answered. With
it switched off, the generated Go was byte-identical for all 395 example
files, so it's gone, about 2,300 lines net. The one thing it still handled,
calls that return nothing, the main pass now does directly.
Generic type aliases work end to end (0.85).
type Conv[A any, B any] func(A) B types the lambdas in its slots, and
type Twin[T any] Pair[T] infers constructor type arguments (Twin(1, 2)).
The compiler is fuzzed with whole programs (0.91). FuzzValueBlocks
generates programs that nest bind, if-expressions, matches, lambdas and
partial functions. Every program it accepts must produce Go that type-checks.
FuzzPrograms generates one- and two-package programs, with the library
imported qualified, under an alias, or dot-imported. It covers most of
pattern matching, and it computes the output each program must print. Every
accepted program is built, run, and its output compared. One local run of
FuzzValueBlocks checked 24,312 programs clean. A reviewer ran 360 programs
from FuzzPrograms through gala run, and every output and accept/reject
verdict matched. The pattern-matching features above were built with these
targets running.
Two smaller additions came out of that work. A bind block or an
if-expression branch can now end in an if/else chain that carries the
value. A partial function also takes its types from any typed slot, such as a
val, a return, or a struct field.
Upgrading
GALA is in beta, and several of these releases are breaking. Each release's notes have a migration table. The changes most likely to touch your code:
&xof avalis an error, andConstPtris gone (0.88).- An uninitialized
OptionisNone, and a value of a type with no default can't be left unset (0.88). - Named sub-patterns match by name, and a guarded case no longer covers its variant (0.88).
case Array(x, y)matches exactly two elements (0.91).- Std pointer types are
Ptr[...], so code that writes out*Array[int]for a mutable collection saysPtr[Array[int]](0.92). - Parameters and receivers are immutable bindings, and
val _ = expris an error: write the bare statement (0.85). StringBuilder.Length()counts characters, and the newByteSize()counts bytes (0.92).
Where this leaves things
A GALA library can now ship as a plain Go module, with its defaults, constructors and docs intact on the Go side. Inside GALA, a pointer is never nil, and a sealed type's zero value is one you declared. A pattern says what it matches, by alternative and by field name, and the compiler says what it's missing.
The gaps are real. Go reads GALA's immutable fields through .Get(). Generic
methods are free functions on the Go side. A Go generic's var zero T can
still produce a nil Ptr that GALA's types can't see. And gala export can't
yet export a project that depends on another GALA module.
The language reference is at gala.fyi/docs, the guide to calling GALA from Go is at gala.fyi/docs/go-interop, the source is on GitHub, and the playground runs GALA without installing anything.
About the author
- Staff Software Engineer at Snowflake. Expert in scalable architecture, cloud infra, and security. Passionate problem-solver and mentor.
Comments
Join the discussionNo comments yet. Be the first to comment.
GALA From the Ground Up
A ten-part technical blog series exploring GALA — a modern programming language that brings sealed types, pattern matching, monadic error handling, and immutable-by-default semantics to the Go ecosystem. Each post takes a single concept, shows the problem it solves with real code, compares it to idiomatic Go, and is honest about trade-offs. Written for Go developers who want more expressiveness and functional programmers who want Go's runtime.