Monorepo Layout¶
bazel run //:gazelle infers the targets from the tree. Every directory holding
.ts files becomes a ts_compile target, every test file a ts_test:
my-monorepo/
├── MODULE.bazel
├── pnpm-lock.yaml # single lockfile for all packages
├── packages/
│ ├── ui/
│ │ ├── BUILD.bazel # ts_compile(name = "ui", ...)
│ │ └── index.ts
│ ├── utils/
│ │ ├── BUILD.bazel # ts_compile(name = "utils", ...)
│ │ └── index.ts
│ └── config/
│ ├── BUILD.bazel
│ └── index.ts
└── apps/
└── server/
├── BUILD.bazel # ts_compile that depends on //packages/ui, //packages/utils
└── main.ts
Package Boundaries¶
Gazelle's default is every-dir, the way every directory with .go files is a
Go package. Two directives depart from it:
# gazelle:ts_package_boundary index-only for the older
index.ts-marks-a-package behaviour, and # gazelle:ts_target_name to rename
one target.
Write a target by hand when a directory is a genuine unit: a public API behind an
index.ts, something other packages import, something published as its own npm
package.
# packages/utils/BUILD.bazel
load("@rules_typescript//ts:defs.bzl", "ts_compile")
ts_compile(
name = "utils",
srcs = ["index.ts", "string.ts", "number.ts"],
visibility = ["//visibility:public"], # allow other packages to depend on this
)
# apps/server/BUILD.bazel
load("@rules_typescript//ts:defs.bzl", "ts_compile")
ts_compile(
name = "server",
srcs = ["main.ts"],
deps = [
"//packages/utils",
"//packages/ui",
"@npm//:express",
],
)
Cross-Package Dependencies¶
.d.ts files are the compilation boundary between packages:
# //lib/BUILD.bazel
ts_compile(
name = "lib",
srcs = ["math.ts"],
visibility = ["//visibility:public"],
)
# //app/BUILD.bazel
ts_compile(
name = "app",
srcs = ["main.ts"],
deps = ["//lib"],
)
If lib/math.ts changes but its exported types don't change, app is not
recompiled. Bazel's content-based caching uses the .d.ts fingerprint as the
dependency boundary.
Every import has to be satisfied by a direct dep. A .d.ts that reaches a
target through another dep's own deps does not count, so the deps list above is
the whole truth about what apps/server may import; bazel run //:gazelle
keeps it that way. See
Deps have to be direct.
Relative imports across packages work as written. For a bare specifier,
import { Button } from "@acme/ui", set module_name = "@acme/ui" on the
producing target; the dependent then gets a paths entry pointing at whatever
.d.ts Bazel produced for it. Do not hand-write that entry, and do not point
path_aliases into bazel-out/: both break under a different configuration, and
the second is a hard error. See
ts_compile.
Single pnpm Lockfile¶
Use a single pnpm-lock.yaml at the repo root covering all packages, and one
npm.translate_lock call for it. That avoids the version conflicts per-package
lockfiles create, and it costs nothing in fetch time: the extension declares one
Bazel repository per package and Bazel fetches only the ones your targets
actually reach, so a 2731-entry lockfile does not make a one-package build slow.
pnpm workspaces work: a workspace:* dependency resolves to a target in your own
repository.
Several hubs are supported. Reach for one when a closure has no business in the
tree your app's tests resolve against, or when a lockfile is a curated fixture no
pnpm add should regenerate. The cost is two lockfiles to
keep in step, a Gazelle directive per package, and one ts_add_package target
per hub. See More than one hub.
Visibility¶
Set visibility = ["//visibility:public"] on packages that other packages depend
on. Keep leaf-node packages at ["//visibility:private"] unless needed
externally. Gazelle writes //visibility:public on every ts_compile target it
generates.
ts_refresh_tsconfig's deps is a normal rule attribute, so it obeys visibility
too. A package-private target cannot be listed there, and the IDE's
tsconfig.json carries no paths entry for it, because the aspect never reaches
it. In a hand-written monorepo, the targets you want your editor to see need a
visibility grant to the root package. See
IDE Setup.
The ts_compile targets ts_test generates from your sources take the test's
visibility, and are public when it declares none. An IDE tsconfig can
therefore see the npm packages only a test declares.