Skip to content

Latest commit

 

History

106 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rules_typescript

An opinionated Bazel ruleset for TypeScript, optimised for the Oxc + Vite toolchain rather than broad compatibility with every JS build tool. If your stack is TypeScript, Vite, and a Vite-based framework — this replaces tsc, your bundler, and your dev server with a single hermetic build. If you need tsc compatibility or non-Vite toolchains, see aspect-build/rules_ts.

Rust and Go do the work: Oxc compiles, tsgo type-checks. Bundling and dev serving speak one generated Vite config, run by Vite or by oj. Gazelle writes the BUILD files. Write .ts, run Gazelle, bazel build //.... No node_modules/. No system Node. Just Bazelisk.

Coming from an existing TypeScript repository: Install is the short path, and the Quick Start covers the migration questions.

Full documentation: mikn.github.io/rules_typescript

Built for the Vite Ecosystem

Vite bundles, and Vite or oj serves. Frameworks that ship a Vite plugin fit either, because both read the same generated config.

  • React + Vite — plain Vite: SPA bundle, CSS modules, and Fast Refresh HMR under react_refresh = True.
  • Remix — SPA bundle and SSR via remix_build. Routes get their own chunks.
  • SvelteKit — SSR via sveltekit_build, components via svelte_library. Both Vite passes run: hashed chunks in client/, and a server/manifest.js route id per route directory. svelte_library emits either the compiler's browser or its SSR output, picked by generate ("client" by default).
  • TanStack Start — bundle, and server functions that reach the client through a generated handler id. No dev server: its SSR module runner inlines react/jsx-runtime instead of externalising it against a node_modules tree that is a build output.
  • Solid Start — no bundle target. @solidjs/start ships no Vite plugin: defineConfig() returns a vinxi app, which ts_bundle's vite_config contract (a default export with a plugins array) cannot consume.

Where a target cannot be built, Gazelle writes none and reports why.

Non-Vite frameworks are not a priority. Next.js is the exception. next_build runs the framework's own build from declared inputs; next_dev_server and next_serve run the app from source or from that build. Both routers work, both API-route flavours, "use client"/"use server", middleware, CSS and static image imports. The build action runs with the network blocked, so next/font/google fails with a diagnostic naming the download; allow_network = True is the opt-out. See next_build.

Key Ideas

  • Oxc compiles — Rust-based TypeScript/JSX transformer. .js + .js.map per file, and .d.ts too under declarations = "oxc".
  • tsgo type-checks — Go port of TypeScript, and it emits the declarations too, so unmodified TypeScript compiles: no export annotations required, and the .d.ts are what tsc would produce. Type errors fail bazel build.
  • Vite bundles — production bundles with tree-shaking, code splitting, minification. App mode (HTML + hashed assets) and lib mode.
  • The dev server is swappablets_dev_server(server = ...) takes any target providing DevServerInfo. Vite is the default; @rules_typescript//oj:dev_server selects oj, a Rust-native server that adopts the same generated Vite config and needs no @npm//:vite in the tree. What each server does not read is declared in its provider, so a target depending on a field its server ignores fails at analysis time naming both.
  • Isolated declarations — annotate a package's exports and set declarations = "oxc", and Oxc emits its .d.ts syntactically, which moves type-checking off the critical path and shortens a deep dependency chain substantially. Opt-in, per package — see Cost of each mode.
  • Gazelle generates BUILD files — infers targets from the directory tree, resolves imports to labels, generates lint, bundler and dev-server targets, and takes eleven # gazelle:ts_* directives. It regenerates the attributes it owns on every run and names every value it drops, so a value it cannot derive needs # keep — see Attributes Gazelle owns.
  • CSS modulescss_module runs postcss-modules once, generates the .d.ts and the scoped-name map from that result, and hands the map to Vite. styles.button type-checks against the keys the stylesheet exports, and the class name in a test is the one in the bundle — see CSS and assets.
  • Direct dependencies — a source may import only what a direct dep provides. A declaration arriving through another dep's own deps does not satisfy an import: the build fails naming the file, the specifier and the label to add, and bazel run //:gazelle writes it.
  • How npm packages are fetched — one Bazel repository per package, fetched on demand, behind a @npm alias hub, so a target fetches only its own dependency closure. A generated node_modules tree holds every resolution that closure made — name, version and peer set — flat where a name resolved once, keyed by resolution where it did not.
  • Zero prerequisites — only Bazelisk needed; Node.js, Go, Rust and pnpm are all fetched hermetically. A pnpm-lock.yaml is the one npm input there is — no npm or yarn lockfile is read — but the pnpm binary itself is only for editing that file, never for a build.

Requirements

The only prerequisite is Bazelisk (or Bazel 9+). Everything else — the Rust toolchain, Go toolchain, Node.js runtime, and the npm packages your targets actually reach — is fetched hermetically. The first build compiles oxc-bazel from Rust source, the slow part; everything after that is cached.

Supported platforms: Linux x86_64, Linux ARM64, macOS x86_64, macOS ARM64. Windows is not supported right now. It may be considered in the future. See COMPATIBILITY.md.

Nothing has shipped yet. There is no tag, no release, no Bazel Central Registry entry and no production users. Pre-1.0, any commit may break the API with no deprecation window. Every break is listed in CHANGELOG.md with the edit it requires; read it before moving a pin. Full policy: COMPATIBILITY.md.

Vite and vitest are your dependencies, not the ruleset's: they come from your own lockfile, and the rules generate configuration for whichever version it resolves to. The versions the tests exercise, and the places a generated config is version-sensitive, are in COMPATIBILITY.md.

Install

Step 1. Create .bazelversion:

9.2.0

Step 2. Add to MODULE.bazel. The ruleset is not on the Bazel Central Registry yet, so bazel_dep alone has nothing to resolve against; pin it from git:

module(name = "my_project", version = "0.0.0")

bazel_dep(name = "rules_typescript", version = "0.2.0")
git_override(
    module_name = "rules_typescript",
    remote = "https://github.com/mikn/rules_typescript.git",
    commit = "REPLACE_WITH_A_COMMIT_SHA_FROM_MAIN",
)
register_toolchains("@rules_typescript//ts/toolchain:all")

bazel_dep(name = "gazelle", version = "0.47.0")

Pin a full commit SHA, not a branch. bzlmod still requires version on bazel_dep and ignores its value while the override is active. The archive_override (smaller fetch) and local_path_override forms are in Depending on rules_typescript.

Step 3. Add to .bazelrc:

build --incompatible_strict_action_env
build --nolegacy_external_runfiles
build --output_groups=+_validation

Those three lines are the whole file. Do not add an @rules_rust flag: rules_rust is a transitive dependency of rules_typescript, not of your module, so Bazel cannot resolve the label and rejects the invocation with No repository visible as '@rules_rust' from main repository.

Step 4. Add to BUILD.bazel at the repository root. The file has to exist even if empty: rules_rust resolves //:MODULE.bazel while fetching crates, which requires the root to be a Bazel package:

load("@gazelle//:def.bzl", "gazelle")

gazelle(
    name = "gazelle",
    gazelle = "@rules_typescript//gazelle:gazelle_typescript",
)

Point at gazelle_typescript, not gazelle_ts: the latter also carries the Go and proto languages, because rules_typescript generates BUILD files for its own .go sources, and in a polyglot repo it would rewrite Go BUILD files you never asked it about.

Step 5. Write TypeScript. Export annotations are optional: tsgo emits the declarations from the full type program, so an inferred return type is fine:

export function add(a: number, b: number) {
  return a + b;
}

Step 6. Generate BUILD files, build, and test:

bazel run //:gazelle
bazel build //...
bazel test //...

Adding npm Dependencies

One-time setup in MODULE.bazel:

npm = use_extension("@rules_typescript//npm:extensions.bzl", "npm")
npm.translate_lock(pnpm_lock = "//:pnpm-lock.yaml")
use_repo(npm, "npm", "pnpm")

Take "pnpm" even if you never run pnpm through Bazel: Gazelle writes ts_pnpm and ts_add_package targets into your root BUILD.bazel as soon as a lockfile exists, and without that repo bazel build //... aborts with No repository visible as '@pnpm' from main repository.

Then, per package:

pnpm add zod --lockfile-only   # updates pnpm-lock.yaml, no node_modules created
bazel run //:gazelle           # picks up new package, updates BUILD files
bazel build //...              # fetches just that package's closure, builds

Bazel fetches a package the first time a target needs it. No node_modules/ directory ever exists in the source tree; the lockfile is the only npm artifact in git.

bazel run //:pnpm -- add zod --lockfile-only uses a hermetic pnpm — two lines of setup.

IDE Integration

ts_refresh_tsconfig writes the workspace-root tsconfig.json from Bazel's build graph: source roots, path aliases, and one compilerOptions.paths entry per npm package your targets reach that ships declarations, pointing at the copies it installs under .bazel/npm. The file is meant to be checked in, and test = True adds a test that fails once it goes stale. Because it is an ordinary checked-in config, an editor, a plain tsc run and a coding agent's language server all resolve Bazel's declarations through it with no setup. A tsserver plugin is installed alongside it for editors that want live resolution instead of a re-run; that one needs configuring.

# BUILD.bazel
load("@rules_typescript//ts:defs.bzl", "ts_refresh_tsconfig")

ts_refresh_tsconfig(
    name = "refresh_tsconfig",
    test = True,
    deps = [
        "//apps/web",
        "//packages/ui",
    ],
)

deps is the whole input. An aspect walks it, so listing a target covers everything it depends on; the default, deps = [], writes an empty paths. It obeys visibility, so a package-private target cannot be listed.

bazel run //:refresh_tsconfig        # writes tsconfig.json, .bazel/npm/, and the plugin
bazel test //:refresh_tsconfig_test  # fails when the checked-in tsconfig is stale

The plugin is optional. To turn it on, point tsserver's plugin probe at .bazel and name @rules_typescript/tsserver-plugin — per editor, and for a coding agent's language server, in IDE Setup.

nested_tsconfigs lists the packages that need their own editor program, as workspace-relative paths to the tsconfig.json each one gets. A package belongs there when its targets set compilerOptions the root block cannot also be set to. The list is declared, not discovered, and the rule fails at analysis time when it disagrees with the graph in either direction — so a repository with one such package fails the snippet above until the list is filled in. That attribute, extra_exclude, npm_dir and the other editors are in IDE Setup.

Documentation

  • Quick Start — new project or migrating an existing codebase
  • IDE Setup — a generated tsconfig.json plus live tsserver resolution from Bazel's build graph (TypeScript's GOPACKAGESDRIVER)
  • Isolated Declarations — the opt-in throughput mode
  • npm Dependencies — pnpm lockfile integration, platform-specific packages, bin scripts
  • Testing with vitestts_test, snapshots, sharding, watch mode with ibazel
  • Bundlingts_bundle with Vite or any BundlerInfo-compatible bundler
  • Dev Server — a pluggable dev server with ibazel HMR: Vite by default, oj through server = "@rules_typescript//oj:dev_server", one generated config driving either
  • Monorepo Layout — package boundaries, cross-package .d.ts caching
  • Gazelle Reference — directives, framework detection, auto-detected lint and codegen targets
  • Rules Reference — all attributes, providers, and outputs
  • Migration from rules_ts — differences from aspect-build/rules_ts
  • Troubleshooting — the error messages, by message text
  • Compatibility — Bazel and platform support, the Vite/vitest versions the tests exercise, and what "pre-1.0" means here

License

MIT

About

TypeScript rules for Bazel using Oxc and tsgo. TypeScript on Bazel should feel like Go on Bazel.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages