IDE Setup¶
ts_refresh_tsconfig turns Bazel's build graph into the two things an editor can
read:
- A workspace-root
tsconfig.jsonwhosecompilerOptions.pathsnames everyts_compilepackage your targets reach, source directory andbazel-bintwin. The file is 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.
Generated sources in native editors¶
For generated sources or declaration trees, set
ts_refresh_tsconfig(tsconfig = None, generated_sources = True, deps = [...]).
The macro rejects generated_sources = True with any other tsconfig value,
including its default: native editors need the authored solution wrapper below
to discover these projects.
Name the owning program, including its tests when they share the compiler program.
Refresh materializes the generated inputs in Bazel's output tree and installs
<package>/.bazel/tsconfig/<target>.json; it preserves authored configurations
and source-side generator outputs. Ignore .bazel/ in version control.
Each installed project includes the roots observed for its target. Add new
sources to the Bazel graph and refresh before the editor checks them; authored
include patterns cannot pull sibling targets into the installed project.
Explicit generated files entries must name the exact selected filename and
extension. Refresh rejects an entry absent from the compiler's loaded roots,
including a missing child of a declared generated tree, and preserves the previous
editor project; ordinary compilation is unchanged.
Keep the authored program in tsconfig.build.json, selected by
ts_config(name = "tsconfig", src = "tsconfig.build.json") with # keep on
src. Gazelle reads that selected file and ignores derived editor projects.
The tracked tsconfig.json extends the build config, sets files and include
to empty arrays, and references ./.bazel/tsconfig/<target>.json.
The generated-input fixture shows this
setup. Package-local placement preserves ancestor lookup for ambient type packages.
Run bazel run --run_validations=false --output_groups=-_validation //:refresh_tsconfig
before opening the editor and after changes to BUILD files or inputs Bazel tracks.
The output-group flag cancels an explicit +_validation in a workspace rc. Use
ibazel run with the same arguments to rerun refresh for those changes;
--watchfs alone does not rebuild. Bazel owns generation and the editor's
established client owns file-change notifications. No tsserver plugin is required
for this mode.
Refresh also installs the current plugin package so an already enabled global plugin respects the projected aliases. After upgrading the ruleset, run refresh and restart the TS server once to load the updated plugin. Later BUILD or generator-input refreshes use the same running editor session.
When refresh serves a generated file from a cache, Bazel replaces it within about a millisecond. tsserver's own file watcher can miss that replacement and keep the old declaration, for example a completion for a deleted generated member, until the TS server restarts. Restart it if the editor shows a generated declaration that the refreshed file no longer has.
Derived projects retain the native compiler’s effective resolution mode, including defaults omitted from its printed configuration. The editor compiler must support that mode and the authored options. For example, TypeScript 5.9 rejects Bundler resolution with CommonJS modules; use a compatible editor compiler for that program rather than changing resolution to hide the diagnostic.
Projection runs --noEmit --traceResolution --explainFiles when refresh requests
it. It performs semantic checking to collect missing reference-path diagnostics
and uses --//ts:checkers for checker threads and Bazel's CPU reservation.
Consumer semantic errors do not block projection, but generators and declaration
producers must succeed. In particular, a dependency's type error can prevent
declaration emission with --//ts:declarations=tsgo. Tracing retains failed
ordinary import resolutions even when their diagnostics are suppressed. It adds
output and bypasses the compiler's resolution-result cache. Refresh cost is not
measured.
Refresh rewrites aliases only where generated output resolution requires it,
preserving authored specificity and fallback order. An alias selecting an
authored file inside a generated tree uses that compiler-selected file when
relocating its candidates would hide it, including extensionless and .js
spellings. Changed selections need another refresh. Other authored aliases and
npm resolution stay native. Tree aliases and scalar aliases directly naming their
generated file relocate the original ordered candidates, so adding or deleting
a preferred candidate changes native fallback without project regeneration.
Other scalar aliases use the compiler's exact file selection; new source or
import choices that need exact selection require another refresh. Generated
JSON inputs use the same projection when resolveJsonModule enables their imports.
Refresh rejects a synthesized tree wildcard when it would outrank an overlapping
authored suffix alias. Authored aliases keep their key order, including the first
match among equal-prefix patterns such as #x/*z and #x/*. Synthesized wildcard
aliases follow authored aliases, so an equal-prefix synthetic pattern cannot replace the
authored winner. Unrelated suffix aliases retain native resolution. Refresh projects
a broad alias such as #src/* → ./src/* over a generated subtree
src/generated for imports such as #src/generated/value. At the directory
boundary, refresh rejects a projection that hides an observed authored sibling
resolution: moving generated/model hides that import's generated/model.ts,
while keeping the checkout directory can revive a deleted generated index. Name
the authored file explicitly in a distinct alias, or name a file inside the tree.
Refresh checks the current compiler program; a new source or import choice
introducing a boundary sibling needs another refresh.
Generated tree aliases use canonical output paths; relative
imports and re-exports within those outputs stay in that namespace when a child
is replaced or deleted. The editor project has no workspace/output rootDirs
overlay through which a deleted child could resolve to a stale checkout twin.
Generated typeRoots also point into canonical output; authored type roots
retain native type-package lookup. Refresh rejects a relocated root when explicit
or implicit type-package lookup, or a source /// <reference types="..." />
directive, selects an authored package inside it, including with types: [].
Separate authored and generated type roots so both retain their identity.
A type root containing a generated child package or scalar declaration cannot
exclude stale checkout packages. Refresh rejects that shape before replacing
the installed project: generate the whole
type root or use an authored editor project without generated_sources.
A generated declaration named by a path-shaped compilerOptions.types entry is
compiled at its source path, so an exclude pattern matching that source path
drops it as well, in builds and in the editor project alike.
Refresh rejects compiler-reported relative edges between authored sources and
generated output in either direction, including explicit paths through
bazel-out. Installation binds output paths outside the checkout, where those
relative imports cannot retain their selected files. Use a
compilerOptions.paths alias for the crossing. Relative edges within authored
sources or within generated output remain native, including authored imports of
an emitted library whose build resolves through declarations beside its sources.
Refresh also rejects relative edges that need the build's rootDirs overlay and
compiler-reported unresolved relative references into a declared generated tree,
including when the canonical child is absent. For scalar reference paths, this
includes extensionless references whose compiler-reported candidates name a
generated file. A tsconfig cannot redirect those edges while excluding a checkout
twin; use an alias or an authored editor project without generated_sources.
Preventing stale checkout sources from resolving is best effort, even after
refresh. With current supported compilers, missing
/// <reference path="..." /> targets may appear only as suppressible diagnostics;
suppression can leave refresh without the reference facts needed to reject the
edge. This guarantee can be tightened when a supported compiler release exposes
those facts independently of diagnostic suppression.
Refresh also rejects first-party package imports or exports that reach
generated output through checkout paths, including missing targets with stale
checkout twins and package fallback after all matching paths targets fail.
Use a compilerOptions.paths alias into generated output or an
authored editor project without generated_sources. Package imports already
resolving within canonical output retain native resolution.
Generated refresh also rejects a workspace member reached through an npm link when its dependency closure contains original generated compiler inputs, including scalar sources, JSON inputs, and generator declaration trees. The build uses the member's store copy, while the editor follows pnpm's checkout link; refresh has no mapping between those identities. This is a whole-member restriction, even when the current compiler listing does not select the generated input. The refusal names the member link and a generated producer witness before installation. Authored members remain supported when only their emitted declarations or manifests are generated. Direct generated inputs outside workspace-member links retain the projection support described above; ordinary builds remain supported in either case.
Refresh materializes the producer-owned package.json beside generated inputs so
NodeNext retains their module format on a clean refresh. An effective authored
package scope with imports or exports enclosing relocated generated inputs is unsupported:
its relative namespace changes, including queries the compiler has not observed.
Refresh rejects it before publishing. Generated package scopes retain their
producer path and contain only generated members in the compiler-loaded program,
whether scalar or tree outputs. Config-only inputs and declared but unloaded
files do not count as program members; compiler-loaded JSON counts even when it
also supplies configuration. Refresh rejects scopes mixed with retained authored
members, including files whose module format would change, and renamed producer manifests. Authored
siblings outside the scope do not conflict. A nearer complete generated package
scope owns its private imports and self-references, so an outer authored scope
does not block refresh. Authored inputs keep their original scope.
Generated sources may require a wider rootDir, including for noEmit projects.
Declarations and config artifacts do not widen it. When both output directories
are explicitly disabled, refresh conservatively includes imported generated
sources in containment. A nonempty containment set gives the read-only editor
project a filesystem-volume rootDir, preserving composite while containing
workspace aliases on that volume and canonical generated paths. With active or
inherited output directories, the compiler listing must establish containment
eligibility: imports with unknown external-library membership are
rejected unless they are also compiler-listed program roots. Authored and unmoved
inputs do not need this proof.
When source containment requires widening, refresh rejects active or inherited
outDir and declarationDir: those options make package resolution depend on
rootDir, even for unobserved queries. This can reject direct-source imports that
happen to be unaffected. Use an editor configuration without emission output
directories or an authored editor project without generated_sources.
Projects retaining emission output directories preserve their rootDir and resolution context; open these projects through the physical workspace path, because unrelated workspace aliases are not guaranteed to contain canonical generated sources.
Output-directory support also requires unchanged containment of the effective
project file by every declared first-party package scope with imports or
exports. Moving the config inside a package can enable output-to-source remapping
without widening rootDir, including for declaration-only generated input.
Refresh rejects that context change before publishing, even when the current map
uses direct-source targets that happen to be unaffected. Configs without emission
output directories and scopes with unchanged containment remain supported. Npm
resolution through the declared node_modules chain is excluded from this local
output-to-source remapping rule.
Generated tree aliases retain native moduleSuffixes selection, including
fallback and deletion. Exact file projection rejects nonempty suffixes because the compiler
applies them again to the selected filename, potentially selecting another file
or failing. Aliases already pointing into canonical output need no exact
projection and retain native suffix selection.
Generated projection also rejects generated inputs from external repositories and
external authored sources the compiler reads, including .d.ts and emit = False
dependencies. This applies even when the consuming target and its authored config
are local. Ordinary builds can still consume these inputs.
Generated inputs must share the editor project's Bazel output root. Refresh
rejects local producers built in another configuration, such as through a
configuration transition, before replacing the installed project. Use an authored
editor project without generated_sources for those programs; ordinary builds
and type checking remain supported.
It also rejects external authored configs, conflicting selections for an exact
alias, and aliases needing exact projection
under node16 or nodenext resolution. Exact paths can hide failed imports in
another importer's resolution mode. Relocated original candidates, including
explicit scalar filenames, and unprojected authored aliases retain native
resolution in those modes. Rejection leaves the installed project intact.
Use an authored editor project or refresh without generated_sources for
unsupported programs; ordinary builds and type checking remain supported.
Native-editor replacement, deletion, and recovery behavior is not measured. Acceptance requires one running editor with an unchanged consumer observing all three through its established file-watching adapter.
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/design-system",
],
)
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. The result is atsconfig.jsonwith an emptypaths: no packages, no aliases.depsobeys visibility, so a package-privatets_compiletarget cannot be listed here. Gazelle writesvisibility = ["//visibility:public"]on the targets it generates. Ats_testcarries its own program and is listed itself, under its ownvisibility; the targets over this list are testonly for 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; checked in |
.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 |
The target copies those files into the source tree, so it runs only under
bazel run.
tsconfig (default "tsconfig.json") is where the generated config lands.
Add .bazel to .bazelignore, so Bazel never reads the plugin's files as a
package. This repository's own .bazelignore starts with that line.
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.
By default, Gazelle discovers the program through tsconfig.json.
Renaming it without selecting the new file through a kept ts_config.src
leaves no program there, so Gazelle withdraws the rules it wrote. The
generated-source setup uses that
explicit selection to retain the authored program. For the default setup, set
ts_refresh_tsconfig(tsconfig = "tsconfig.bazel.json") and extends the
generated file from yours; see
Extending the Generated File.
Extending the Generated File¶
A repository that keeps its own tsconfig.json points the generator at another
name and extends it:
ts_refresh_tsconfig(
name = "refresh_tsconfig",
test = True,
tsconfig = "tsconfig.bazel.json",
deps = ["//src/app", "//src/lib"],
)
Three edits make that build:
-
The
ts_configneeds the base indeps. Gazelle writests_config(name = "tsconfig", src = "tsconfig.json")and fillsdepsfor anextendsnaming anothertsconfig.jsonby relative path. A base of another name gets no entry, and every target fails witherror TS5083: Cannot read file '.../tsconfig.bazel.json'.Add the file with a# keep, sincedepsis Gazelle's: -
An option the root block cannot hold puts every package on the nested list. Gazelle wires your file onto every target, so a
"module": "ESNext"in it disagrees with the root block'sPreservefor every package with targets, and the firstbazel run //:refresh_tsconfigfails instead of writing:ts_refresh_tsconfig: the nested_tsconfigs list does not match what the graph needs. add: src/app/tsconfig.json, src/lib/tsconfig.jsonList them in
nested_tsconfigs(Nested Tsconfigs), or drop the option from your file. -
Each nested package exports its
tsconfig.json. The stalenessdiff_testfor a nested entry names//<pkg>:tsconfig.json, and the generated file is one Gazelle skips, so nothing declares it. Analysis fails withno such target '//src/lib:tsconfig.json'until the package's BUILD carriesexports_files(["tsconfig.json"]), asvite/BUILD.bazelandtests/compiler_options/BUILD.bazeldo here.
With those three and .bazel in .bazelignore, bazel build //... and the
staleness tests pass. A tsconfig with "baseUrl" in it fails for another
reason; see
Option 'baseUrl' has been removed.
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/design-system"],
)
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 from what each target names: the tsconfig it compiles
under, and allowJs for a target with JavaScript srcs where the root block does
not set it. A package whose targets name a tsconfig of their own gets its own
program; one whose targets name none stays in the root's. A package whose
targets name the tsconfig.json in their own directory -- every package
Gazelle writes -- has its program already, that file: nothing is generated for
it, and the root program excludes the files it covers.
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 tsconfig the package's targets
name, root first so the package's file 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 tsconfig inherited whole would emit into your source tree
and reject files outside one target's rootDir.
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 files in one package is the same error. TypeScript
applies an extends array later-wins, so listing both would let one's keys
replace the other's for both targets' sources. A package gets at most one, from
whichever of its targets name one.
A target in that package naming no tsconfig inherits that file in the editor,
and does not in the build: the rule's baseline (strict, module: Preserve,
target: es2022, jsx: react-jsx, skipLibCheck, esModuleInterop) is all
it gets there, which is what the root block holds. Give the odd target the same
tsconfig, or its own package, when that is not close enough.
Bare Specifiers for First-Party Packages¶
Every package the aspect reaches gets a paths key of its own in the root
config -- @/<rest>/* for a package under src/, the package path otherwise,
plus the bare key once the package has an index file -- mapped to the source
directory and its bazel-bin twin. A pnpm link:/workspace: dependency
imported by its package name gets no key: the checkout's node_modules holds
pnpm's link to the member, which is where the build resolves it too, through the
hub's view of the member.
npm Packages¶
The generated config names no npm package. TypeScript resolves a bare specifier
by walking the checkout's node_modules from the importing file, as tsgo walks
the importer chain a build stages, so pnpm install is the editor's npm setup: the tree
it installs is the lockfile's, which is what the build resolves too. A
@types/* package, an exports subpath and a types entry naming a package
resolve the same way. A package your targets declare and the checkout does not
hold is TS2307 in the editor until the next pnpm install.
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
Each compiler target that contributes a package writes a
<target>.tsconfig-fragment.json beside its other outputs in bazel-out.
Wrappers forward those fragments, and the resolver merges them 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 |
npm packages are in neither: TypeScript resolves them through the checkout's
node_modules (npm Packages).
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 writes
types from the tsconfig's entries or, when it sets neither types nor
typeRoots, from the direct @types/* deps' names, and a tsconfig that sets
typeRoots admits what those roots hold, so a global reaches a target because
that target asked for it. The editor's root program has one compilerOptions
block for the whole workspace and no types key, so TypeScript includes every
@types/* package under the root node_modules/@types. A file using process
type-checks in the editor as soon as @types/node is installed, and then fails
bazel build with TS2304 until the target asks for it: node in its
tsconfig's types, or @types/node among its direct deps when the tsconfig
sets neither types nor typeRoots.
Narrowing that per target would need a tsconfig per target, and a package only
gets its own program when its targets name one
(nested_tsconfigs).
Treat bazel build as the authority, and declare ambient packages up front:
"types": ["node"] in the tsconfig every package of a tree extends does that
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:
VS Code passes no --globalPlugins, so the plugin has to be named in the config
the editor is using as well; the generated tsconfig.json names it, and
bazel run //:refresh_tsconfig keeps it there. Do not add the entry by hand to
a config the 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
unresolved imports.
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 its own language server, not
an editor's. Claude Code installs typescript-language-server and typescript.
The generated tsconfig.json needs no configuration. It is a checked-in
file with a paths map, which every TypeScript tool reads. An agent's language
server resolves a Bazel-built .d.ts through it to the real declarations, not
to any; a nonexistent member on an imported symbol is still an error.
bazel run //:refresh_tsconfig keeps the file current, and the
staleness test asks for it.
The plugin needs a configurable server. If the harness exposes LSP
initializationOptions, pass the plugins entry from the
nvim-lspconfig recipe;
most harnesses run typescript-language-server. If it does not, the plugin
cannot be reached and the tsconfig.json is the whole answer.
Two facts about NODE_OPTIONS:
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_OPTIONSkills the process. Node resolves--require ./x.jsagainst the process's cwd; from any other directory the process fails to start:Cannot find module, withrequireStack: [ 'internal/preload' ], exit 1. Absolute paths only.
To check what a harness 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. lib/tsserver.js loads its bundle as
require("./typescript.js"), which the preload's matcher does not accept, so the
patch never installs. With the matcher widened, a real tsserver still reports
TS2307 for the same import: the language service resolves through its
LanguageServiceHost, not through the export. The plugin decorates that host.
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(thets_compilepackage list) and turns it into a module-name → declaration-path map - Internal packages resolved from
bazel-bin(.d.tsafter a build emitted them) or the source tree (.tsbefore one) - Fragments, if the
.bazelrclines above are in place, add the packages 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 - Directory watchers watch the graph data file's parent and canonical output
directories and their parents, renewing watches after directory replacement or
recreation. Changes to graph data, declarations, or fragments rebuild the map.
npm packages and path aliases are not in the map: TypeScript resolves both
itself, through the checkout's
node_modulesand the tsconfig'spaths
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¶
For packages resolved through the worker map, including fragment entries, the
worker tries index.d.ts, index.ts, and index.tsx in that order. For each
filename, it checks bazel-bin before the checkout.
Exact package aliases written by refresh retain the declared entrypoint's provenance: authored entrypoints resolve in the checkout, and generated-only entrypoints resolve in the output tree. TypeScript applies native extension substitution within that namespace. Generated-only entrypoints have no checkout fallback.
npm packages are not in the map: TypeScript resolves them itself through the
checkout's node_modules.
What a Build Provides¶
Authored first-party entrypoints resolve without bazel build; generated
entrypoints require their producer to run. A build adds the .d.ts of every
package a dependent's compile read -- of every package with
--output_groups=+declarations
(Which Tool Emits the Declarations)
-- and, with the aspect enabled, the fragments naming the packages deps could
not reach.
npm resolution is bounded by the checkout's node_modules: a package the
lockfile gained resolves in the editor after the next pnpm install.
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.
Gazelle lists every test file the program owns in the generated ts_test,
whatever other rule names it, so my.test.ts stays tested by bazel test
//... while this manual target holds it too. Delete the target when the
session is over.