Skip to main content

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.

Updated 16 min readMarkdown
Part of seriesGALA From the Ground Up

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:

  • &x of a val is an error, and ConstPtr is gone (0.88).
  • An uninitialized Option is None, 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 says Ptr[Array[int]] (0.92).
  • Parameters and receivers are immutable bindings, and val _ = expr is an error: write the bare statement (0.85).
  • StringBuilder.Length() counts characters, and the new ByteSize() 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.

No comments yet. Be the first to comment.

GALA From the Ground Up

Part 14 of 14

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.

Start from the beginningPattern Matching in Go: How GALA Brings Sealed Types and Exhaustive Matching to the Go EcosystemGo is a language built on simplicity. But simplicity has costs, and one cost Go developers feel acutely is the lack of pattern matching and algebraic data types. If you have ever modeled a closed set