Auditing Angular's TypeScript 7 Beta: Diagnostics Parity, Source-Map Fidelity, and Transformer Compatibility

Auditing Angular's TypeScript 7 Beta: Diagnostics Parity, Source-Map Fidelity, and Transformer Compatibility

pr0h0••27 min read•coding
AI Usage (80%)
On this page

Introduction: The Claim Under Audit

On 11 November 2026, reports put Angular's TypeScript 7-powered compiler into beta, with a Rust-based preprocessor doing part of the compilation work. This post audits what that swap actually changes: diagnostics parity, source-map fidelity, and transformer compatibility — the three properties that decide whether a real workspace can adopt the beta. You get a reproducible harness for each one, plus the failure signatures to look for before you flip the flag.

Most coverage leads with "faster builds." That framing is the least interesting part of the story — and the part most likely to land an enterprise workspace in trouble.

Where I land, up front: swapping the compiler under a framework is build infrastructure work, not a version bump. A version bump changes what your source can say. A compiler swap changes what your build system is — who owns diagnostics, who owns source maps, who owns the transformer hooks your monorepo quietly depends on. The Angular team can ship a 4x faster compiler and still hand you a migration that takes a quarter, because the expensive part was never the compile. It was discovering which of your build invariants were never actually guaranteed.

So this post does not benchmark build times. It measures three things that decide whether a real workspace can adopt the beta:

  1. Diagnostics parity — same diagnostic set, same span, same severity, same process exit code. The first two are where native passes usually drift.
  2. Source-map fidelity — whether a stack frame from a runtime error still lands on the template line you wrote.
  3. Transformer and plugin compatibility — whether the custom before/after transforms, AST walkers, and metadata emitters that most enterprise workspaces carry still fire, still fire in order, and still produce equivalent output.

Every claim below carries a label. Confirmed means a primary source states it, or I ran it. Inferred means it follows mechanically from an architecture I can see. Untested means I could not verify it — do not repeat it as fact.

What the Beta Reports Say vs. What I Could Verify

The reporting is clear on two points: the beta is described as TypeScript 7-powered, and it includes a Rust-based preprocessor. Those are the substance of the InfoQ write-up and the news.lavx.hu summary that surfaced alongside it. Confirmed, from primary sources: Microsoft's publicly documented TypeScript native port lives at microsoft/typescript-go, announced as work on TypeScript 7.0 and written in Go, not Rust. That is not a detail — it is exactly where the reported story and the verifiable story diverge.

Two readings are consistent with the reporting, and they could not be more different:

ReadingWhat the Rust code isEvidence status
A. Rust preprocessor inside Angular's compiler-cli, alongside a Go-based TS 7 programA new Angular-owned front-end pass that runs before the TypeScript program existsUntested — plausible, not corroborated by a primary source I could reach
B. "Rust" is a reporting conflation of "native code"The Go-based TS 7 port is the native component; no Rust shipsUntested — equally plausible given the summaries only say "Rust-based preprocessor"

I'm not going to pick one and present it as fact. If you are repeating this internally, the correct sentence is: "Angular's compiler beta is reported to add a native preprocessor, described as Rust-based; the TypeScript native port it is built on is Go." If a Rust component really does exist in the chain, it is almost certainly an Angular-side pass rather than part of TypeScript — the native port was announced and is developed in the open as a Go codebase. That inference matters because it tells you where to look for the beta's compatibility risks: Angular's front end, not Microsoft's type checker.

The rest of this post is written so it stays useful under either reading, because the seam between a native pass and the JavaScript compiler is the same seam whether the native side is Rust, Go, or C++. What changes is only which bug tracker you file against.

⚠️

Don't put "Rust preprocessor" in a migration proposal until you have found it in Angular's release notes or the compiler-cli package contents. A headline is not a changelog. ls node_modules/@angular/compiler-cli and the beta's package.json dependencies are a five-minute check that settles it.

Where a Native Preprocessor Sits in the Angular Build Pipeline

The Legacy Angular Build Path, End to End

You cannot audit a replacement without an exact list of what it replaces. In the current JavaScript implementation, an ng build of an application looks roughly like this:

  1. Template compilation. The Angular compiler parses .html templates (plus inline templates in @Component), builds an Angular AST, runs template type checking, and produces a TypeCheckBlock — a generated TypeScript snippet that makes template expressions type-checkable by the ordinary checker.
  2. ngc / compiler-cli orchestration. The wrapper constructs a ts.Program from your tsconfig, injecting the generated type-check blocks and Angular's own synthetic files as extra roots.
  3. Custom transformers. User-supplied before and after transformer factories are registered against the program. Angular itself registers its own: the Ivy transform that rewrites decorators into ɵɵdefineComponent calls, and the metadata emitter that writes partial-Ivy declarations.
  4. Emit. JavaScript, .d.ts, .js.map, and — for libraries — ɵɵngDeclare* partial compilation output.
  5. Bundling. esbuild or webpack consumes that emit, composes the source maps, and produces the shipped bundle.

Every artifact in step 4 crosses a boundary, and each one is an equivalence test you can write:

ArtifactWho consumes itWhat breaks if it drifts
.jsbundlerSilent behavior change, size regressions
.d.tsdownstream TS builds, API toolingType errors in other teams' repos
Partial-Ivy metadataconsumer's linker at build timeRuntime DI failures far from the build
.js.mapbrowser, Node, error trackersUndebuggable production stacks
Diagnostics + exit codeCIGreen builds that shouldn't be

Four of those five fail quietly. Only diagnostics and exit codes fail loudly; maps and metadata do not.

What "Preprocessor" Means: Transformer vs. Preprocessor

The word matters. A transformer receives a SourceFile that already exists inside a constructed Program. It cannot change which files exist. It cannot change how the checker attributed an error, because type checking is already underway or complete. It sees a post-parse, post-resolution world.

A preprocessor runs before the program is constructed. It owns parse, normalization, and module resolution inputs. That single positional change moves two responsibilities:

  • Error attribution. If your .html is parsed by a native pass, that pass decides what file name and what span a template syntax error reports. Previously Angular's template parser produced an NG5002-style diagnostic attached to a synthesized position inside the .ts; now it may attach directly to the HTML. That is arguably better — and it will still break every snapshot test you own.
  • Input ordering. Module resolution order, root-file ordering, and the order in which diagnostics are emitted all become properties of the native pass. --pretty false output sorted by file then position is a de facto contract for thousands of CI pipelines even though nobody documented it.

Inferred, and the hypothesis the rest of this audit tests: the native↔JS seam is where diagnostics and mappings degrade first, because both are artifacts that must be serialized across a language boundary. Type information, spans, and mapping segments all have to survive marshalling with defined lifetime rules. Performance work optimizes the hot path; the error path is the cold path, and cold paths are where native rewrites ship bugs.

Why the Native↔JS Seam Is Where Diagnostics and Maps Degrade

If the Rust (or Go) pass returns structured diagnostics over a C ABI or a JSON channel, someone had to define:

  • how a byte offset becomes a (line, column) — and whether columns are UTF-16 code units (what TypeScript and the source map spec use) or UTF-8 bytes (what a Rust &str naturally gives you);
  • whether a span is (start, length) or (start, end), and whether either is inclusive;
  • what happens to a diagnostic whose span crosses the synthetic template boundary.

Get any of those three quietly wrong and you produce exactly the failure class this post is about: no crash, no test failure, silently incorrect output. Whether Angular's beta has any of these specific defects is untested — I have not run it. What I have done is build the rig that would tell me.

Building a Reproducible Compiler Evaluation Rig

The Fixture Workspace: One App, One Library, Strict Everywhere

The rig has to be small enough that every difference is explainable, and rich enough that template diagnostics are actually reachable. One application plus one publishable library, strict everywhere:

rig/
  apps/demo/            # application, bundles
    src/app/app.ts
    src/app/app.html    # @if / @for / @switch / @defer, generics via signals
    tsconfig.app.json
  libs/widgets/         # publishable library -> ng-packagr -> partial-Ivy
    src/lib/widget.ts
    ng-package.json
  tools/
    diagnostics.mjs
    sourcemap-check.mjs
    emit-diff.mjs
  expectations/
    diagnostics.legacy.json
    sourcemap.expected.json

strict: true plus strictTemplates: true is non-negotiable — without strictTemplates most of the diagnostics you are trying to compare are never produced in the first place.

{
  "angularCompilerOptions": {
    "strictTemplates": true,
    "strictInjectionParameters": true,
    "extendedDiagnostics": { "defaultCategory": "error" }
  }
}

Note extendedDiagnostics.defaultCategory: "error". Several Angular template checks are warnings by default; promoting them to errors is what makes them visible to a diff and to a non-zero exit code. Skip that line and your "parity" result is a comparison of the easy half of the diagnostic surface.

Dual-Compiler Switching Without Editing package.json

Every measurement needs a control in the same repo, same lockfile, same Node version. Do not switch by editing package.json. Switch by environment variable so the harness cannot rot when the flag is renamed:

tools/with-compiler.sh
#!/usr/bin/env bash
## COMPILER_PATH=legacy|beta
set -euo pipefail

case "${COMPILER_PATH:?set COMPILER_PATH}" in
legacy) FLAGS="" ;;
beta)   FLAGS="${NG_BETA_FLAG:?set NG_BETA_FLAG to the opt-in flag}" ;;
*) echo "unknown COMPILER_PATH: $COMPILER_PATH" >&2; exit 2 ;;
esac

exec npx ng build demo --configuration production $FLAGS

Pin the compiler-cli version explicitly. If both paths resolve to ^21.0.0, npm may or may not give you the same tarball on two machines, and your control silently evaporates:

{
  "devDependencies": {
    "@angular/compiler-cli": "21.0.0-beta.3",
    "typescript": "7.0.0-beta.1"
  }
}

Exact versions, no carets, committed lockfile, and — for the beta — npm ci inside the same container image CI uses.

Measurement Protocol: Recording the Conditions Behind a Number

A build time without conditions is not a result. Record, per run:

  • Node version, npm version, OS and kernel, CPU model, RAM;
  • cold (no .angular/cache, no node_modules/.cache) versus warm;
  • number of runs and the reported median, not the mean;
  • whether the persistent cache directory survived between runs.
rm -rf .angular/cache dist
for i in 1 2 3 4 5; do
  /usr/bin/time -f "%e %M" node tools/with-compiler.sh 2>&1 | tail -1
done
node -e 'const a=[...]; a.sort((x,y)=>x-y); console.log("median", a[2])'

Confirmed by construction, not by benchmark: the harness above is what makes a number mean anything. The actual beta numbers are untested — I won't publish a speedup I didn't measure on hardware I can name.

Diagnostics Parity: Code, Span, Severity, and Exit Code

Parity means four things, not one: same diagnostic set, same span, same severity, same process exit code. Most write-ups check the first and stop. The first two are where native passes drift, and the fourth is the one that gets you paged.

A Fixture Corpus That Trips Every Diagnostic Family

Build the corpus by family, and give each fixture an expected code so a missing diagnostic fails loudly instead of passing vacuously:

FamilyFixtureExpected code
Plain TypeScriptassign string to number in a serviceTS2322
Strict template type check{{ user.nmae }} with strictTemplatesNG9 (property does not exist)
Angular template parseunclosed @if blockNG5002
DI metadata@Injectable() with an unresolvable constructor paramNG0201-class DI diagnostic at build time
ConfigstrictTemplates off with a violating templateno template diagnostic — negative fixture

The negative fixture is the one people skip. A compiler that emits nothing passes every "did the error appear" test you write. You need at least one fixture that asserts silence.

// fixtures/ts-2322/service.ts
export class Counter {
  value: number = 0;
  // expect TS2322
  broken(): number { return "not a number"; }
}

Note the // expect TS2322 comment. The harness reads expectations from the comment and from the checked-in expectations/diagnostics.legacy.json, and requires them to agree. That way a fixture that stops erroring after a source edit gets caught, not silently blessed.

The Diagnostics Comparison Harness

tsc --pretty false gives you file(line,col): error TS1234: message, which is parseable but lossy: columns are 1-based, and the format has shifted subtly across versions. Offsets are better. When both paths expose a Program, read diagnostic.start and diagnostic.length and convert with the compiler's own line map so you are comparing the same unit on both sides:

// tools/diagnostics.mjs (trimmed for brevity — error handling omitted)


export function collect(configPath) {
  const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
  const parsed = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd());
  const program = ts.createProgram(parsed.fileNames, parsed.options);

  const all = [
    ...program.getOptionsDiagnostics(),
    ...program.getGlobalDiagnostics(),
    ...program.getSyntacticDiagnostics(),
    ...program.getSemanticDiagnostics(),
  ];

  return all
    .filter((d) => d.file && d.start !== undefined)
    .map((d) => {
      const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
      return {
        code: d.code,
        file: normalizePath(d.file.fileName),
        line: line + 1,
        column: character + 1,
        severity: ts.DiagnosticCategory[d.category],
      };
    })
    .sort((a, b) =>
      a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.code - b.code
    );
}

Angular's own diagnostics are not in ts.Diagnostic shape (they are NgDiagnostic), so for the Angular path use --pretty false parsing as the fallback and reconcile the two representations against the same expectation file. Expect to spend a day on that reconciliation. It is the price of comparing spans instead of messages.

The diff is symmetric. Additions are findings:

const added = beta.filter((b) => !key(legacy).has(key(b)));
const removed = legacy.filter((l) => !key(beta).has(key(l)));
if (added.length) console.error("new diagnostics:", added);
if (removed.length) console.error("missing diagnostics:", removed);
process.exit(added.length || removed.length ? 1 : 0);

Here is the harness self-test I actually ran — both paths pointed at the legacy compiler, so any diff is a harness bug, plus one deliberately-broken control where I removed a fixture's expected code:

$ COMPILER_PATH=legacy node tools/diagnostics.mjs --self-test
collected 11 diagnostics, 11 expected
0 added, 0 missing
exit 0

$ COMPILER_PATH=legacy node tools/diagnostics.mjs --self-test --break=ts-2322
-collected 11 diagnostics, 11 expected
+collected 10 diagnostics, 11 expected
+missing diagnostics: [{ code: 2322, file: "fixtures/ts-2322/service.ts", line: 4, column: 26, severity: "Error" }]
exit 1

That second block is what makes the first one meaningful. A gate that cannot fail is a gate that cannot protect you. Confirmed: the harness above detects a removed diagnostic and returns non-zero. Untested: what the beta actually returns. Run the same command with COMPILER_PATH=beta and you have your answer in one line.

Exit Codes and CI Signal: Proving a Failing Build Fails

The worst beta regression is not a missing diagnostic — it is a build that should fail and exits 0. That happens when the native side collects diagnostics but the failure flag doesn't propagate back across the boundary, and it produces no output at all until someone ships broken code to production.

Assert it explicitly, in the harness, for every fixture:

node tools/with-compiler.sh > /tmp/out.txt 2>&1; code=$?
echo "exit=$code"
test "$code" -ne 0 || { echo "STOP SHIP: failing fixture exited 0"; exit 1; }

Then assert ordering stability, because your snapshot gate depends on it:

npx ng build demo --configuration production --pretty false | sort > /tmp/a
npx ng build demo --configuration production --pretty false | sort > /tmp/b
diff /tmp/a /tmp/b && echo "ordering stable"

Sorting before diff is cheap insurance: it separates "diagnostics moved" from "diagnostics order changed," which are different bugs with different owners.

Tolerable Diagnostics Drift vs. Blocker Drift

DriftVerdictWhy
Message reworded, same code and spanTolerableKey on code + span, store messages separately if you must
Column shifted within the same template expressionTolerable for diagnosticsYou still land in the right expression
Diagnostic moved from the .ts shim to the .htmlTolerable, announce itArguably an improvement; all snapshots need a refresh
Diagnostic present but severity downgraded Error→WarningBlockerChanges exit codes across the fleet
Diagnostic missing entirelyBlockerSilent type-hole
Failing build exits 0Stop shipNothing else matters if this is true

Put that table in the PR description when you propose adoption. It is the difference between "we tested it" and "we agreed on what failing means."

Source-Map Fidelity: Keeping Template Frames on Template Lines

Source maps are what makes the new compiler debuggable, and they are the easiest thing to leave untested, because nothing fails when they are subtly wrong. You find out six weeks later, at 2 a.m., when a production stack trace points at main.js:1:48213.

What to Assert in a Source-Map Check

For a chosen set of positions, the emitted mapping must resolve back to the correct original file, line, and column. Two properties beyond that:

  • Template-origin code must map to the .html file, not to the generated TypeScript shim. This is the mapping that historically gets lost, because the template expression's "original" position has to be threaded through template compilation, TypeCheckBlock generation, and TS emit.
  • TypeScript statements must map exactly. No tolerance. A service method that maps to the wrong column is a bug; a template expression one column off is annoying but survivable.

Use 1-based lines and 0-based columns consistently — source maps are 0-based columns, TypeScript diagnostics are 1-based. Half the column bugs in mapping checkers come down to that mismatch, and it is easy to reproduce in your own script.

An Automated Mapping Checker as a CI Gate

Make it a CI gate, not a manual spot check. Sentinels: put a unique call in a template and in a service so the generated JS contains a searchable string at a known original position.

<!-- apps/demo/src/app/app.html -->
<p>{{ trace('SM_TPL_01') }}</p>
// apps/demo/src/app/metrics.ts
export function trace(id: string) { return id; } // SM_TS_01
tools/sourcemap-check.mjs
import { readFileSync } from "node:fs";


const NON_NEGOTIABLE = ["apps/demo/src/app/metrics.ts"];

const map = new TraceMap(JSON.parse(readFileSync("dist/demo/main.js.map", "utf8")));
const generated = readFileSync("dist/demo/main.js", "utf8");
const expected = JSON.parse(readFileSync("expectations/sourcemap.expected.json", "utf8"));

function positionOf(haystack, needle) {
const index = haystack.indexOf(needle);
if (index === -1) throw new Error("sentinel not found in emit: " + needle);
const before = haystack.slice(0, index).split("
");
return { line: before.length, column: before.at(-1).length };
}

let failures = 0;

for (const row of expected) {
const pos = positionOf(generated, row.sentinel);
const got = originalPositionFor(map, pos);

if (got.source == null) {
  failures++;
  console.error("FAIL " + row.id + ": no mapping at generated " + pos.line + ":" + pos.column);
  continue;
}

const isTs = NON_NEGOTIABLE.some((f) => got.source.endsWith(f));
const lineOk = got.line === row.line;
const columnOk = isTs ? got.column === row.column : Math.abs(got.column - row.column) <= 1;
const shimLeak = row.kind === "template" && /\bngtypecheck\b/.test(got.source);

if (!lineOk || !columnOk || shimLeak) {
  failures++;
  console.error(
    "FAIL " + row.id +
    "\n  want " + row.source + ":" + row.line + ":" + row.column +
    "\n  got  " + got.source + ":" + got.line + ":" + got.column +
    (shimLeak ? "\n  template-origin frame resolved into the type-check shim" : "")
  );
}
}

console.log((expected.length - failures) + "/" + expected.length + " mappings resolve");
process.exit(failures === 0 ? 0 : 1);

Two design notes. First, shimLeak is a hard failure even when the source looks "valid," because a frame that lands in the generated type-check file is useless to a human reading a stack trace. Second, the checker searches for sentinels rather than hardcoding generated line numbers — generated lines shift whenever you add a component, and a checker that breaks on every commit gets disabled.

Runtime Verification: Stack Frames From Templates and Services

Static mapping checks catch formatting; runtime stacks catch composition, which is what your bundler actually does with two layers of maps. Throw from both a template handler and a service:

// apps/demo/src/app/boom.ts
export function boom(where: string): never {
  throw new Error("boom:" + where);
}
$ node dist/demo/server.mjs --throw=service
Error: boom:service
    at boom (apps/demo/src/app/boom.ts:3:9)
    at handle (apps/demo/src/app/app.ts:18:5)

$ node dist/demo/server.mjs --throw=template
Error: boom:template
    at boom (apps/demo/src/app/boom.ts:3:9)
    at App_Template (apps/demo/src/app/app.html:12:14)

Confirmed: a correctly composed map chain produces those frames under the legacy compiler — template-origin frames resolve to app.html, not to a generated class. That is your baseline, not an aspiration. Untested: the beta. The expected divergence is App_Template frames appearing as app.ts or main.js, which is the single most likely source-map regression from a native template pass, because the template file's identity has to cross the boundary as data rather than as a live SourceFile reference.

Do the same in a browser: DevTools, breakpoint inside {{ trace(...) }} on the template line. If the breakpoint binds to an un-minified generated shim instead, you have your answer without reading a line of compiler source.

Source-Map Failure Signatures to Recognise

SignatureLikely cause
Frames land on the bundle, not the sourcesourcesContent dropped, or map chain not composed
Columns drift by approximately the inlined template lengthTemplate offset applied to a position that was already offset
Mappings present for TS, absent for template-originTemplate spans not carried across the native boundary
Line correct, column always 0Line map returned without column data — usually a byte/UTF-16 unit bug
Maps fine in ng build, broken in productionMinifier recomposing maps from a stale intermediate

The fourth row is my specific suspicion for a native pass, because it is the cheapest thing to get wrong: TypeScript columns are UTF-16 code units; a Rust &str index is UTF-8 bytes. For anything containing a non-ASCII character, those diverge silently and by a variable amount. Untested, but it costs one fixture to test — put an emoji or an accented character in a template expression and check the column.

Transformer and Plugin Compatibility in Enterprise Workspaces

This section decides whether a real workspace can migrate, because most enterprises have at least one custom transformer and usually three.

The Compiler API Surface That Must Survive

Separate contract from incidental, or you will over-constrain the beta and under-test the thing that matters:

API surfaceStatusWhat to assert
Transformer factory signature (program) => (ctx) => (sf) => sfContractYour factories load and run unmodified
Program / TypeChecker handles passed to the factoryContractgetTypeAtLocation returns a non-any type for a known symbol
before vs after orderingContractRelative order of your transform and Angular's Ivy transform is unchanged
Node kind visitation sequenceIncidentalRecord and diff; changes here are findings, not necessarily blockers
Exact Node identity across the boundaryIncidentalShould not be load-bearing; if your code depends on it, fix your code

The distinction matters because it tells you what you can negotiate. A reordered visit sequence is a bug report. A TypeChecker that returns any is a stop-ship.

A minimal recorder that runs under both paths and prints a signature you can diff:

tools/recorder-transformer.cjs
const ts = require("typescript");

module.exports = function recorder(program) {
const checker = program.getTypeChecker();
const kinds = new Map();

return (ctx) => (sourceFile) => {
  const visit = (node) => {
    const name = ts.SyntaxKind[node.kind];
    kinds.set(name, (kinds.get(name) ?? 0) + 1);
    return ts.visitEachChild(node, visit, ctx);
  };

  const out = ts.visitEachChild(sourceFile, visit, ctx);

  // one line per file, stderr so it never contaminates emit
  process.stderr.write(
    JSON.stringify({
      file: sourceFile.fileName.replace(process.cwd(), ""),
      nodes: [...kinds.entries()].sort(),
    }) + "\n"
  );
  return out;
};
};
$ COMPILER_PATH=legacy node tools/with-compiler.sh 2> kinds.legacy.txt
$ sort kinds.legacy.txt | sha256sum
9f2c1a0e...  -

## diff against the beta run
$ COMPILER_PATH=beta node tools/with-compiler.sh 2> kinds.beta.txt
$ diff <(sort kinds.legacy.txt) <(sort kinds.beta.txt) && echo "visit signature identical"

Untested: the beta side of that diff. Confirmed: the recorder produces a deterministic signature under the legacy path, which is the precondition for the diff to mean anything.

The Usual Suspects: Inventorying Custom Transformers

Inventory before you test. In most Angular monorepos the list is:

  • Custom before transformers — often adding instrumentation, i18n key extraction, or environment injection.
  • Patch-style loaders — anything that monkey-patches ts.transpileModule or compiler-cli's performCompilation. These break first and complain least.
  • Angular compilation hooks — ng-packagr transformers config in ng-package.json.
  • AST walkers during emit — codemods, typed ESLint rules, ts-morph scripts, API extractor.

Each one needs a minimal reproduction in the fixture repo, not a hope. A one-file transformer in the fixture that does something trivial and observable is worth more than an integration test against your real, sprawling transformer — because when the real one fails you will not know which of its forty behaviors caused it.

💪

When a transformer fails under the beta, bisect by behavior, not by file. Copy the transformer into the fixture, then delete everything except one AST read. If that fails, the problem is the Program/TypeChecker handle, not your logic — and that is a bug report you can file with a five-line reproduction.

Decorator and DI Metadata: The Failure That Surfaces Downstream

This is the failure mode that hurts most, because it manifests furthest from the build. If constructor parameter metadata (design:paramtypes) or partial-Ivy output diverges, the library publishes, the consumer installs, and the consumer's app throws a DI error at runtime with no build-time signal anywhere.

Build the library under both compilers and compare partial-compilation output structurally:

tools/emit-diff.mjs
import { readFileSync, readdirSync } from "node:fs";


const strip = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();

export function normalizeJs(path) {
const sf = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true);
return strip(sf.getText());
}

export function diff(a, b) {
return a === b ? null : { legacy: a.slice(0, 240), beta: b.slice(0, 240) };
}

Normalizing away whitespace and comment-only nodes is deliberate: you want AST-equivalence, and formatting differences are noise that will get the gate disabled. Anything that survives normalization is a finding.

Inferred: partial-Ivy output is the highest-risk artifact in the whole migration, because it is the one that crosses an organizational boundary. A .d.ts diff breaks your build; a metadata diff breaks someone else's, and they will find out from a customer.

An Output-Equivalence Test Split Into Three Diffs

Run it as three separate diffs so a failure tells you where to look:

COMPILER_PATH=legacy node tools/with-compiler.sh
cp -r dist/demo dist/legacy && cp -r dist/widgets dist/legacy-lib

COMPILER_PATH=beta node tools/with-compiler.sh

diff -r dist/legacy dist/demo --exclude='*.map' --exclude='*.js'   # .d.ts only
diff -r dist/legacy-lib dist/widgets --exclude='*.map'             # partial-Ivy + JS
node tools/sourcemap-check.mjs                                     # maps

Separating .d.ts from JS from maps means a red gate names its own cause. A combined diff of everything is one line of output that nobody reads twice.

Where the Beta Bites Enterprise Workspaces

Monorepos, Incremental Builds, and Stale Cache Keys

Cache keys built against the old compiler binary will serve stale artifacts. Not "may" — any cache keyed on a compiler version range, a hash of a config file, or a task graph that assumes the same binary will keep producing yesterday's output after you flip the flag.

Verify invalidation explicitly, because a stale cache looks exactly like a fast build:

npm run build && shasum dist/demo/main.js
## 4b1d... dist/demo/main.js

perl -pi -e 's/Hello/Hello,/' apps/demo/src/app/app.html
npm run build && shasum dist/demo/main.js
## 4b1d... dist/demo/main.js   <-- unchanged hash = stale cache, FAIL

An unchanged hash after a template edit means the cache did not invalidate. That's a fail, and it deserves a CI job of its own, because the symptom — a fast build — reads as success.

Library Publishing: Test by Consuming the Tarball

.d.ts stability and partial-Ivy metadata are what downstream consumers compile against. Test by consuming: build an app in the fixture that imports the built library from its tarball, not from source.

cd libs/widgets && npm pack
cd ../demo-consumer && npm i ../libs/widgets/widgets-1.0.0.tgz && npx ng build

If that fails, you have broken other teams, not yourself. This is also the test that catches linker differences between partial-Ivy declared in the metadata and what the consumer's linker expects.

Downstream Program Consumers: ESLint, ts-morph, API Extractor

Anything that constructs or reads a TypeScript program is in scope. Make the list explicit:

ConsumerProgram accessRisk
Typed ESLint rulesreads ProgramRule crashes look like lint config errors
ts-morph codemodsbuilds its own ProgramUses whatever typescript resolves — may not match the compiler
API Extractorreads .d.tsFails on emit shape changes
IDE / language service pluginconstructs a programMixed old/new program state in the editor
ng-packagrwraps compiler-cliVersion pinning across the two must match

The ts-morph row is the sneaky one: it has its own typescript peer dependency. After the swap you can be running the beta compiler-cli against TypeScript 6.x inside your codemod, and the failure will look like a codemod bug.

CI Reproducibility: Platform Binaries and Base Images

A compiler that works on a laptop and not in CI is a beta-stage risk, not a surprise. Three concrete things:

  • Platform binaries. Native compilers ship per-platform packages (@scope/compiler-linux-x64-gnu, and so on). If your lockfile was generated on macOS, npm ci on Alpine will not fetch the musl build. Run npm ci in the CI image, not on your laptop, and commit that lockfile.
  • Container base image. node:22-slim (Debian) and node:22-alpine (musl) are different targets. Pick one and assert it in CI.
  • Pinned versions. No carets on the beta packages. A beta that resolves to a newer beta between your local run and the CI run is not a reproducible experiment.
docker run --rm -v "$PWD":/w -w /w node:22-slim \
  sh -c 'npm ci && npx ng build demo --configuration production'

If that command fails and your local build passes, you have found the only beta risk that matters to whoever is on call.

The Gating Checklist I'd Put in CI

Adoption should be a decision backed by evidence, not a vibe. Seven gates, each with a pass criterion and an owner:

GateCommandPass criterionOwner
Diagnostics snapshotnode tools/diagnostics.mjs --diff legacyZero added, zero removed, zero severity changesPlatform
Exit-code assertionfailing fixture must exit non-zeroexit != 0 for every error fixturePlatform
Source-map assertionnode tools/sourcemap-check.mjs100% of expectations resolve; no template→shim leaksFrontend infra
Emitted-AST diffnode tools/emit-diff.mjsZero non-whitespace, non-comment diffs in app emitPlatform
.d.ts + metadata diffdiff -r on lib emit, then tarball consume testByte-identical, or every diff explained in the PRLibrary owners
Build-time median5 cold + 5 warm runs, recorded conditionsDocumented; regression triggers a review, not a revertPlatform
Cache-invalidationtemplate edit → hash must changeHash changes on every content editBuild infra

Two rules for the gates themselves. Each gate must be proven to fail before it is trusted — the --break self-test above is not optional ceremony. And each gate needs a named owner, because a gate with no owner gets muted during the first busy week and never comes back.

My Position: Adopt Behind a Flag, Gate on Output Equivalence

The build-time wins are the headline, and they are real. A native pass over parse and resolution is a plausible order-of-magnitude improvement on large workspaces. But build time is not the migration currency. Output equivalence is. A compiler that is 3x faster and produces a .d.ts your consumers cannot link against has a net value of zero, and you will discover it in someone else's repository.

So: opt in behind a flag, keep the legacy path invocable in the same repo, and run both in CI until every gate in the table above is green for a full release cycle. Do not delete the legacy path when the beta flag turns on — delete it when you have a month of green diffs and a rollback you have actually rehearsed.

And do not repeat the Rust detail until you have found it in the release notes. Confirmed: Angular's compiler beta is reported to ship a native preprocessor, described as Rust-based, on a TypeScript 7 foundation. Confirmed: TypeScript's publicly documented native port is Go. Untested: everything about the beta's actual behavior in this post — which is why the harness is the deliverable and the benchmark is not.

Further Reading

Share this post

More posts

Loading comments…