IDE Setup¶
ts_refresh_tsconfig turns Bazel's build graph into the two things an editor can
read:
- A workspace-root
tsconfig.jsonwhosecompilerOptions.pathsnames every source root, path alias,module_nameand npm package your targets reach. This is the primary mechanism, and the file is meant to be checked in. - A tsserver plugin that resolves the same set live, following
bazel buildoutputs with no tsconfig reload. It is a layer on top of the generated file, and it needs editor configuration; the generated file needs none.
Setup¶
Declare the target once, in your root BUILD.bazel:
load("@rules_typescript//ts:defs.bzl", "ts_refresh_tsconfig")
ts_refresh_tsconfig(
name = "refresh_tsconfig",
test = True,
deps = [
"//apps/web",
"//packages/ui",
],
)
An aspect walks deps from each entry, so listing a target covers everything it
depends on. Two constraints:
deps = []is the attribute default, and it reaches nothing. No packages, no aliases, no npm entries: atsconfig.jsonwith an emptypaths, and an editor told nothing.depsobeys visibility, so a package-privatets_compiletarget cannot be listed here. Gazelle writesvisibility = ["//visibility:public"]on the targets it generates, and so doests_testfor thets_compiletargets it generates fromsrcs,setup_filesandglobal_setup://path:_my_test_compileis listable, and the npm packages only a test declares reach the tsconfig.visibilityon thets_testnarrows them again, and the generated targets follow it. Hand-written private targets are covered by Complete coverage for the resolution map.
Then run it:
That writes, into the source tree:
| Path | What it is |
|---|---|
tsconfig.json |
Compiler options and the paths map. Check this in |
.bazel/npm/ |
The .d.ts (and package.json) of every npm package the paths entries name, plus a .gitignore of * |
.bazel/tsserver-hook-data.json |
The same graph facts, in the shape the plugin reads |
.bazel/node_modules/@rules_typescript/tsserver-plugin/ |
The tsserver plugin, as a package tsserver can load by name |
.bazel/tsserver-hook.js |
A preload variant for a client that resolves through the public ts.resolveModuleName; see What the preload does not reach |
.bazel/tsserver-hook-resolver.js |
The map builder both front-ends share |
.bazel/tsserver-hook-worker.js |
Its background worker |
Two attributes move the first two. tsconfig (default "tsconfig.json") is
where the generated config lands. npm_dir (default ".bazel/npm") is where the
npm declarations land; npm_dir = "" opts out, dropping the npm paths entries
and their files for a workspace that resolves npm types some other way.
It replaces the file at tsconfig wholesale
A migrating repository already has a root tsconfig.json, and the first
bazel run //:refresh_tsconfig overwrites it: include, baseUrl,
module and every other option in it, not only paths. The generated file
is a complete config and carries nothing over from yours.
Move your own options before that first run. Keep them in a file of another
name and name that in ts_compile(tsconfig = ...), which is what the compile
actions read
(where compiler options come from).
Pointing ts_compile at the generated file instead makes it that target's
own baseline. Or set ts_refresh_tsconfig(tsconfig = "tsconfig.bazel.json")
and extends the generated file from yours.
Excluding Foreign TypeScript¶
The generated config leaves include at **/*, so tsc walks every .ts in
the repository, including trees outside this module's build graph: a nested Bazel
module, a workspace listed in .bazelignore, a vendored example. Nothing in
deps names those files, so they are checked under the wrong compilerOptions
and their errors are noise. extra_exclude adds globs to the generated
exclude:
ts_refresh_tsconfig(
name = "refresh_tsconfig",
test = True,
extra_exclude = ["**/e2e", "**/examples"],
deps = ["//apps/web", "//packages/ui"],
)
Anchor each entry with **/, the way the built-in exclusions
(**/bazel-*, **/node_modules, **/dist, **/build, **/.next,
**/.nuxt, .bazel) are.
Nested Tsconfigs¶
One compilerOptions block cannot serve a target that turns strict off beside
one that leaves it on, or a target naming a lib its target does not imply.
An editor resolves a file to a program by directory, so such a package needs
its own tsconfig.json next to its sources, and the root has to stop claiming
those files.
ts_refresh_tsconfig generates those files, and you declare which packages get
one:
ts_refresh_tsconfig(
name = "refresh_tsconfig",
test = True,
nested_tsconfigs = ["apps/worker/tsconfig.json"],
deps = ["//apps/web", "//apps/worker"],
)
The set is computed by comparing each target's options against the root block. Two details affect that comparison:
targetandjsx_modecount. They are rule attributes and notcompiler_optionsentries. A target setting either to something other than the root's value (ES2022,react-jsx) goes on the list.- Values are canonicalised before they are compared. TypeScript reads
target,module,moduleResolution,jsx,moduleDetectionandnewLinecase-insensitively and treatslibas a set, so"Preserve"and"preserve"are not a disagreement and neither is["esnext", "dom"]against["DOM", "ESNext"]. Folding both sides keeps a package that merely restates a default off the list, and keeps two targets spelling one value differently from reading as a conflict.
The rule fails when the declared list disagrees with the graph, in either
direction, and the message names what to add or remove. The list is declared
because glob() does not cross a package boundary, and a leftover entry would go
on owning its subtree in the editor. Each entry gets its own staleness
diff_test.
Each generated file extends the root and the package's own ts_compile
baseline, root first so the baseline wins. Inherited paths are not re-resolved,
so the root's aliases still work from down there; include and exclude are
re-resolved against the extending file, so they are written out. noEmit,
composite, incremental, rootDir and files are pinned in the file itself,
since a baseline inherited whole would emit into your source tree, reject files
outside one target's rootDir, and lose every ambient declaration.
A package whose targets set the same option to different values has no representation, since one directory cannot hold both answers. That is an error naming both targets; move one target into its own package.
Two different tsconfig baselines in one package is the same error.
TypeScript applies an extends array later-wins, so listing both baselines would
let one's keys replace the other's for both targets' sources. A package gets at
most one baseline, from whichever of its targets name one.
A target in that package naming no tsconfig inherits that baseline in the
editor, and does not in the build: the rule applies its own baseline options
(strict, module: Preserve, moduleResolution: Bundler, skipLibCheck,
esModuleInterop) in either mode, and with no tsconfig above them that is all
it gets — which is what the root block already holds.
The nested file's own compilerOptions restate every option any target in the
package sets explicitly and beat every extends, so a baseline reaches only keys
no target in the package has an opinion about. In //vite, :plugin_typecheck
names vite.tsconfig.json and :tsup_config names nothing, and the generated
vite/tsconfig.json pins the module/moduleResolution both targets ask for,
keeping the baseline's Node16 answer away from tsup.config.ts. Give the odd
target the same tsconfig, or its own package, when that is not close enough.
Bare Specifiers for First-Party Packages¶
A target that sets module_name = "@acme/ui" gets an @acme/ui/* paths entry
of its own, and @acme/ui too once the package has an index file, so the editor
resolves the same bare specifier ts_compile resolves during the build. Those
keys are written last, so a first-party module_name wins over a same-named npm
package, the precedence ts_compile's own generated tsconfig uses.
module_name also covers a pnpm link:/workspace: dependency imported by its
package name. Bazel resolves the hub's alias before Starlark sees it, so the name
the code imports exists only inside the alias; module_name on the target that
produces the declarations puts it back in the graph.
npm Declarations¶
Each npm package is its own lazily-fetched Bazel repository, living only under
<output_base>/external/, which nothing links into the execroot the
bazel-<workspace> symlink points at, so no workspace-relative path reaches it.
Copying the .d.ts into npm_dir makes a paths entry possible, and the copies
are keyed by package name, so the canonical repository name that changes on every
version bump never enters the config.
The copied .d.ts is the entry point the package's own metadata designates. See
how that is resolved.
The wildcard entry is rooted at that file's directory, so a package designating
dist/node/index.d.ts gets pkg → that file and pkg/* → dist/node/*:
Staleness Test¶
test = True adds a diff_test named <name>_test that compares the
checked-in tsconfig.json against the one the graph currently implies:
It fails whenever a dependency edit changes what the IDE should see:
Turn it on once the file is checked in.
Complete Coverage for the Resolution Map¶
deps is a rule attribute, so it reaches only what this workspace's visibility
lets a rule name. An aspect propagates along the dependency edges a build
already has and creates none, so it needs no grant. Two lines in .bazelrc turn
that on for every build:
build --aspects=@rules_typescript//ts/private:tsconfig_aspect.bzl%tsconfig_aspect
build --output_groups=+ide_fragments
Every target whose closure holds a source root, a path alias or an npm entry
then gets a <target>.tsconfig-fragment.json beside its other outputs in
bazel-out, and the resolver merges what it finds there into the map. +group is
additive, so this composes with --output_groups=+_validation and with anything
a command line adds, and any ordinary bazel build refreshes the fragments.
Both lines are optional. Without them nothing writes fragments and the plugin
works from .bazel/tsserver-hook-data.json alone. Fragments augment that file;
they never replace it, and every key it resolved wins over a fragment that
disagrees.
What Fragments Cover¶
| Covered by | Reaches package-private targets | |
|---|---|---|
ts_compile source roots |
fragments, and the data file | yes, via fragments |
module_name bare specifiers |
fragments, and the data file | yes, via fragments |
ts_path_alias prefixes |
fragments, the data file, and a BUILD-file scan | yes, via fragments |
npm .d.ts declarations |
.bazel/npm, installed by bazel run //:refresh_tsconfig |
no |
The npm row is the exception for the same reason .bazel/npm exists: a fragment
can only name the package, since nothing in the external repository has a
workspace-relative path. Whether that name resolves depends on what
bazel run //:refresh_tsconfig last installed, and that target's deps do obey
visibility.
The checked-in tsconfig.json does not change either. It stays what
refresh_tsconfig generates from deps, which is what a fresh clone, a plain
tsc run and every editor read. Fragments reach the plugin only.
Cost¶
- Each fragment carries its target's whole closure, at the cost of bytes:
any one fragment is a complete answer for its own subgraph, which makes a
partially built
bazel-outusable. - A deleted or renamed target leaves its fragment behind, because nothing
cleans
bazel-out. The resolver opens fragments only under directories the source tree still has a BUILD file for, and nothing enters the map unless the path it names exists on disk, so a stale fragment contributes nothing.bazel cleanis not needed.
Ambient Types in the Editor¶
The editor is more permissive than the build in one place. ts_compile names
only a target's direct @types/* deps in the tsconfig it gives tsgo, so a
global reaches a target because that target asked for it. The
editor program has one root compilerOptions block for the whole workspace, and
its ambient entries are the union of every @types/* package anywhere in the
graph. A file using process therefore type-checks in the editor even when its
own target never declared @types/node, and then fails bazel build with the
strict-deps error naming the label to add.
Narrowing the union per target would need a tsconfig per target, and a package
only gets its own program when its compilerOptions genuinely disagree with the
root (nested_tsconfigs). Narrowing it globally would
make the editor wrong for every target that does declare the dep.
Treat bazel build as the authority, and declare ambient packages up front.
# gazelle:ts_ambient_types
does that for a whole tree in one line.
Editor Configuration¶
The generated tsconfig.json needs no editor setup; every editor already reads
it, and it is what makes a Bazel-built declaration resolve in a fresh clone.
The rest of this section is for the plugin, which adds live resolution on top.
tsserver loads a plugin by name from a probe location. The plugin is installed as
a package under .bazel/node_modules/, so the probe location is .bazel and the
name is @rules_typescript/tsserver-plugin. Every recipe below is those two
facts in one editor's spelling.
VS Code¶
.vscode/settings.json:
That is the whole of it. VS Code passes no --globalPlugins, so the plugin has
to be named in the config the editor is using as well — but the generated
tsconfig.json already names it, and bazel run //:refresh_tsconfig keeps it
there. Do not add the entry by hand to a config that macro owns: the next
refresh rewrites the file whole and drops it, and tsserver logs and ignores a
plugin it cannot load, so the only symptom is imports quietly going unresolved
again.
A workspace whose editor config is its own file — ts_refresh_tsconfig(tsconfig
= "tsconfig.bazel.json") with a hand-written tsconfig.json that extends it
— inherits the entry through extends and needs nothing either.
Restart the TS server: Cmd+Shift+P → TypeScript: Restart TS Server.
Neovim (nvim-lspconfig with typescript-language-server)¶
require('lspconfig').ts_ls.setup({
init_options = {
plugins = {
{ name = "@rules_typescript/tsserver-plugin", location = ".bazel" },
},
},
})
typescript-language-server turns its plugins option into tsserver's
--globalPlugins and --pluginProbeLocations, so no compilerOptions.plugins
entry is needed with it.
Neovim (coc-tsserver)¶
coc-settings.json:
{
"tsserver.globalPlugins": [
{ "name": "@rules_typescript/tsserver-plugin", "location": ".bazel" }
]
}
Emacs (lsp-mode)¶
(setq lsp-clients-typescript-plugins
(vector (list :name "@rules_typescript/tsserver-plugin"
:location ".bazel")))
tsserver Directly¶
Any client that spawns tsserver itself takes the two flags:
--globalPlugins @rules_typescript/tsserver-plugin
--pluginProbeLocations /abs/path/to/workspace/.bazel
Coding Agent Harnesses¶
A coding agent that reads TypeScript usually runs a language server of its own
rather than an editor's. Claude Code, for example, installs
typescript-language-server and typescript. The short answer for those:
The generated tsconfig.json works with no configuration at all. It is a
checked-in file with a paths map, which is the mechanism every TypeScript tool
already reads. An agent's language server resolves a Bazel-built .d.ts through
it without knowing Bazel exists, and resolves it to the real declarations rather
than to any — a nonexistent member on an imported symbol is still an error.
Keep the file current with bazel run //:refresh_tsconfig, which the
staleness test will ask for.
The plugin needs the harness to let you configure the server, which is the
part that varies. If the harness exposes LSP initializationOptions, pass the
plugins entry from the
nvim-lspconfig recipe —
typescript-language-server is what most of them run. If it does not, the plugin
cannot be reached and the tsconfig.json is the whole answer.
Two things to know before reaching for a generic mechanism:
NODE_OPTIONSis not a way in. It propagates into the forked tsserver, so the preload does load there, but loading is not the same as taking effect; see What the preload does not reach.- A relative path in
NODE_OPTIONSis worse than useless. Node resolves--require ./x.jsagainst the process's cwd, and from any other directory the process fails to start at all —Cannot find module, withrequireStack: [ 'internal/preload' ], exit 1. An agent's language server would die rather than degrade. Absolute paths only.
To check what your harness actually gives you, ask its language server for
diagnostics on a file importing a Bazel-built package. TS2307 Cannot find
module before bazel run //:refresh_tsconfig and no diagnostic after it means
the tsconfig.json path works. TSSERVER_HOOK_DEBUG=1 in the server's
environment makes the plugin report on its stderr whether it loaded and how many
entries its map holds.
What the Preload Does Not Reach¶
.bazel/tsserver-hook.js patches the typescript module's exported
resolveModuleName. That reaches a client which builds a LanguageService host
itself and routes resolution through the public API. It does not reach a
standalone tsserver process, and every editor above spawns one.
Two measured facts, in case the distinction matters to you. lib/tsserver.js
loads its bundle as require("./typescript.js"), which the preload's matcher
does not accept, so the patch never installs. Widen the matcher so it does
install, and a real tsserver still reports TS2307 for the same import: the
language service resolves through its LanguageServiceHost, not through the
export. Decorating that host is what the plugin does, and it is why the plugin
exists.
How It Works¶
The plugin is TypeScript's equivalent of Go's
GOPACKAGESDRIVER,
with one difference: it never runs Bazel. Everything Bazel knows arrives
through .bazel/tsserver-hook-data.json, which refresh_tsconfig wrote at
analysis time. A long-lived editor process asking the Bazel server for anything
would sit on the same lock a build wants.
- Worker thread reads
.bazel/tsserver-hook-data.json— the npm entry points, thets_compilepackage list, themodule_namespecifiers, the path aliases — and turns it into a module-name → declaration-path map - npm packages resolved from the declarations installed under
npm_dir, the same set the generatedtsconfig.jsonnames - Internal packages resolved from
bazel-bin(.d.tsafter a build) or the source tree (.tsbefore one) - Fragments, if the
.bazelrclines above are in place, add the packages and aliases of every target the aspect reached, including the ones no rule may name. One target built in two configurations writes two fragments, deduplicated by label with the first config root in sorted order winning, so the merge does not depend on whatbazel-outholds - Path aliases come from that graph data, plus a scan of
# gazelle:ts_path_aliasdirectives in BUILD files to cover directives added since the last refresh. The graph wins, since it is what the build resolves - File watcher watches the graph data file, the root
BUILD.bazelandpnpm-lock.yaml, andbazel-binrecursively for new.d.tsand new fragments; a change to any of them rebuilds the map
The main thread is never blocked: the worker builds the map off-thread and posts it back. tsserver returns "unresolved" briefly on first load, then resolves when the worker completes.
Resolution Priority¶
.d.tsinbazel-bin— fast, precise (available afterbazel build).tssource file — always available, slower for tsserver to process- npm declarations under
npm_dir— whatever the lastbazel run //:refresh_tsconfiginstalled
What a Build Provides¶
First-party resolution works without bazel build, since the source .ts files
are always on disk. A build adds the .d.ts files and, with the aspect enabled,
the fragments naming the packages deps could not reach.
npm resolution is bounded by the refresh. The packages that resolve are the ones
reachable from deps when refresh_tsconfig last ran, in both the paths
entries and the plugin, so an import pulling in a package none of those targets
reached is unknown until you re-run the target. The staleness test asks for that
same re-run.
Debugging¶
Set TSSERVER_HOOK_DEBUG=1 in the environment the language server starts in.
The plugin and its worker then report on the server process's stderr: whether the
plugin loaded, which project it decorated, how many entries the map holds, and
each invalidation. It is the server's stderr and not the tsserver log, so where
it surfaces depends on the client.
Debugging Tests in vs Code¶
To attach a debugger to vitest running inside the Bazel sandbox:
ts_test(
name = "my_test_debug",
srcs = ["my.test.ts"],
deps = [":my_lib", "@npm//:vitest"],
tags = ["manual"],
env = {"NODE_OPTIONS": "--inspect-brk=9229"},
)
Vitest pauses before executing, waiting for a debugger on port 9229. Attach VS Code via "Attach to Node Process" or use chrome://inspect. Source maps are configured automatically.