Skip to main content

Command Palette

Search for a command to run...

From Zero to a Go Module on Bazel in One Sitting: Onboarding to GALA 0.53

Importing a third-party Go module through Bazel and rules_gala - with concrete types across the seam, verified hands-on.

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

There's a specific moment that decides whether a new language is worth your time: the first time you try to use something from the ecosystem you already depend on. For a language that transpiles to Go, that means pulling in a third-party Go module and having it just work — concrete types, real build, no interface{} leaking through.

I wanted to know if GALA passes that test today, so I did the honest thing: started in an empty directory with nothing but the gala CLI, Go, and Bazel on my PATH, and tried to build a program that imports a Go module through Bazel and rules_gala. No copying an example repo, no insider shortcuts. This post is the actual transcript, friction and all.

The short version: it works, the types come out concrete, and the whole thing is about four small files you write by hand plus two generated ones. There's exactly one wiring detail the main docs get wrong, and I'll show you the version that actually works.


The goal

A program that imports github.com/google/uuid — a real third-party Go module, not stdlib — generates a UUID, and prints it. Built and run entirely through Bazel with rules_gala. The interesting part isn't the UUID; it's whether GALA can read a third-party Go package's types well enough to produce concrete, compiling Go.

Versions used: gala 0.53.1, Go 1.25.5, Bazel 8.


Step 1 — Initialize the module and add the Go dependency

gala mod init example.com/hellouuid
gala mod add github.com/google/uuid@v1.6.0 --go
Initialized gala.mod for module example.com/hellouuid
Fetching github.com/google/uuid@v1.6.0...
Added github.com/google/uuid@v1.6.0 (Go dependency)

The --go flag is the whole story of GALA's dependency model: it marks a requirement as a Go package — fetched and linked, never transpiled — as opposed to a GALA library, which is fetched as .gala source and transpiled at build time. The resulting gala.mod is deliberately Go-mod-shaped:

module example.com/hellouuid

gala 0.53.1

require github.com/google/uuid v1.6.0 // go

No go.mod in your project yet, and that's intentional — GALA projects are driven by gala.mod.

Step 2 — Write the program

package main

import "github.com/google/uuid"

func main() {
    val id = uuid.New()
    Println(s"Fresh UUID: ${id.String()}")
}

That's the entire program. uuid.New() is a Go function returning a uuid.UUID; id.String() is a method on that Go type. Println and the s"..." interpolation are GALA built-ins — no fmt import needed. The question is whether the transpiler knows what uuid.New() returns. Hold that thought.

Step 3 — The Bazel wiring

Four files. The .bazelrc selects the rules_gala registry and passes the Go SDK through to the transpiler:

common --enable_bzlmod
common --noenable_workspace
build  --enable_runfiles

common --registry=https://raw.githubusercontent.com/martianoff/rules-gala/main/
common --registry=https://bcr.bazel.build

build  --action_env=GOROOT
build  --action_env=PATH

MODULE.bazel is normal bzlmod — the GALA toolchain, Go rules, and the Gazelle extension:

module(name = "hellouuid", version = "0.0.1")

bazel_dep(name = "rules_gala", version = "0.1.3")
bazel_dep(name = "gala", version = "0.53.1")
register_toolchains("@gala//tools/toolchain:gala_toolchain")

bazel_dep(name = "rules_go", version = "0.50.1")
bazel_dep(name = "gazelle", version = "0.39.1")
bazel_dep(name = "gala_gazelle", version = "0.2.5", dev_dependency = True)

go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(go_deps, "com_github_google_uuid")

And the root BUILD.bazelthis is the one line that matters:

load("@gala_gazelle//gala:defs.bzl", "gala_gazelle")

gala_gazelle(name = "gazelle")

The one gotcha. If you reach for the plain gazelle(name = "gazelle") rule from @gazelle//:def.bzl — the one you'd use in a pure-Go project — Gazelle only knows the Go language. It greets you with unknown directive: gazelle:gala_prefix and generates zero GALA targets. The fix is the gala_gazelle macro above: it builds a composite Gazelle binary that embeds both the Go language and the GALA language, plus a toolchain-driven import helper. Use it and BUILD generation covers Go, GALA, and mixed packages in one pass.

Step 4 — Generate, build, run

gala mod tidy notices the project is now a Bazel project (because MODULE.bazel exists) and emits a portable go.mod/go.sum carrying just the Go dependencies:

gala mod tidy

Then Gazelle writes the build target for you:

bazel run //:gazelle

It appends a gala_binary to BUILD.bazel, with the Go dependency already resolved to its Bazel label — I didn't type this:

gala_binary(
    name = "hellouuid",
    srcs = ["main.gala"],
    deps = ["@com_github_google_uuid//:uuid"],
)

Build and run:

bazel build //...
bazel run //:hellouuid

A real UUID, from a real Go module, through the full Bazel pipeline.


The part that actually matters: the types are concrete

Anyone can make interface{} compile. The test of "Go interop" is whether the transpiler understood the third-party type. Here's the Go that GALA generated for main.gala:

// Code generated by GALA transpiler. DO NOT EDIT.
package main

import "fmt"
import "martianoff/gala/std"
import "github.com/google/uuid"

func main() {
    var id = std.NewImmutable(uuid.New())
    fmt.Println(fmt.Sprintf("Fresh UUID: %v", id.Get().String()))
}

uuid.New() is called directly and its concrete uuid.UUID result flows into id.Get().String(). There is no any, no type assertion, no reflection. If the transpiler had failed to resolve uuid.New()'s return type, id would have been any and .String() would not compile — the build would have failed, not silently degraded. It compiled, so the type resolution is real.

This is the capability that landed recently: GALA reads the Go SDK at transpile time to resolve function signatures, struct fields, and method sets from third-party modules — not just stdlib. That's why the .bazelrc passes GOROOT and PATH into the build actions. It's also why (T, error) returns from Go functions get auto-wrapped into Try[T] at the call site: the transpiler can see the shape of what it's calling.


Was it genuinely simple? An honest scorecard

Yes — with one caveat I won't paper over.

What you write by hand: main.gala (6 lines), .bazelrc (~8 lines), MODULE.bazel (~18 lines), BUILD.bazel (3 lines + a directive). What's generated for you: go.mod/go.sum (by gala mod tidy) and the gala_binary target (by Gazelle). The dependency label resolution — the part that's tedious and error-prone in hand-written Bazel — is automatic.

The friction, named honestly:

  • The Gazelle wiring. The working recipe is the gala_gazelle macro from @gala_gazelle//gala:defs.bzl, documented in rules-gala's gazelle/README.md. If you follow only the main repo's dependency-management doc, which shows the plain Go gazelle rule, you'll hit unknown directive: gazelle:gala_prefix and produce no GALA targets. This is the single thing most likely to trip up a newcomer. (It's a doc gap, not a tooling gap — once you use the macro, it just works.)
  • Order of operations. gala mod tidy only generates go.mod once MODULE.bazel is present — it detects "Bazel project" by that file. Run the Bazel scaffolding first, then tidy.
  • Cold build cost. The first bazel run //:gazelle builds the GALA toolchain and the Go SDK from the registry — about a minute and a half on a cold cache here. Every build after that is single-digit seconds. This is ordinary Bazel behavior, not a GALA quirk.
  • Bazel-first. This is the Bazel path. For a quick single binary you can skip all of it and use gala build / gala run directly; the Bazel story is for projects that want reproducible, registry-pinned, polyglot builds.

None of these are blockers. The worst case costs you one web search to find the right Gazelle macro. After that, adding another Go dependency is two commands — gala mod add … --go then bazel run //:gazelle — and the BUILD file updates itself.


Why this is a bigger deal than a UUID

The reason "import a Go module" is the make-or-break test is that it's the entire value proposition of a transpile-to-Go language. If GALA's interop were shallow — stdlib only, or types collapsing to any at the boundary — then every time you reached for the Go ecosystem you'd fall out of the type system that made you choose GALA in the first place. You'd be writing Go with extra steps.

That's not what happened here. A third-party module dropped in, kept its types, and built through a production-grade Bazel toolchain with generated BUILD files. The recent work — third-party Go type inference, the extraction of rules_gala into its own registry-published module, and first-class Gazelle support — adds up to a build story where the polyglot seam is close to invisible. You get sealed types, exhaustive pattern matching, and Option/Try/Either on the GALA side, and the entire Go module ecosystem on the other, with concrete types across the boundary.

Try it before you commit to anything: the GALA Playground runs in the browser with no install. When you're ready for a real project, the steps above are the whole recipe — and rules_gala plus Gazelle make sure the build is the part you stop thinking about.

GALA From the Ground Up

Part 12 of 12

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 beginning

Pattern Matching in Go: How GALA Brings Sealed Types and Exhaustive Matching to the Go Ecosystem

Go 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