Learn · Learning Bazel
seedling
One Command, Two Languages
A Go binary and a TypeScript project in a single workspace, built by a single command — and the first place the build system tells you something your own tools never could.
By the end of this chapter you will have a workspace containing a Go binary and a TypeScript project, built and run by one tool. You will be able to read a target declaration and say what it depends on, and you will have seen the first hint of the idea that carries the rest of the book: the compiler is an input too.
Where we left off
Chapter one ended with a build that lied, and a diagnosis: Make knows what you declared, not what your recipe read. The fix is a system that runs each step somewhere the undeclared file is not present.
We will get to that enforcement in chapter four. First we need something to enforce, which means writing the same message-building program again — this time in two real languages, under a tool that insists on knowing what each piece is made of.
The workspace
Start an empty directory. One file makes it a workspace:
MODULE.bazel
module(name = "greet")
# Go.
bazel_dep(name = "rules_go", version = "0.61.1")
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.26.5")
# TypeScript. rules_ts needs a compiler version, which is the chapter's
# point in miniature: the compiler is an input too.
bazel_dep(name = "aspect_rules_js", version = "3.2.2")
bazel_dep(name = "aspect_rules_ts", version = "3.8.11")
bazel_dep(name = "rules_nodejs", version = "6.7.4")
node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(node_version = "22.22.2")
rules_ts_ext = use_extension("@aspect_rules_ts//ts:extensions.bzl", "ext")
rules_ts_ext.deps(ts_version = "5.9.3")
use_repo(rules_ts_ext, "npm_typescript")Twenty lines. Two languages, both pinned to exact versions — including the compilers.
Read it in three parts.
module(name = "greet") names the workspace. That is the whole declaration; there is no directory scanning and no magic.
The Go half asks for the Go rules and a Go SDK at an exact version. Note what that means: the compiler is not something the machine happens to have. It is a version you wrote down, fetched by the build, and identical for everyone who builds this project.
The TypeScript half does the same for Node and for TypeScript itself. rules_ts_ext.deps(ts_version = "5.9.3") is the first quiet appearance of an idea this book will keep returning to — the compiler is an input like any other, and inputs have versions.
There is no package manager here yet, and no lockfile. That arrives in chapter ten, when we want a third-party library. Until then the only external thing either language needs is its own compiler.
The Go side
Two files. The program:
server/main.go
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}Ordinary Go. Nothing about the build system appears in the source, and nothing ever will.
And its declaration:
server/BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "server_lib",
srcs = ["main.go"],
importpath = "example.com/greet/server",
)
go_binary(
name = "server",
embed = [":server_lib"],
)A library made of one source file, and a binary that embeds it.
A BUILD file is a list of targets. Each target has a name, a rule that says what kind of thing it is, and attributes saying what it is made of. srcs is what it is built from. embed is how a Go binary absorbs a library. The load() line at the top says where the rules come from — rules are not built in, they are libraries, and you can see which one you are using.
Two targets in that file, so two names: //server:server_lib and //server:server. That syntax is a label, and it is worth reading carefully now because the entire graph is made of them. Everything before the colon is a directory path from the workspace root. Everything after is a target name inside it.
The TypeScript side
Same shape, different language:
web/greet.ts
export function greet(name: string): string {
return `Hello ${name}`;
}
console.log(greet('world'));Ordinary TypeScript, exporting a function and calling it.
web/tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true
}
} The compiler settings, in the file TypeScript programmers already keep them in.
web/BUILD.bazel
load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
ts_config(
name = "tsconfig",
src = "tsconfig.json",
)
ts_project(
name = "web",
srcs = ["greet.ts"],
# tsc both type-checks and emits. A later chapter splits those apart;
# here one tool doing both keeps the graph small.
transpiler = "tsc",
tsconfig = ":tsconfig",
)The TypeScript declaration. ts_config makes the settings a target; ts_project is the code.
Two things here surprise people.
The first is that tsconfig.json gets its own target. It is an input — change it and the output changes — so it is a node in the graph like any source file, and other targets refer to it by label.
The second is transpiler. TypeScript's compiler does two jobs: checking types, and emitting JavaScript. Most tools fuse them, and most programmers have never had to think of them as separable. Here you must choose. We pick tsc for now, which does both, and chapter fifteen comes back to why you might not.
Leave transpiler out and the build refuses to start, with a message telling you to pick one. It is a good error — better than a default that quietly makes a decision you did not know was being made.
Build it
One command:
$ bazel build //...
INFO: Analyzed 7 targets (8 packages loaded, 83 targets configured).
INFO: Build completed successfully, 38 total actionsA cold build with nothing cached. The Go SDK and the TypeScript compiler are both downloaded, because they are inputs and this machine has not fetched them yet.
//... means every target in the workspace. Seven targets, from two languages, in one command — and you never said which order to build them in, or that they were independent, or that the Go SDK had to be fetched first.
Run them:
$ bazel run //server
Hello world
$ node bazel-bin/web/greet.js
Hello worldBoth halves work. The Go binary runs through the build system; the emitted JavaScript is an ordinary file that Node can run directly — for now.
You declared what each target is made of. You never declared the order. The order is a consequence of the graph, computed rather than maintained — which is why it cannot go stale when someone adds a dependency.
The version you wrote down is not the version you get
Before saying what the build just did, it is worth watching your own tools do the thing it prevents. This is a different failure from chapter one — nothing here is stale, and nothing is undeclared. The declaration is simply not enforced.
Put this in an empty directory:
go code/learning-bazel/ambient-toolchain/version.go slices.Sorted over an iterator arrived in Go 1.23. On anything older this is not a subtle behavior difference — it is a compile error.
Next to it, a module file claiming a much older language version:
$ cat go.mod
module probe
go 1.19The module file says 1.19. The source needs 1.23. These disagree by four years of language evolution.
Now build it:
$ go build ./...
$ echo $?
0
$ go run version.go
[everybody friends world]No error. No warning. The build succeeds, the program runs, and the answer is right.
That should be surprising. The module file makes a claim about the language version, the source violates it by four years, and nothing objects.
The reason is that the go directive is a minimum language version request, not a pin. What actually compiled this program is the toolchain installed on this machine:
$ go version
go version go1.26.5 darwin/arm64The thing that really decided the outcome, and it is written down nowhere in the repository.
Hand the identical directory to a colleague running Go 1.21 and they get a compile error — same source, same module file, same commit. The difference between success and failure lives entirely outside the repository, in a fact about their laptop that nobody wrote down and nobody can review.
This is why "works on my machine" survives as a phrase. It is not carelessness; it is an accurate report about a variable the project never declared.
What just happened that your own tools cannot do
Now look again at what you built a moment ago.
Two compilers ran, at versions written in a file you committed, fetched by the build itself. Nothing about your machine's installed Go or Node mattered — and a colleague cloning this workspace onto a laptop with no Go at all gets the same result, because the build brings its own.
The compiler is an input. Once it is written down and fetched like any other input, "which version compiled this?" stops being a property of whoever ran the build and becomes a property of the commit.
That is the first thing here that go build and tsc genuinely cannot do for you. Not because they are badly made, but because the question "which compiler?" is outside the job either of them signed up for. A tool that owns one language has to take the toolchain as given. A tool that owns the whole graph can put it in the graph.
Two cooks in one kitchen, each with their own recipe. You did not tell them who goes first — you told them what each dish is made of, and the kitchen worked out that the salad and the soup have nothing to do with each other and can happen at the same time.
What is still missing
Nothing so far stops a Go file from reading something nobody declared. The BUILD files are still promises, exactly like chapter one's dependency line, and nothing has checked them.
The difference is that now there is a graph precise enough to check against. The next chapter makes that graph visible and asks it questions — including one your own tools cannot answer at all.
A workspace is a module file plus BUILD files full of targets. A target has a name, a rule, and attributes saying what it is made of; labels like //server:server are how targets refer to each other. Compilers are pinned inputs, not machine state. Order is computed from the graph, never written down.
Try this in your own repository
Find out what actually compiled your last release. Not which version your manifest requests — which compiler binary ran. Check your CI configuration, your Dockerfile, your team's setup instructions, and your own machine. If those four disagree, you have found the gap this chapter is about, and the interesting part is which one produced the artifact your users are running.
Ask a colleague to build your project from a clean checkout. No instructions beyond what is in the repository. Note every question they have to ask you. Each one is a build input that lives in your head rather than in version control.
What you can now do
Build a Go binary and a TypeScript project from one workspace with one command, read a target declaration and name its inputs, and explain why the compiler version belongs in the repository rather than in a setup document.