Skip to content

Monorepo Layout

bazel run //:gazelle writes one package per tsconfig.json: the directory holding the file gets a ts_compile over what the program lists, a ts_test over its test files, and a ts_config over the file:

my-monorepo/
├── MODULE.bazel
├── pnpm-lock.yaml          # single lockfile for all packages
├── tsconfig.json           # the base every package extends; //:tsconfig
├── packages/
│   ├── ui/
│   │   ├── BUILD.bazel     # ts_compile(name = "ui", ...), ts_config
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/index.ts
│   └── utils/
│       ├── BUILD.bazel     # ts_compile(name = "utils", ...), ts_config
│       ├── package.json
│       ├── tsconfig.json
│       └── src/index.ts
└── apps/
    └── server/
        ├── BUILD.bazel     # ts_compile that depends on @npm//:ui, @npm//:utils
        ├── package.json
        ├── tsconfig.json
        └── src/main.ts

Package Boundaries

A package is a TypeScript project: the unit tsc -p compiles is the unit Bazel builds, and the tsconfig's include, files and exclude say what it holds. A directory under a package with no tsconfig.json of its own is part of the package above; a directory with one is a package of its own, and the parent's edge into it is a dep. See the package model.

# packages/utils/BUILD.bazel
load("@rules_typescript//ts:defs.bzl", "ts_compile", "ts_config")

ts_compile(
    name = "utils",
    srcs = [
        "package.json",
        "src/index.ts",
        "src/number.ts",
        "src/string.ts",
    ],
    tsconfig = ":tsconfig",
    visibility = ["//visibility:public"],
)

ts_config(
    name = "tsconfig",
    src = "tsconfig.json",
    visibility = ["//visibility:public"],
    deps = ["//:tsconfig"],
)
# apps/server/BUILD.bazel
load("@rules_typescript//ts:defs.bzl", "ts_compile", "ts_config")

ts_compile(
    name = "server",
    srcs = [
        "package.json",
        "src/main.ts",
    ],
    tsconfig = ":tsconfig",
    visibility = ["//visibility:public"],
    deps = [
        "@npm//:ui",
        "@npm//:utils",
        "@npm//apps/server: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 what apps/server may import; bazel run //:gazelle keeps it current from tsgo's listing of each program. See Deps have to be direct.

A relative import into another package resolves to that package's ts_compile. A bare specifier, import { Button } from "@acme/ui", names a workspace member, which the dependent reaches through the hub's view of it, @npm//:acme_ui, as it reaches any npm package; see importing another target by bare specifier.

Single pnpm Lockfile

One pnpm-lock.yaml at the repo root covers all packages, with one npm.translate_lock call for it. The extension declares one Bazel repository per package and Bazel fetches only the ones a target reaches, so a 2731-entry lockfile does not slow a one-package build.

pnpm workspaces work: a workspace:* dependency resolves to a target in your own repository.

A second hub keeps a closure out of the tree an app's tests resolve against, or keeps a curated fixture lockfile out of pnpm add's reach. It costs two lockfiles to keep in step, one ts_add_package target per hub, and hand-written deps under # keep for the packages on it, since Gazelle writes @npm alone. 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. A hand-written target the editor should see needs a visibility grant to the root package. See IDE Setup.

A ts_test is listed there itself, under its own visibility; the targets over that list are testonly for it. An IDE tsconfig can therefore see the npm packages only a test declares.