Bazel build setup, a Gazelle TypeScript language extension, and the Rust import extractor that powers it through cgo.
Built on Bazel 8.5+ (bzlmod) with
rules_rs for the Rust side and
aspect_rules_ts / aspect_rules_js in the examples. CI tests the repo with
Bazel 8.5.1 and 9.0.0.
# MODULE.bazel
bazel_dep(name = "gazelle", version = "0.50.0")
bazel_dep(name = "gazelle_ts", version = "<latest>")
# Required so the consumer .bazelrc below can reference @llvm directly.
# bzlmod does not transitively expose deps' repos.
bazel_dep(name = "llvm", version = "0.7.6")
gazelle_ts registers a hermetic @llvm cc toolchain so the rules_rs Rust
toolchain does not trip Bazel's Xcode autodetect on macOS. Mirror these flags
in the consumer workspace because Bazel only reads the consumer's rc files:
common --enable_platform_specific_config
# Linux/Windows: pin host_platform so rules_rs's Rust toolchains match the
# gnu.2.28 libc / msvc constraints they tag via target_compatible_with.
common:linux --host_platform=@gazelle_ts//platforms:local_gnu
common:windows --host_platform=@gazelle_ts//platforms:local_windows_msvc
# Suppress Bazel's autodetected cc toolchain so @llvm wins resolution.
# NO_APPLE avoids the XcodeLocalEnvProvider duplicate-SDKROOT crash on macOS.
common --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
common --repo_env=BAZEL_NO_APPLE_CPP_TOOLCHAIN=1
# rust stdlib's link spec hardcodes -lgcc_s; @llvm's clang does not ship it,
# so we inject an empty stub.
common --@llvm//config:experimental_stub_libgcc_s=True
# rules_go cgo external link via clang+lld cannot produce PIE. Drop when
# Go 1.27 lands PIE-compatible objects.
build:linux --linkopt=-no-pie
See examples/basic/.bazelrc for a working setup.
@gazelle_ts//ts is a Gazelle language extension. Consumers compose their own
gazelle_binary so TypeScript can run alongside other languages such as Go,
Python, or proto.
# BUILD.bazel
load("@gazelle//:def.bzl", "gazelle", "gazelle_binary")
# gazelle:ts_npm_link_pattern //:node_modules/{pkg}
gazelle_binary(
name = "gazelle_bin",
languages = ["@gazelle_ts//ts"],
)
gazelle(
name = "gazelle",
gazelle = ":gazelle_bin",
)
Then run:
bazel run //:gazelle
The plugin deliberately emits abstract TypeScript-flavored kinds and leaves the
concrete rule implementation to the consumer. Add map_kind directives near
the workspace root:
# gazelle:map_kind ts_library myrepo_ts_library //tools:ts.bzl
# gazelle:map_kind ts_test myrepo_ts_test //tools:ts.bzl
# gazelle:map_kind ts_binary myrepo_ts_binary //tools:ts.bzl
# gazelle:map_kind ts_bundler_config myrepo_bundler_config //tools:ts.bzl
A typical wrapper file:
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_test")
def myrepo_ts_library(name, srcs, **kwargs):
ts_project(
name = name,
srcs = srcs,
composite = True,
declaration = True,
declaration_map = True,
source_map = True,
# transpiler, default tsconfig, and other project defaults live here.
**kwargs
)
def myrepo_ts_test(name, srcs, deps = [], data = [], **kwargs):
# Generated ts_test srcs are test entrypoints. Multi-entry runners can
# forward srcs/deps/data directly; stock js_test needs one entry_point.
js_test(name = name, data = srcs + deps + data, entry_point = srcs[0], **kwargs)
def myrepo_ts_binary(name, **kwargs):
# Gazelle keeps data in sync from entry_point/srcs imports.
js_binary(name = name, **kwargs)
def myrepo_bundler_config(name, srcs, **kwargs):
ts_project(name = name, srcs = srcs, **kwargs)
If you skip map_kind, the fallbacks in @gazelle_ts//ts:defs.bzl collect
files into filegroups so BUILD files still load, but they do not typecheck,
run tests, or build binaries.
package.json imports For Internal Pathsgazelle_ts reads the root package.json imports map and uses it for TS
dependency resolution. This is the preferred way to describe internal #...
subpath imports because the same map is visible to Node.js, TypeScript bundler
resolution, bundlers, and Gazelle.
{
"imports": {
"#packages/*": "./packages/*",
"#generated/typespec/rest/*/index.js": "./typespec/rest/*",
"#generated/protobuf/*": [
"./bazel-bin/generated/protobuf/*",
"./generated/protobuf/*"
],
"#generated/npm/*/index.js": "//generated/npm/*:*.web"
}
}
Path targets such as ./typespec/rest/* resolve through Gazelle's RuleIndex to
the longest matching TypeScript package. Literal Bazel labels beginning with
// or @ are used directly as deps after substituting the * capture.
Use Gazelle's native resolve and resolve_regexp directives for imports that
are not in package.json dependencies and are not described by imports:
# gazelle:resolve ts ts mystery:banner //tools:mystery
# gazelle:resolve_regexp ts ^@myrepo_generated/(.*)$ //:node_modules/@myrepo_generated/$1
Overrides win before every TypeScript-specific resolver path.
For .d.ts packages that declare globals and are never imported, map the
global name to the provider label:
# gazelle:ts_resolve_global process //:node_modules/@types/node
# gazelle:ts_resolve_global chrome //:node_modules/@types/chrome
# gazelle:ts_resolve_global import.meta.env //app/frontend/@types/app-env
# gazelle:ts_resolve_global R2Bucket //:node_modules/@cloudflare/workers-types
When source references those globals, Gazelle adds the provider to deps and
adds the inferred compilerOptions.types entry to tsconfig_types:
ts_library(
name = "worker",
deps = [
"//:node_modules/@cloudflare/workers-types",
"//:node_modules/@types/node",
],
tsconfig_types = [
"@cloudflare/workers-types",
"node",
],
)
Tests are separated from libraries by ts_test_pattern. Add patterns for
project-specific layouts:
# gazelle:ts_test_pattern __tests__/**/*.ts
# gazelle:ts_test_pattern __tests__/**/*.tsx
Bundler and tooling configs can be held out of the library closure:
# gazelle:ts_bundler_config_pattern vite.config.* vite_config
# gazelle:ts_bundler_config_pattern vitest.config.* vitest_config
# gazelle:ts_bundler_config_pattern tailwind.config.ts tailwind_config
# gazelle:ts_bundler_config_pattern .storybook/main.ts storybook_config
Use Gazelle's built-in exclude directive for files owned by another package
or another tool:
# gazelle:exclude .storybook/**
# gazelle:exclude vitest.storybook.config.ts
Gazelle never generates ts_binary or js_binary, but it recognizes existing
rules of those kinds, scans their entry_point and srcs imports, and keeps
data in sync. For ts_binary, it also manages tsconfig_types.
All TypeScript directives live in BUILD.bazel files as
# gazelle:<key> <value> and inherit into subdirectories unless a child
directory overrides or appends to them.
| Directive | Default | Behavior |
|---|---|---|
ts_enabled |
true |
Enable or disable the TypeScript extension for the current tree. Accepts true/false, 1/0, yes/no, and on/off. |
ts_library_name |
package basename, or lib at repo root |
Name of the generated ts_library. |
ts_test_name |
package basename + _test, or test at repo root |
Name of the generated ts_test. |
ts_visibility |
//visibility:public |
Space-separated visibility labels. Replaces inherited visibility. |
ts_test_pattern |
*.test.ts, *.test.tsx, tests/**, test/**, **/*.test.ts, **/*.test.tsx, **/*.spec.ts, **/*.spec.tsx |
Append a doublestar glob used to classify tests. |
ts_extension |
.ts, .tsx |
Append a file extension treated as TypeScript input. |
ts_npm_link_pattern |
//:node_modules/{pkg} |
Template for npm labels. {pkg} is replaced with the resolved package name, including scopes. |
ts_test_data |
empty | Append a label to every generated test rule's data. |
ts_tsconfig_types |
node |
Append ambient type names that may be emitted in tsconfig_types when imported @types/* deps are resolved. This is an allowlist, not a list of every type dep; ts_resolve_global providers infer their own type names. |
ts_resolve_global |
empty | Add a <global> <label> mapping. Referencing the global adds the label to deps and infers a tsconfig_types entry. Longest matching global prefix wins. |
ts_bundler_config_pattern |
empty | Add a <glob> <name> mapping. Matching files are removed from library/test srcs and emitted as ts_bundler_config(name = <name>). |
Useful Gazelle directives alongside gazelle_ts:
| Directive | Use |
|---|---|
map_kind |
Map ts_library, ts_test, ts_binary, and ts_bundler_config to concrete project macros. |
resolve / resolve_regexp |
Override arbitrary TypeScript imports before package, subpath, builtin, or npm resolution. Use language ts. |
exclude |
Remove files or directories from Gazelle's walk before this extension sees them. |
| Kind | Generated? | Managed attrs | Intended implementation |
|---|---|---|---|
ts_library |
yes | srcs, visibility, deps, tsconfig_types |
A wrapper over ts_project or equivalent compile rule. |
ts_test |
yes | srcs, deps, data, tsconfig_types |
A wrapper over vitest, jest, mocha, js_test, or another runner. No entry_point is emitted. |
ts_bundler_config |
yes, from ts_bundler_config_pattern |
srcs, visibility, deps, tsconfig_types |
A wrapper over ts_project or equivalent tooling-config typecheck rule. |
ts_binary |
no | data, tsconfig_types |
A hand-written binary rule mapped through map_kind. Gazelle scans entry_point / srcs. |
js_binary |
no | data |
A hand-written stock rules_js binary. Gazelle scans entry_point / srcs. |
The plugin does not take a transitive dependency on aspect_rules_ts or
aspect_rules_js; the examples use those rules through local wrappers.
The Rust extractor parses TypeScript static imports, import type, inline
import("pkg").Type, dynamic imports, side-effect imports, re-exports, and
static CommonJS require("pkg") calls. The resolver then checks each import in
this order:
| Import shape | Example | Result |
|---|---|---|
| Same-package relative import | ./util |
No dep; the file is already in the package's srcs. |
| Cross-package relative import | ../shared/util |
Internal label found through the RuleIndex, when it crosses into another indexed TypeScript package. |
| Explicit override | mystery:banner with gazelle:resolve ts ... |
Configured Bazel label. |
| Regexp override | @myrepo_generated/foo with gazelle:resolve_regexp ts ... |
Configured Bazel label with captures substituted. |
package.json subpath import |
#packages/core/user |
Internal RuleIndex label or literal Bazel label target from the imports map. |
| Node.js builtin | fs, path, node:crypto |
@types/node via ts_npm_link_pattern; node is emitted in tsconfig_types by default. |
| Bare npm package | react, lodash/fp |
Package label from ts_npm_link_pattern; paired @types/<pkg> is also added when present. |
| Scoped npm package | @mui/material, @tanstack/react-query/devtools |
Scoped package label from ts_npm_link_pattern; paired DefinitelyTyped package uses @types/scope__name when present. |
| Type-only fallback | import type { Foo } from "lodash" when only @types/lodash is in deps |
The @types/lodash package label. |
| CommonJS require | const x = require("react"), require("reflect-metadata") |
Same as an import. Only unshadowed string-literal require(...) calls are extracted. |
| Global reference | R2Bucket with ts_resolve_global |
Configured provider label plus inferred tsconfig_types. |
Intentionally unsupported or skipped:
require(name), template-string
require(...), shadowed local require functions, and require.resolve(...)
are ignored.tsconfig.json paths mappings are not read. Use package.json imports
or Gazelle resolve / resolve_regexp directives.# keep, ts_test_data, or a project-specific wrapper.package.json importsAt the repo root, Gazelle reads:
dependenciesdevDependenciesoptionalDependenciesimportsimports entries are matched by longest key first. A single * capture may
appear anywhere in the key and target. Gazelle follows Node's ordered target
model and uses the first target that resolves:
| Target shape | Example | Behavior |
|---|---|---|
| String | "#foo": "./foo/index.js" |
Resolve the string target. |
| Array fallback | "#foo": ["./bazel-bin/foo.js", "./foo.js"] |
Try entries in order. |
| Conditional object | "#foo": {"types": "./foo.d.ts", "default": "./foo.js"} |
Evaluate supported conditions in declaration order; nested objects are supported. |
null |
"#foo/private/*": null |
Treat as no mapping. |
Supported conditions are types, node-addons, node, import,
module-sync, and default. Other conditions are ignored unless a later
supported condition resolves.
tsconfig_types is only for ambient providers that belong in
compilerOptions.types. Ordinary module declaration packages such as
@types/react are still added as deps, but they are not listed in
tsconfig_types unless allowlisted by ts_tsconfig_types.
For imported packages, the ts_tsconfig_types allowlist controls which
resolved @types/* packages become tsconfig_types. The default allowlist is
node, so Node builtins add both //:node_modules/@types/node and "node".
For global references, ts_resolve_global maps a global name or dotted prefix
to the ambient provider. Matching is exact or prefix-based (google.picker
matches google.picker.DocumentObject), and the longest configured global wins.
Type names are inferred as follows:
| Provider label | Inferred tsconfig_types |
|---|---|
//:node_modules/@types/node |
node |
//app/frontend/@types/app-env |
app-env |
//:node_modules/@cloudflare/workers-types |
@cloudflare/workers-types |
//types:custom-global-env |
custom-global-env |
Scoped ambient npm packages that match ts_npm_link_pattern keep their full
package name because values such as "@cloudflare/workers-types" are valid
compilerOptions.types entries.
Gazelle partitions TypeScript files in each package before generating rules:
ts_extension.ts_bundler_config_pattern become
ts_bundler_config srcs.ts_test_pattern become ts_test srcs.ts_library srcs.Bundler-config classification wins over test classification. Multiple bundler-config patterns can share the same target name; their files are merged. If a config imports a relative helper that lives in the library, the config rule depends on the sibling library. Imports from library sources into config files are deliberately not routed back to the config target.
Generated files that would otherwise be dropped can be preserved with Gazelle's
normal # keep comments on the relevant attr.
Attrs the plugin manages are listed below. Attrs not listed are left alone, so
wrapper-specific settings such as tsconfig, transpiler, args, env,
fixed_args, project-reference flags, and launcher settings survive Gazelle
runs.
ts_library| Attr | Set by | Behavior |
|---|---|---|
name |
generate | Required, from ts_library_name or package basename. |
srcs |
generate | Mergeable; preserves # keep lines. |
visibility |
generate | Replaced from ts_visibility. |
deps |
resolve | Replaced with resolved internal, npm, and global-provider labels. |
tsconfig_types |
resolve | Inferred ambient type names. |
ts_test| Attr | Set by | Behavior |
|---|---|---|
name |
generate | Required, from ts_test_name or package basename + _test. |
srcs |
generate | Mergeable test entrypoints. |
deps |
generate + resolve | Includes the sibling library when present plus imports from test files. |
data |
generate | Mergeable runtime fixtures from ts_test_data and # keep. |
tsconfig_types |
resolve | Inferred ambient type names for test-only imports/globals. |
ts_bundler_config| Attr | Set by | Behavior |
|---|---|---|
name |
generate | From ts_bundler_config_pattern's <name>. |
srcs |
generate | Mergeable config files. |
visibility |
generate | Replaced from ts_visibility. |
deps |
resolve | Config-only imports plus sibling library when a relative helper is imported. |
tsconfig_types |
resolve | Inferred ambient type names. |
ts_binary / js_binary| Attr | Kind | Behavior |
|---|---|---|
entry_point / srcs |
both | Hand-written by the user; Gazelle scans these files. |
data |
both | Replaced with resolved deps from imports. |
tsconfig_types |
ts_binary only |
Inferred ambient type names. |
flowchart LR
A["BUILD.bazel<br/>(directives)"] --> CFG["Configure"]
F["*.ts / *.tsx files"] --> GEN["GenerateRules"]
CFG --> GEN
EXT["import_extractor<br/>(Rust staticlib, cgo)"] -. parses TS .-> GEN
GEN --> RULES["generated rules<br/>+ ImportData"]
RULES --> IDX["Imports / RuleIndex"]
RULES --> RES["Resolve"]
IDX --> RES
PKG["package.json<br/>(deps + imports)"] --> RES
RES --> OUT["BUILD.bazel"]
Gazelle calls the language in three main phases:
The Rust crate at crates/import_extractor is built
as a rust_static_library and linked into the Go language extension via
cdeps. Calls into it use a plugin-namespaced C ABI:
gazelle_ts_ie_dispatch / gazelle_ts_ie_free.
crates/
import_extractor/ Rust staticlib for TS import/global extraction
ts/ Go Gazelle language extension
examples/ Self-contained Bazel workspaces
basic/ One TS package, npm deps, .ts/.tsx, smoke test
bundler-config/ Separate vite/vitest/tailwind config targets
composite/ Multi-package workspace with #packages/* refs
graphql/ @graphql-codegen -> npm_package -> app
advanced/ Composite + Bazel-built synthetic npm_package
The examples each have their own MODULE.bazel, pnpm-lock.yaml, and
tsconfig.json. They local_path_override this module so changes to the
plugin apply on the next bazel run //:gazelle. See
examples/README.md.
If you are updating from a version that emitted ts_project / js_test
directly:
map_kind directives for ts_library, ts_test, and any generated
ts_bundler_config rules.composite, declaration,
source_map, declaration_map), transpiler, and tsconfig into wrapper
macros.entry_point handling into the ts_test wrapper, or use a multi-entry
runner.ts_project_references,
ts_library_kind, ts_test_kind, ts_tsconfig, ts_transpiler,
ts_test_entry_point, and ts_test_entry_point_auto.bazel test //...
Tests in crates/ and ts/ run on linux-x86_64 and macos-arm64 in CI. The
example workspaces run on linux-x86_64 only.
Bazel build setup, a Gazelle TypeScript language extension, and the Rust import-extractor that powers it (linked in via cgo).
@hermeticbuild/gazelle_ts