mailwal

Lesson 10: TypeScript at scale, with the native compiler

Frontend Engineering
  • One current ongoing course with private github repository access.

  • Annual/lifetime subscribers would also get all future courses and source code access. Read more


Lesson 10: TypeScript at scale, with the native compiler

Phase 3 · Language, Contracts & Design Handoff

Oct 27

What you’ll be able to do

You’ll turn a monorepo where TypeScript checked five hand-picked files into one where it checks all twelve projects, in dependency order, skipping anything that hasn’t changed. Then you’ll settle “is the TypeScript 7 native compiler actually faster?” with a measurement on your own code instead of a headline. You’ll know you’ve got this when you change one function’s signature in @pulse/utils, run pnpm typecheck, and get the exact three call sites in two other packages that broke, from both compilers, before anything runs.

The concept

Think of a school with one exam office checking every class’s paperwork. If each class keeps its own folder, and a sign-up sheet says which classes copy from which, the office only re-checks a folder when something it copies from has changed. That’s a TypeScript project reference: each package is its own project with its own tsconfig.json, it declares which projects it depends on, and tsc -b (build mode) checks them in order and skips any whose inputs haven’t changed. The native compiler is TypeScript 7, the same type checker rewritten in Go so it runs as a native program instead of on Node; it promises the same answers, faster. See the picture in lesson-10-design-diagram.svg for how the twelve projects reference each other.

The visual deliverable

The Control Tower gains a Compiler benchmark panel, third on the board: three bar charts (cold build, no-change rebuild, one-file edit) comparing tsc 5.9 and tsc 7.0 with the real medians from pnpm bench:tsc, a ring for every individual run, a verdict on whether both compilers gave the same answers, and a Run benchmark button that re-measures on the server and redraws the panel live.

Implementation steps

GitHub Link:

https://github.com/sysdr/frontend-engineering-p/tree/main/lesson-10-code/lesson-10-typescript-at-scale-with-the-native-compiler

Step 1: Put the compiler options in one place

Twelve projects with twelve copies of strict: true would drift within a month. One base file in the config package holds every option; Node tooling extends it and adds Node’s types. composite and emitDeclarationOnly are what make a project referenceable: each one writes .d.ts files a downstream project reads instead of re-checking your source.

packages/config/typescript/base.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "//": "Lesson 10: the one set of compiler options every project in the monorepo shares. Browser code by default; Node projects extend node.json instead.",
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["es2023", "dom", "dom.iterable"],
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "skipLibCheck": true,
    "types": [],
    "composite": true,
    "declaration": true,
    "emitDeclarationOnly": true
  }
}

Verify: cat packages/config/typescript/node.json shows "extends": "./base.json" and "types": ["node"].

Step 2: Give every package its own project, and the root a solution

Each package’s tsconfig.json extends the base, says where to write its output, and references the packages it imports. The monolith imports three apps, so it references three. The root file checks nothing itself: it’s a list of the twelve projects, so tsc -b at the root builds them all.

packages/design-system/tsconfig.json, apps/monolith/tsconfig.json, then the root tsconfig.json (full file in the zip)

{
  "extends": "../config/typescript/base.json",
  "compilerOptions": { "rootDir": ".", "outDir": "../../.tsbuild/design-system" },
  "include": ["src/**/*.js"],
  "references": []
}
{
  "extends": "../../packages/config/typescript/base.json",
  "compilerOptions": { "rootDir": ".", "outDir": "../../.tsbuild/monolith" },
  "include": ["src/**/*.js"],
  "references": [{ "path": "../shell" },{ "path": "../billing" },{ "path": "../analytics" }]
}
{
  "//": "Lesson 10: the solution file. It checks nothing itself; `tsc -b` builds every project below in dependency order and skips any whose inputs have not changed.",
  "files": [],
  "references": [
    { "path": "packages/utils" },
    { "path": "packages/api-client" },
    { "path": "packages/config" },
    { "path": "packages/design-system" },

Verify: pnpm typecheck exits 0, and ls .tsbuild lists twelve folders, one per project.

Step 3: Keep TypeScript’s map of the repo equal to pnpm’s

References are a second copy of the dependency graph, and second copies drift. check-ts-projects compares each package’s references to its @pulse/* dependencies in package.json, in both directions, and also checks that every project extends the base and that the solution lists them all. It runs inside pnpm lint.

scripts/check-ts-projects.mjs (full file in the zip)

      const want = pkg.deps.map((d) => dirOf.get(d)).filter((d) => d !== undefined).sort();
      const have = referencesOf(p);
      for (const d of want) if (!have.includes(d)) problems.push(`${pkg.name} depends on ${packages.find((x) => x.dir === d)?.name} in package.json, but ${p.dir}/tsconfig.json does not reference ${d}.`);
      for (const d of have) if (!want.includes(d)) problems.push(`${p.dir}/tsconfig.json references ${d}, but ${pkg.name} does not depend on it in package.json.`);

Verify: pnpm check:ts-projects prints OK: 12 TypeScript projects (one per package, plus apps/control-tower/server and scripts); every package's references match its package.json dependencies, and all extend the shared base.

Step 4: Install both compilers side by side

Both compilers publish a package called typescript with a binary called tsc, so the native one is installed under an npm alias. Scripts call each by its path, never a bare tsc, so you always know which one ran. The gate uses the native compiler; 5.9 stays as a cross-check.

package.json (excerpt)

    "typecheck": "node node_modules/typescript-native/bin/tsc -b",
    "typecheck:js": "node node_modules/typescript/bin/tsc -b",
    "typescript": "5.9.3",
    "typescript-native": "npm:[email protected]",

Verify: pnpm typecheck:js also exits 0 with no output: the same twelve projects pass under both compilers.

Step 5: Fix what checking the whole repo found

Three things surfaced. load-remote.js typed another team’s remote-entry.json as any, which silently switched type checking off for every use of it; it’s now unknown until a type guard (a function that proves a value’s shape, so TypeScript trusts it afterwards) says otherwise. BUTTON_VARIANTS became its two literal values (design system 1.2.1). And TypeScript 7 checks side-effect imports like import "./stories.css" that 5.9 skipped, so Storybook declares what a .css import is.

apps/shell/src/federated/load-remote.js (excerpt), then apps/storybook/css.d.ts

/**
 * Lesson 10: remote-entry.json comes from another team's deploy, so it is
 * `unknown` until this proves its shape. Typed as `any` it hid a broken call
 * to satisfies() from the type checker; typed like this, it cannot.
 * @param {unknown} x
 * @returns {x is RemoteEntry}
 */
export function isRemoteEntry(x) {
  if (typeof x !== "object" || x === null) return false;
  const e = /** @type {{ version?: unknown, exposes?: Record<string, unknown> }} */ (x);
  return typeof e.version === "string" && typeof e.exposes === "object" && e.exposes !== null && typeof e.exposes["./mount"] === "string";
}
// Lesson 10: Storybook's Vite build imports stylesheets for their side effect.
// TypeScript 7 checks side-effect imports by default (TS 5.9 skipped them),
// so the native compiler needs to be told a .css import is a real module.
declare module "*.css";

Verify: pnpm try-type-break && pnpm typecheck lists three errors, the first at apps/shell/src/federated/load-remote.js(63,20); with any it listed two. Then pnpm try-type-break --restore.

Step 6: Measure, fairly, and check the answers too

A benchmark you run once on a warm machine is a rumour. bench-tsc runs each compiler through three real situations, five rounds, alternating which goes first, and keeps the median (the middle value, so one slow run can’t drag the result). It also compares every diagnostic each compiler prints, because a faster compiler that gives different answers isn’t a replacement.

scripts/bench-tsc.mjs (full file in the zip)

    for (let round = 0; round < rounds; round++) {
      /** @type {CompilerId[]} */
      const order = round % 2 ? ["native", "js"] : ["js", "native"];
      for (const id of order) {
        rmSync(BUILD_DIR, { recursive: true, force: true });
        const cold = runCompiler(id);
        runs.cold[id].push(cold.ms);
        if (round === 0) results[id] = { exitCode: cold.exitCode, diagnostics: parseDiagnostics(cold.out) };
        runs.noop[id].push(runCompiler(id).ms);
        writeFileSync(EDIT_FILE, readFileSync(EDIT_FILE, "utf-8") + EDIT_LINE);
        try {
          runs.edit[id].push(runCompiler(id).ms);
        } finally {
          restoreEditFile();
        }
export function compareResults(r) {
  const a = new Set(r.js.diagnostics.map(keyOf));
  const b = new Set(r.native.diagnostics.map(keyOf));
  const onlyJs = [...a].filter((k) => !b.has(k));
  const onlyNative = [...b].filter((k) => !a.has(k));
  const passJs = r.js.exitCode === 0;
  const passNative = r.native.exitCode === 0;
  return { agree: passJs === passNative && !onlyJs.length && !onlyNative.length, passJs, passNative, onlyJs, onlyNative };
}

Verify: pnpm bench:tsc ends with OK: both compilers give the same answer: no errors. and writes .bench/tsc.json.

Step 7: Draw it on the Control Tower

The panel reads .bench/tsc.json through /api/bench and draws three small bar charts from plain DOM, like every panel since Lesson 2. Each chart gets its own axis, because a cold build takes seconds and a no-change rebuild takes milliseconds; on one shared axis the small ones would shrink to slivers. Each axis ends on a round number that splits into four round ticks.

apps/control-tower/src/panels/compiler-bench.js (full file in the zip)

/** A round end for an axis: the smallest "nice" number (1, 1.2, 1.6, 2, 2.4, 3, 4, 6 or 8 x 10^k) at or above n. Each splits into four round ticks. @param {number} n */
export function niceMax(n) {
  if (!(n > 0)) return 1;
  const p = 10 ** Math.floor(Math.log10(n));
  return ([1, 1.2, 1.6, 2, 2.4, 3, 4, 6, 8, 10].find((m) => m * p >= n - 1e-9) ?? 10) * p;
}

Verify: run node scripts/deploy-remotes.mjs and pnpm dev, open http://localhost:4100/, and the Compiler benchmark panel shows a green Native 5.2x faster, cold badge (your number) with blue bars shorter than grey in all three charts.

Step 8: Make CI enforce it

CI type-checks with the native compiler, then runs a three-round benchmark. The benchmark exits 1 only if the compilers disagree, so while both are installed, any difference in answers blocks the merge, and the timings are kept as a build artifact.

.github/workflows/ci.yml (excerpt)

      - run: pnpm typecheck
      - name: Both compilers, same answers
        run: pnpm bench:tsc --rounds 3

Verify: pnpm bench:tsc --rounds 3; echo $? prints the OK: lines and 0.

The architecture decision

We made the native compiler the gate (pnpm typecheck) and kept TypeScript 5.9 installed beside it as a cross-check. The alternative on the table was staying on 5.9 until the native compiler had been around longer. On this repo the measured answer is not hype: about 5x faster on a cold build and on a one-file edit, with identical errors. But read the whole chart. On 6,600 lines that’s four seconds saved cold, and the no-change rebuild only improved 1.8x, because there’s barely any checking left to speed up. The bigger win was structural: without build mode every run is a full check (the 5-second cold number), and with project references the JavaScript compiler’s no-change rebuild is 133 ms before the compiler changes at all. Stay on 5.x as the gate when your toolchain loads TypeScript as a JavaScript library, such as type-aware lint rules or codegen built on the compiler API, and that tooling hasn’t caught up with 7 yet.

The proof

  1. The whole monorepo type-checks with either compiler. Run pnpm install --frozen-lockfile, pnpm build, pnpm check:ts-projects, pnpm typecheck, pnpm typecheck:js. You see OK: 12 TypeScript projects (one per package, plus apps/control-tower/server and scripts); ..., then both typechecks exit 0 and print no errors.

  2. Measure both compilers. Run pnpm bench:tsc. Exit code 0, with your machine’s numbers:

OK: tsc 7.0.2 (native) vs tsc 5.9.3 (JavaScript): tsc -b over 12 projects, 94 files, 6,616 lines. Median of 5 runs on 2 CPUs.
    Cold build         5,160 ms  -> 987 ms    5.2x faster
    No-change rebuild  133 ms    -> 76 ms     1.8x faster
    One-file edit      834 ms    -> 163 ms    5.1x faster
OK: both compilers give the same answer: no errors.
  1. See it in the Control Tower. Run node scripts/deploy-remotes.mjs and pnpm dev, open http://localhost:4100/. The Compiler benchmark panel, third on the board, shows a green Native 5.2x faster, cold badge matching the terminal’s Cold build line; fact cards whose speedups and medians match step 2 line for line, Answers: Identical / both: 0 errors, and pnpm typecheck runs tsc 7.0.2; three charts where the blue native bar is shorter than the grey JavaScript one, with five rings per bar; Same answer: no errors; and a table of all 30 runs.

  2. Break a shared contract on purpose. Keep the page open. In a second terminal run pnpm try-type-break then pnpm typecheck. Exit code 2, with exactly these three errors:

apps/shell/src/federated/load-remote.js(63,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Version'.
scripts/check-remotes.mjs(42,32): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Version'.
scripts/check-remotes.mjs(43,53): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Version'.
  1. pnpm typecheck:js prints the same three lines. Now press Run benchmark in the panel: the badge reads Benchmark running and the button greys out; about 40 seconds later, without a reload, the badge turns amber, Both report 3 type errors, the Answers card reads both: the same 3 errors, the three call sites are listed, and the No-change rebuild bars are much longer, because tsc -b never marks a project with errors as up to date.

  2. Put it back. Run pnpm try-type-break --restore, pnpm typecheck, pnpm bench:tsc. You see Restored: satisfies() takes a version string again., a clean typecheck, and step 2’s OK: lines; the panel turns green again without a reload.

  3. The automated version. pnpm test and pnpm test:e2e: 194 unit tests and 33 browser tests pass.

Working Demo Link:

The review

  • When a pull request adds an @pulse/* dependency, does it add the matching references entry in the same change, or does it only pass because the lint step was skipped locally?

  • Is any data from outside this package (a fetched JSON file, another team’s deploy) typed any or force-cast, where unknown plus a type guard would keep the checker honest?

  • Are the benchmark numbers medians from alternating runs on one machine, or a single run someone pasted from a laptop with other things open?

  • Looking at the panel in both themes, can a reader tell the compilers apart without colour (each row is labelled), and does anyone read two charts’ bar lengths against each other when each chart has its own axis?

The takeaway

Type safety at scale comes from structure: one project per package, references that mirror the real dependency graph, and a check that keeps the two equal. A faster compiler makes that structure cheaper to run, and you only know how much cheaper, and whether it still gives the same answers, by measuring it on your own code.

© 2026 ctoi
548 Market Street PMB 72296, San Francisco, CA 94104