Learn · Learning Bazel
seedling
Seeing Your Project as a Graph
Your build already is a graph; this chapter makes it visible and asks it the question no language toolchain can answer — what breaks if I change this?
By the end of this chapter you will have asked your own project three questions and gotten exact answers: what a target depends on, what depends on a target, and what kind of thing each node in the graph actually is. The last one contains a surprise that sets up part three.
Give it something worth asking about
Chapter two's workspace has two flat targets. There is nothing interesting to ask about two things, so start by giving each language a shared library — the shape every real project has within a week.
On the Go side, a package the binary will call:
server/greeting/greeting.go
package greeting
import "fmt"
// Greet builds the message the server prints.
func Greet(name string) string {
return fmt.Sprintf("Hello %s", name)
}The greeting logic, moved out of main.
server/greeting/BUILD.bazel
load("@rules_go//go:def.bzl", "go_library")
go_library(
name = "greeting",
srcs = ["greeting.go"],
importpath = "example.com/greet/server/greeting",
visibility = ["//server:__subpackages__"],
)Its declaration. Note visibility — we will come back to it in chapter six; for now it means "only things under //server may depend on this."
The binary now depends on it by label:
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",
deps = ["//server/greeting"],
)
go_binary(
name = "server",
embed = [":server_lib"],
)deps is the edge. //server/greeting names the package; with no colon, the target takes the package's own name.
The TypeScript side gets the same treatment:
web/greeting/greeting.ts
export function greet(name: string): string {
return `Hello ${name}`;
}An exported function, no longer calling itself.
web/greeting/BUILD.bazel
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
ts_project(
name = "greeting",
srcs = ["greeting.ts"],
declaration = True,
transpiler = "tsc",
tsconfig = "//web:tsconfig",
visibility = ["//web:__subpackages__"],
)declaration = True makes it emit type declarations, so its consumer can typecheck against it.
Setting declaration = True on the rule without the matching compilerOptions.declaration in tsconfig.json fails the build with a message naming both. Two files have to agree, and the build system checks. They are one decision viewed twice.
Three levels now, in both languages: a binary, a library it depends on, and the sources underneath. Build it to be sure everything still works, then start asking questions.
What does this depend on?
$ bazel query 'deps(//server, 2)'
//server:main.go
//server:server
//server:server_lib
//server/greeting:greetingThe Go binary's dependencies, two levels deep, with external repositories filtered out. Every node is a target or a source file.
This is the direction you already know. Your compiler knows it too — follow the imports and you get the same answer. Useful, but not new.
What depends on this?
Now run it backwards:
$ bazel query 'rdeps(//..., //server/greeting)'
//server:server
//server:server_lib
//server/greeting:greetingEverything in the workspace that would be affected by a change to the shared Go library. rdeps means reverse dependencies.
Stop here for a second, because this is the chapter.
You just asked what breaks if I change this file and got an exact answer. Not a guess, not a grep, not a mental model somebody maintains — a complete list, derived from declarations the build system already had to have in order to work at all.
deps is a question your compiler can answer. rdeps is not. Nothing in go build or tsc knows what depends on a package, because nothing ever told them about the parts of the project that are not the thing being compiled right now.
On three targets the answer is obvious. On three thousand it is the difference between a refactor you can reason about and one you do by feel. This is also the question every continuous-integration system wants answered, which is why chapter thirteen can build only what changed rather than everything.
Your compiler is a cook who can read one recipe and tell you what ingredients it needs. The build system is the person who owns the whole cookbook, and can tell you which dishes are ruined if the milk goes off.
What kind of thing is each node?
The graph is not only targets and sources. Ask what each direct dependency of the TypeScript project actually is:
$ bazel query 'deps(//web:web, 1)' --output=label_kind
source file //web:greet.ts
ts_config rule //web:tsconfig
ts_project rule //web:web
ts_project rule //web/greeting:greeting
alias rule @@aspect_bazel_lib+//lib:coreutils_toolchain_type
options rule @aspect_rules_ts//ts:options
config_setting rule @npm_typescript//:is_typescript_5_or_greater
js_binary rule @npm_typescript//:tsc
js_binary rule @npm_typescript//:tsc_worker
js_binary rule @npm_typescript//:validatorThe TypeScript project's direct dependencies, labeled by kind. The first four are yours; everything below them is the compiler and its supporting targets.
There it is: @npm_typescript//:tsc, sitting in the dependency list next to your source file.
The TypeScript compiler is a dependency of your code. Not an ambient tool the machine happens to have, not a thing you install before building — a node in the graph, at a pinned version, with an edge pointing at the target that uses it.
This is the idea chapter two mentioned in passing and part three builds on properly. It matters because of what follows from it: if the compiler is an input, then changing the compiler changes the output, so the build knows to redo the work. And if the build knows exactly which compiler produced an artifact, two machines with different compilers installed cannot silently disagree.
@ at the start of a label means the target lives in a different workspace — one your build fetched rather than one you wrote. Your own labels start with //.
You can also see validator in that list, which is the target that produced the tsconfig mismatch warning mentioned earlier. Nothing in this system is hidden machinery; it is all targets, and you can look at any of it.
A cycle your compiler will happily ship
The graph is not only a thing to query. It is a thing with rules, and here is one your own tools do not enforce.
Two TypeScript modules that need each other:
import { punctuate } from '../format/format.js';
export function greet(name: string): string {
return `${prefix()}${punctuate(name)}`;
}
export function prefix(): string {
return 'Hello ';
}The greeting module imports the format module.
import { prefix } from '../greeting/greeting.js';
export function punctuate(name: string): string {
return prefix().length > 3 ? `${name}!` : name;
}And the format module imports greeting back. Each needs a value from the other at call time.
Type-check it:
$ tsc --noEmit --strict greeting.ts
$ echo $?
0The TypeScript compiler is satisfied. Circular imports are legal, and nothing here is a type error.
Nothing wrong was found because, by the compiler's standards, nothing is wrong. Whether this program works depends on which module the runtime happens to evaluate first — and if it picks badly, you get a binding that is declared, typed, visible in your editor, and undefined.
Now declare the same two modules as targets and build:
$ bazel build //web/...
ERROR: in ts_project rule //web/format:format: cycle in dependency graph:
//web/format:format
.-> //web/format:format
| //web/greeting:greeting
`-- //web/format:format
ERROR: Analysis of target '//web/format:format' failed; build abortedThe build refuses during analysis, before compiling anything, and prints the loop it found.
The graph is acyclic by construction, so a cycle is not a warning to be triaged — it is a graph that cannot exist. The build cannot order the work, so it stops and shows you why. Your compiler had no such constraint, because it was never asked to order anything beyond one file's imports.
Go, for what it is worth, rejects import cycles too — this is one of the places its designers made the stricter choice. TypeScript did not, and the result is a class of bug that type-checks, ships, and surfaces as a mysterious undefined in production.
Worth doing in your own repository: a cycle check is cheap to write over the import graph you already have, and the first run is usually informative. Count only value imports — import type is erased before the code runs, so a cycle closed by one exists in the type graph and nowhere else.
The graph is not a diagram
It is tempting to treat this as documentation — a nice picture of the project. It is stronger than that. The graph is not a description of the build, it is the build: the same structure that decides what to rebuild, in what order, and what may run in parallel.
That is why the answers are exact rather than approximate. A wiki page about your architecture can be wrong. This cannot be wrong without the build also being wrong, and the build being wrong is loud.
deps walks the graph forward, which your compiler could also do. rdeps walks it backward — what breaks if I change this — which nothing in a single-language toolchain can answer. --output=label_kind shows what each node is, and reveals the compiler sitting in the graph as an ordinary pinned input.
Try this in your own repository
Draw your own dependency graph, then check it. Sketch what you think depends on your most-changed shared module — from memory, before looking. Then find out for real, however your tooling allows. The gap between the sketch and the answer is the part of your architecture that currently exists only as folklore.
Go looking for a cycle. Most large TypeScript codebases have at least one. Write the check if you do not have it — walking imports is an afternoon, and counting only value imports is the part that makes it usable. If the first run finds nothing, that is worth knowing too.
What you can now do
Ask your project what depends on any file in it and get a complete answer, and explain why the compiler appearing in a dependency list is a feature rather than a leak. The next chapter takes the graph's promises and starts enforcing them — with a build that fails on a file that is sitting right there on disk.