ts_compile¶
Compiles TypeScript with Oxc and emits .d.ts declarations with tsgo.
Usage¶
load("@rules_typescript//ts:defs.bzl", "ts_compile")
ts_compile(
name = "lib",
srcs = ["index.ts", "math.ts"],
deps = ["//other/package", "@npm//:zod"],
visibility = ["//visibility:public"],
)
Unmodified TypeScript compiles: no explicit export type annotations, no tsconfig
wiring, no extra flags in .bazelrc.
Attributes¶
| Attribute | Type | Default | Description |
|---|---|---|---|
srcs |
label_list |
required | .ts, .tsx, .d.ts, .js, .mjs or .cjs files. See Sources |
deps |
label_list |
[] |
ts_compile, ts_npm_package, css_library, css_module, asset_library or json_library targets |
target |
string |
"es2022" |
ECMAScript target version |
jsx_mode |
string |
"react-jsx" |
JSX transform: react-jsx, react, preserve; empty disables JSX |
declarations |
string |
"tsgo" |
Which tool emits .d.ts: "tsgo" or "oxc" |
enable_check |
bool |
True |
Run tsgo. See Turning tsgo off |
source_map |
bool |
True |
Emit a .js.map next to every .js. See Source and declaration maps |
declaration_map |
bool |
False |
Emit a .d.ts.map next to every declaration. See Source and declaration maps |
tsgo_args |
string_list |
[] |
Extra tsgo flags. See tsgo flags |
tsconfig |
label |
None |
The project's own tsconfig.json, or a ts_config target, as the compilerOptions baseline |
lib |
string_list |
None |
compilerOptions.lib, e.g. ["es2022", "webworker"]. Replaces the whole set target implies |
types |
string_list |
None |
compilerOptions.types — which ambient type packages load. [] loads none; relative entries resolve against this target's package |
jsx_import_source |
string |
None |
compilerOptions.jsxImportSource, e.g. "solid-js", "preact" |
compiler_options |
dict |
None |
Any other compilerOptions, passed through verbatim |
module_name |
string |
"" |
The bare specifier this target is importable as, e.g. "@acme/ui" |
path_aliases |
string_dict |
{} |
Alias prefix → workspace-relative source directory. Must resolve to files this target stages: its own srcs, or path_alias_srcs |
path_alias_srcs |
label_list |
[] |
Files a path_aliases entry resolves to when they are not in srcs. They join this target's type program, so a type error in one of them fails this target |
vite_types |
bool |
False |
Prepend the Vite ambient type shim to srcs |
Sources¶
A .js, .mjs or .cjs src is staged into the output tree unchanged and joins
the type program. The rule sets allowJs for it, so its JSDoc types cross the
package boundary; add checkJs through compiler_options to have its own body
checked.
A .jsx src is rejected at analysis time, because oxc has no output extension
for one. The message says to rename it .tsx.
Source and Declaration Maps¶
Turn source_map off for a target whose JavaScript nothing debugs: a codegen
step, or a bundle input whose bundler makes its own map.
declaration_map is what makes go-to-definition across a package boundary land
on the .ts source. It requires the tsgo declaration emit
(declarations = "tsgo" with enable_check = True); oxc emits no map.
tsgo Flags¶
tsgo_args accepts only the flags that report on the program:
--traceResolution, --explainFiles, --listFiles, --listEmittedFiles,
--diagnostics, --extendedDiagnostics, --noErrorTruncation. A
compilerOption belongs in compiler_options, where the Bazel-owned-key guard
can see it.
Outputs¶
For each source file foo.ts:
| Output | Description |
|---|---|
foo.js |
Compiled JavaScript (always from Oxc) |
foo.js.map |
Source map |
foo.d.ts |
Declaration file — the compilation boundary |
Under declarations = "tsgo" with enable_check = False there is no type
program and therefore no .d.ts at all; see
Turning tsgo off.
Where Compiler Options Come From¶
The generated tsconfig carries the machinery the rules own: rootDirs bridging
the source and output trees, paths for npm packages, the files list that
carries each @types/* dep, and under declarations = "tsgo" the
outDir/rootDir/noEmitOnError triple. A user tsconfig supplies the
baseline, and the generated one extends it.
Lowest precedence first:
- The ruleset baseline —
strict,module: "Preserve",skipLibCheck,esModuleInterop. Applied in both modes: without atsconfigthey go straight into the generated file, and with one they go into a file the generated configextendsbefore yours, so they reach only the keys your file never mentions.
moduleResolution: "Bundler" joins them only where the baseline also owns
the module it belongs to: without a tsconfig, and while
compiler_options names neither key. TypeScript couples the two, so a
baseline moduleResolution still standing after your module: "NodeNext"
replaced the baseline's is TS5109 before a source is read. Left out, tsgo
derives the resolver from whichever module won — Bundler for all of them
but Node16/NodeNext, which derive their own.
2. tsconfig — the project's own tsconfig.json, and whatever it extends.
Referenced where it lives, never copied, so relative paths inside it still
resolve against the directory they were written for. Everything it says wins
over layer 1, so tsgo checks the code under the options tsc would — and
setting the attribute adds what the file says instead of taking the baseline
away.
3. target and jsx_mode, then jsx_import_source, lib, types, then
compiler_options, which wins among these.
4. The options Bazel owns — paths, include, and the 16 keys listed
below.
target and jsx_mode are injected in every mode, including over a tsconfig
baseline, and supersede a target or jsx in the file. Oxc transforms with
them, and the two compilers have to agree.
One option rides in unasked above layer 2: allowArbitraryExtensions, which the
.d.ts files generated for css_module / css_library / asset_library /
json_library deps require. It overrides the value in your tsconfig file; to
get your own value back, set it in compiler_options, which sits above it.
Layer 1 is a real file — <target>.tsconfig_baseline.json beside the generated
config — because Starlark cannot read your tsconfig to see which keys it
already sets. TypeScript settles that itself: extends takes a list, and a
later entry overrides an earlier one.
The Two Hard Errors¶
Both fail at analysis time.
A Bazel-owned key in compiler_options. These 16 encode the sandbox layout
or the action's declared outputs:
baseUrl, rootDirs, paths, outDir, rootDir, declarationDir,
declaration, emitDeclarationOnly, declarationMap, sourceMap, noEmit,
noEmitOnError, isolatedDeclarations, composite, incremental,
tsBuildInfoFile.
declarationMap and sourceMap follow from the declaration_map and
source_map attributes. Each failure message names the attribute to use
instead where there is one, and otherwise says what the key encodes.
ts_compile: compilerOptions.paths is set by the rule and cannot be overridden --
use path_aliases for source aliases, or module_name on the target that produces
the declarations.
Remove "paths" from compiler_options on //src/app:app.
A path_aliases value pointing into the output tree. A path under
bazel-out/ or bazel-bin/ embeds the build configuration, so it breaks under
-c opt or a different exec platform:
ts_compile: path_aliases["@acme/ui"] on //src/app:app points into the output
tree (bazel-out/k8-fastbuild/bin/packages/ui).
Set module_name on the producing target.
Generated and Checked-In Sources in One Target¶
One tsgo declaration emit has one rootDir. A checked-in source hangs off the
package directory, a generated one off the package's directory in bazel-bin.
A target holding both fails at analysis under the default emit:
ts_compile: srcs on //src/app:app hang off 2 different roots, and one
declaration emit has one rootDir:
bazel-out/k8-fastbuild/bin/src/app
src/app
Put the generated sources in their own target and depend on it, or set
declarations = "oxc" (which groups sources by root and runs oxc once per
group), or set enable_check = False. Neither of the last two emits from tsgo.
Generated sources cannot use the default emit even on their own — see
ts_codegen § Compiling the output.
A Src From Outside This Package's Tree¶
The same rule, one layer out, and the one half of it a srcs list can decide on
its own — so it is decided while the BUILD file loads, before anything is
analysed. A src is compiled into the package of the target that lists it:
its outputs are declared under that package. The root its package-relative path
hangs off, though, is where the file actually lives, so a src from a sibling
package, from an ancestor, or from another repository hangs off a root of its
own while this package's sources hang off the package:
ts_compile: srcs on //src/app:app mix this package's own files with files that
live outside it:
//packages/shared:util.ts
Give the file a target in its own package and depend on that; set module_name
on it when the import is by bare specifier. The same two escape hatches apply,
and the check is skipped when either is set.
Only the mix is rejected, and five shapes are not it:
- A descendant package's file is already inside this package's directory,
which is the root this package's own sources hang off:
srcs = ["a.ts", "//src/app/sub:x.ts"]in//src/appis the onerootDirsrc/app, and builds. Ats_compilemay hold a whole subtree, and a subtree may grow a BUILD file without the target above it having to be split. What it must not do is compile a file the descendant package also compiles: both declarebazel-bin/src/app/sub/x.js, and Bazel rejects that pair as conflicting actions, naming both targets. - A target whose srcs all come from elsewhere has one root like any other.
- The top-level package is the exec root, which is the root a src from any
other package hangs off too — so
srcs = ["main.ts", "//lib:util.ts"]at the repository root is one root, not two, and builds. - A
.d.tsfrom anywhere is passed through rather than compiled, which is what letsvite_types = Trueprepend the Vite shim from@rules_typescript//ts. - A
selectresolves after the loading phase, so this check never sees its branches. A mix hidden inside one still reaches the analysis-time error.
Only a label that names a source file is judged. //other:some_target
stands for whatever files that rule or filegroup produces, wherever those live,
which the loading phase cannot know; the analysis-time root check is what covers
them.
A label naming a repository — @other//pkg:f — is outside this tree whatever
its path. @//pkg:f and the canonical @@//pkg:f are not: an empty repository
part is this repository, so @@//<this package>:f is this package. A repository
that names itself by its own apparent name is the one shape read as foreign
when it is not; spell it //pkg:f.
Deps Have to Be Direct¶
A source may import only what a direct dep provides. Every ts_compile
target that has both sources and deps runs a TsStrictDeps action, which
reads those sources and fails on any specifier that resolves only because it
arrives through another dep's own deps:
ERROR: .../src/app/BUILD.bazel:3:11: TsStrictDeps //src/app:app failed: (Exit 1)
//src/app:app imports a module no direct dep provides:
src/app/main.ts:1 imports "./hidden"
add "//src/app:hidden" to deps
src/app/main.ts:2 imports "zod"
add "@npm//:zod" to deps
Each of those resolves today only because it reaches this target through
another dep's own deps, and stops resolving the moment that dep drops it.
Re-run gazelle to regenerate deps, or add the labels above by hand.
bazel run //:gazelle writes those labels. There is no flag and no opt-out.
What is checked: relative imports, bare specifiers that name an npm package,
and bare specifiers that name another target's module_name (including a
subpath of one). What is exempt: Node builtins and node: specifiers, and
anything under a path_aliases prefix, since an alias resolves to files this
target already stages. An import that nothing in the closure provides is left
alone, because there is no label to suggest; TypeScript reports it as TS2307.
/// <reference types="x" /> is not checked: Gazelle generates no dep for it
either, so the check would have no label to suggest.
Action Inputs¶
Resolution becomes direct; the action inputs do not. One paths map serves the
whole type program, so dropping the transitive entries would stop a declared
dep's own .d.ts from resolving its imports, which TypeScript widens to any
and does not report. They stay available, and TsStrictDeps is what stops your
own source from leaning on them.
Importing Another Target by Bare Specifier¶
path_aliases maps a prefix to a source directory, which is right for
@/components → src/components. It cannot name another target's generated
declarations, because only that target knows where they land under the current
configuration. Set module_name there instead:
# packages/ui/BUILD.bazel
ts_compile(
name = "ui",
srcs = glob(["*.ts", "*.tsx"]),
module_name = "@acme/ui",
visibility = ["//visibility:public"],
)
# apps/web/BUILD.bazel
ts_compile(
name = "web",
srcs = ["main.ts"], # import { Button } from "@acme/ui";
deps = ["//packages/ui"],
)
The dependent gets a paths entry for @acme/ui and @acme/ui/* pointing at
the .d.ts files Bazel produced, with index.d.ts as the entry point. The name
travels in the TsModuleInfo provider, transitively.
A pnpm workspace member linked through @npm//:<name> gets more than that: its
own package.json decides. The exports map, typings, types and main are
read where the lockfile is, and each specifier the member declares becomes its
own paths entry -- so @acme/ui/button resolves to the declaration emitted
from whatever file exports["./button"] names, four directories down if that is
where it is. A wildcard subpath ("./icons/*": "./icons/components/*.tsx")
becomes a wildcard pattern. The guesses above stay behind every declared entry,
so a manifest naming a file this build does not produce is no worse than a
manifest nobody read. See npm workspace members.
ts_config¶
Starlark cannot read a file to follow its extends chain, so a tsconfig that
extends another file has to declare the chain. Every file in it becomes an input
to the type-check action:
load("@rules_typescript//ts:defs.bzl", "ts_compile", "ts_config")
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = ["//:tsconfig.base.json"],
)
ts_compile(
name = "lib",
srcs = ["index.ts"],
tsconfig = ":tsconfig",
)
A tsconfig that extends nothing goes straight into ts_compile:
Worked example: a Cloudflare Worker¶
Ambient globals from a generated .d.ts, no DOM, and no stray @types package
from the dependency graph reaching global scope:
ts_compile(
name = "worker",
srcs = ["index.ts"],
tsconfig = "tsconfig.json",
lib = ["es2022"],
types = ["./worker-configuration.d.ts"],
compiler_options = {"resolveJsonModule": True},
)
Relative entries in types and typeRoots are rewritten to resolve from the
generated config, so they are written exactly as they would be in the package's
own tsconfig. Other path-valued options are not rewritten: they resolve against
the generated config's directory, so they belong in the tsconfig file.
types names a file this target can see. It is not what puts a dep's globals
in scope: a .d.ts in another target's srcs with no top-level import or
export declares globals, and those reach every target that depends on it,
however far down the graph the declaration sits -- the scope a single tsc run
over the same sources would give it.
When two ambients declare the same thing¶
A dep's globals are listed ahead of the ones types and @types/* packages
supply, so where both declare the same declare module pattern the project's
own wins. That is what tsc does natively -- a types package arrives as a
type-reference directive, which joins the program after the root files -- and
it is the only lever there is: the first declaration of a pattern wins, and a
narrower pattern does not change that. An earlier declare module "*.svg"
beats a later declare module "*.icon.svg" even for star.icon.svg.
To let a package's ambient win instead, drop the project's competing declaration.
Which Tool Emits the Declarations¶
Oxc always does the JavaScript transform. declarations decides which tool
produces the .d.ts.
declarations = "tsgo" (default)¶
tsgo emits declarations from the complete type program: the target's sources
plus every transitive .d.ts and npm package.json.
- No source annotations required. Inferred export types are fine.
- Declarations are exactly what
tscwould emit, including inferred object shapes, literal unions andRegExp. - Type errors fail
bazel build. The.d.tsare real outputs of the tsgo action, so a target with a type error produces nothing. No--output_groups=+_validationneeded. - Type-checking is on the critical path. A consumer waits for its dependency's declarations.
declarations = "oxc"¶
Oxc emits declarations syntactically, per file, with no type program. This
requires isolated declarations:
every export needs an explicit type, and Oxc errors when one does not have
one. Type-checking moves into the _validation output group, off the critical
path, so downstream targets compile while checking runs concurrently.
Use it per package once that package's exports are annotated.
Turning tsgo Off¶
enable_check = False means different things in the two modes:
enable_check = True (default) |
enable_check = False |
|
|---|---|---|
declarations = "tsgo" |
tsgo emits .d.ts and reports errors |
no tsgo, and no .d.ts — an opt-out of types, right for terminal targets (app entries, dev servers, bundle inputs) whose declarations nothing consumes |
declarations = "oxc" |
Oxc emits .d.ts; tsgo validates |
Oxc emits .d.ts; nothing type-checks. Oxc still enforces isolated declarations, so the declarations stay complete; the function bodies go unchecked |
declarations = "oxc" with enable_check = False is the only configuration
that runs no tsgo at all. Used broadly, it needs a type-checking gate somewhere
else in the build.
Cost of Each Mode¶
One measurement, one machine, one day, reproducible from this tree:
1,000 annotated files across 20 packages in a single linear dependency chain (so the critical path has somewhere to show up), medians of three interleaved runs:
| Mode | Rebuild wall | Critical path |
|---|---|---|
declarations = "tsgo" |
6.3s | 4.89s |
declarations = "oxc" |
3.8s | 2.15s |
declarations = "oxc", enable_check = False |
2.7s | 1.06s |
Both modes run tsgo once per target, so the gap is serialisation. Under "oxc"
the check is a validation action nothing waits for, and the critical path is
Oxc's per-file transform; under "tsgo" each of the 20 links waits for its
dependency's declarations. The gap shrinks on shallower graphs and widens on
deeper ones.
Providers¶
JsInfo— transitive depset of.jsfiles, used byts_binaryandts_bundleTsDeclarationInfo— depset of.d.tsfiles, used by downstreamts_compiletargets for type resolutionTsModuleInfo— themodule_namethis target is importable as, plus the directories its declarations land in, propagated transitively so a dependent can build its ownpathsentriesOutputGroupInfo(_validation=...)— the tsgo check stamp, written only underdeclarations = "oxc"with checking on; under the default the declarations are the tsgo action's own outputs.OutputGroupInfo(strict_deps=...)— theTsStrictDepsstamp, on any target with bothdepsand sources. The compile actions take it as an input, so a violation fails a plainbazel build; the output group exposes the stamp and the checker on their own.
Architecture¶
The oxc-bazel binary processes each .ts file through:
- Parse (oxc_parser)
- Semantic analysis (oxc_semantic)
- Isolated declarations emit (oxc_isolated_declarations) — only under
declarations = "oxc" - TypeScript/JSX transform (oxc_transformer)
- Code generation (oxc_codegen) for
.js+.js.map
TsStrictDeps runs before the transform, as a Node action over a params-file
manifest of the target's declared and reachable providers. Its scanner is a
character walk over the source: a quoted string is a specifier only when the
tokens before it say so. Gazelle generates deps with the same walk, so the two
cannot demand different things.
tsgo runs as a separate Bazel action against the generated tsconfig.json.
Under declarations = "tsgo" that tsconfig sets declaration,
emitDeclarationOnly, rootDir and outDir so the emitted declarations land
beside Oxc's .js (mnemonic TsgoDeclare). Under declarations = "oxc" it
runs with --noEmit and writes only a stamp (mnemonic TsgoCheck).
Output Paths¶
Output paths are derived from source file names, not target names, so
import "./foo" resolves to bazel-bin/.../foo.js. Two ts_compile targets in
the same package therefore cannot list the same source file: Bazel reports
conflicting actions. Split by directory, or give each target its own sources.