TypeScript 7.1 Beta Restores the Compiler API: What Transformer and Lint-Rule Authors Get Back

TypeScript 7.1 Beta Restores the Compiler API: What Transformer and Lint-Rule Authors Get Back

pr0h0β€’β€’20 min readβ€’coding
AI Usage (80%)
On this page

Introduction

The headline going around is short: TypeScript 7.1 Beta restores the Compiler API and makes Vue and Svelte first-class. Two claims stapled together, and they need very different kinds of evidence. One is API surface you can verify in about forty lines of code; the other is an ecosystem claim that depends on virtual-file plumbing, editor parity, and whether framework toolchains can actually consume the new compiler. In this post I'll show you what the compiler API surface is made of, how to build a minimal transformer and a typed lint rule against the beta, and the checks that tell you in an afternoon whether "restored" is real for your stack.

I'll be upfront about the limits of this post, because my source material is a single InfoQ report dated 2026-11-06 with a headline and a one-paragraph summary. No API diff, no quoted release notes, no migration guide. So I'm not going to write "here is what changed in ts.factory" as if I had the diff in front of me. What I can do is lay out the mechanics you need to understand, the code you need to write, and the checks that will tell you the truth in an afternoon.

πŸ“

I have not run 7.1.0-beta. Anything in this post that counts as fact is attributed to a primary source or to the stable compiler line I can actually test. Anything that is a prediction is labeled as one.

What the beta actually claims, and what this post verifies

From the report, confirmed: a beta of TypeScript 7.1 exists as of publication, it is described as restoring the Compiler API, and Vue and Svelte are described as first-class framework targets.

What I have not confirmed, and do not assert anywhere below: the exact shape of the restored API, which symbols came back, whether anything was deprecated or renamed, what version range the beta covers, and what "first-class" concretely means for .vue and .svelte files.

Why a compiler API regression matters more than a feature regression

A feature regression is loud. You upgrade, tsc rejects syntax you relied on, CI goes red, you read the error. A compiler API regression is quiet β€” and quiet is worse.

If ts.factory.createCallExpression changes arity, your custom transformer does not throw. It produces a call node with the wrong argument in the wrong slot, and emit succeeds. If ESLintUtils.getParserServices(context).program starts returning undefined in a config it used to populate, your typed rules do not crash β€” they take an early return and report nothing. A lint rule that reports nothing looks exactly like a codebase with no violations.

That asymmetry is the whole argument for treating the restored API as the headline, not the framework support.

The Missing-API Backstory: Why Anyone Cares About "Restored"

What the native compiler rewrite changed about the public surface

TypeScript 7 is the version line Microsoft assigned to its native compiler port β€” the Go implementation announced in March 2025. The announcement framed it as an incremental migration and was candid that not everything would land at once. The part most tooling authors flagged as the open question was the JavaScript API surface, because that surface is what every third-party transform, lint rule, codemod, and language-service host compiles against.

Here is the precise point: the native compiler changes who implements the algorithm, not what the API contract is supposed to be. If the port had shipped as a CLI-only tool forever, the ecosystem depending on ts.createProgram and ts.visitEachChild would have been stranded on the last JavaScript-lineage release. "Restored" is therefore a claim about contract continuity across an implementation rewrite β€” the hardest kind of compatibility to hold.

The difference between the type checker, the emitter, and the transform pipeline

People say "compiler API" to mean four different products. They fail in different ways, so separate them:

LayerWhat you consumeWho breaks first
Type checkerProgram, TypeChecker, Symbol, TypeLint rules, IDE features, API doc generators
Transform pipelineTransformerFactory, Visitor, NodeFactoryCustom transforms, macros, instrumentation
Emitterprogram.emit, EmitResult, declaration emitBuild output, .d.ts consumers
Language serviceLanguageServiceHost, LanguageServiceEditors, framework tooling (Volar, svelte2tsx)

A restored API could mean any subset of those four rows. That is exactly what your verification has to pin down.

Reading the Announcement Without Over-Reading It

Confirmed claims from the report versus what I still need to reproduce

The report says the Compiler API is restored. My reading: the JS-facing entry points required to build a Program, walk the tree, resolve symbols, and emit with custom transformers are back. That reading is an inference from the word "restored" plus the fact that InfoQ bothered to report it as a tooling story β€” not from a quoted API list.

What I still need to reproduce, in order:

  1. ts.createProgram exists and returns a Program whose getTypeChecker() works.
  2. ts.factory is populated with constructors that accept the same arguments as the previous line.
  3. ts.visitEachChild / ts.visitNode behave, including the undefined return path.
  4. program.emit honors customTransformers.before.
  5. @typescript-eslint/parser can build a program on top of it.

Facts I am deliberately not asserting

I won't tell you the beta is 7.1.0-beta β€” I don't know the published version string. I haven't seen the diff, so I can't tell you which symbols were removed and re-added. ts-patch and ttypescript compatibility is off the table here too: both reach into typescript/lib/ internals via deep imports, which is exactly the region an implementation rewrite disturbs. And I'm not claiming Vue or Svelte toolchains work unmodified, because their peerDependencies ranges will be the first thing to disagree with you.

⚠️

Do not upgrade typescript in a repo where vue-tsc, svelte-check, or a transformer package pins a peer range and assume npm/yarn/pnpm will warn you. Overrides and resolutions bypass those warnings silently.

What the Compiler API Surface Is Actually Made Of

Program, SourceFile, and TypeChecker as the stable core

Program is the unit of work: a set of SourceFiles plus compiler options plus a TypeChecker. Almost everything else is derived. If the beta gives you these three and nothing else, typed tooling survives and untyped transforms mostly survive.

The parts that matter for stability: program.getSourceFile(fileName), program.getSourceFiles(), program.getTypeChecker(), sourceFile.statements, and checker.getSymbolAtLocation(node).

The factory and visitor pattern used by custom transformers

Transformers do not edit the AST. They return a new tree. The NodeFactory (ts.factory, or the per-transform context.factory) constructs nodes, and ts.visitEachChild recurses while giving you a hook per node.

Historical churn worth knowing: TypeScript 4.8 introduced ts.factory and deprecated the free-standing createX functions. Any guide still importing ts.createCallExpression from the module root is stale, and any transform you inherit that uses them may already be emitting deprecation warnings or worse.

The correctness rule people get wrong: use context.factory, not the module-level ts.factory, inside a transform. The context factory carries the current node-construction state for that transform pass.

Language service host and the editor-facing half of the API

This is the half that decides whether the Vue/Svelte claim is real. A LanguageServiceHost is an interface you implement to tell TypeScript how to find files, what their contents are, what the project's compiler options are, and what version each file is at. Framework tooling implements this host so the editor can ask type questions about code that does not exist on disk β€” the generated TS behind a .vue or .svelte file.

If the restored API includes the language-service host, framework tooling can be rebuilt on 7.x. If it only includes Program and the emitter, CLI type-checking works and the editor story stalls. The report does not say which, so verify it.

Rebuilding a Minimal Transformer Against the Beta

Scaffolding a transform that never mutates the original AST

Here is the transform I use as a canary. It strips console.* calls that are not warn/error, which exercises the factory, the visitor, expression replacement, and emit β€” and is trivially observable in output.

// transform/strip-console.ts


const KEEP = new Set(["warn", "error"]);

export default function stripConsole(
  context: ts.TransformationContext
): ts.Transformer<ts.SourceFile> {
  const f = context.factory; // not the module-level ts.factory

  const visit: ts.Visitor = (node) => {
    if (
      ts.isCallExpression(node) &&
      ts.isPropertyAccessExpression(node.expression) &&
      ts.isIdentifier(node.expression.expression) &&
      node.expression.expression.text === "console" &&
      !KEEP.has(node.expression.name.text)
    ) {
      // void 0 keeps the expression in a valid position
      return f.createVoidZero();
    }
    return ts.visitEachChild(node, visit, context);
  };

  return (sourceFile) => ts.visitNode(sourceFile, visit) ?? sourceFile;
}

Two edge cases that will bite you and are not obvious from the API:

  • The replacement node has no text range, so a leading comment attached to the original statement may not survive emit. If comments matter, set the range explicitly with ts.setTextRange(newNode, node) on the enclosing statement.
  • ts.visitNode can return undefined. The ?? sourceFile fallback is not defensive noise; without it you can push undefined into the emit pipeline.

Wiring the transformer through the emit path and asserting output

// scripts/emit.ts



const options: ts.CompilerOptions = {
  target: ts.ScriptTarget.ES2022,
  module: ts.ModuleKind.ESNext,
  outDir: "dist",
  declaration: true,
  sourceMap: true,
};

const program = ts.createProgram(["src/main.ts"], options);
const preEmit = ts.getPreEmitDiagnostics(program);

const result = program.emit(
  undefined,
  undefined,
  undefined,
  /* emitOnlyDtsFiles */ false,
  { before: [stripConsole], after: [] }
);

console.log(
  JSON.stringify({
    preEmitDiagnostics: preEmit.length,
    emitDiagnostics: result.diagnostics.length,
    emitSkipped: result.emitSkipped,
  })
);

Note that before: [stripConsole] passes the factory, not a constructed transformer. Passing stripConsole(context) here is the single most common wiring mistake.

And the statefulness trap: program.emit is per-file and can be called more than once (JS emit, then declaration emit). A transformer that increments a module-level counter to "count rewrites" will report roughly double. Keep transform state inside the closure the factory returns.

A snapshot test harness that fails loudly when the API shape shifts

Two failure modes need separate tests. A missing API should fail at startup. A silently-ignored transformer should fail at assertion.

scripts/check-transform.mjs
import assert from "node:assert/strict";




// 1. Surface contract: fail loudly if a symbol we depend on moves.
const SURFACE = [
["createProgram", ts.createProgram],
["factory.createCallExpression", ts.factory.createCallExpression],
["factory.createVoidZero", ts.factory.createVoidZero],
["visitEachChild", ts.visitEachChild],
["visitNode", ts.visitNode],
["getPreEmitDiagnostics", ts.getPreEmitDiagnostics],
];

for (const [name, value] of SURFACE) {
assert.equal(typeof value, "function", `compiler API surface changed: ${name}`);
}

// 2. Behavioral contract: prove the transformer actually ran.
const program = ts.createProgram(["src/main.ts"], {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
outDir: "dist",
});

program.emit(undefined, undefined, undefined, false, {
before: [stripConsole],
after: [],
});

const emitted = readFileSync("dist/main.js", "utf8");
assert.ok(!emitted.includes("console.log("), "transform did not strip console.log");
assert.ok(emitted.includes("console.error("), "transform over-matched");
assert.equal(emitted, readFileSync("test/fixtures/expected/main.js", "utf8"));

console.log("surface ok; emit ok; snapshot ok");

That is the harness. On the stable compiler line it prints a single line confirming all three contracts. Against the beta, the same three assertions tell you in under a second whether "restored" means what you need.

Typed Lint Rules: The Other Consumer of the Same API

How typed rules depend on the parser's program, not just its syntax tree

A syntax-only ESLint rule sees an ESTree. A typed rule sees a Program through parserServices. The parser builds the program from your tsconfig.json (parserOptions.project, or parserOptions.projectService in newer setups) and exposes the maps that let you translate between the two trees.

This is where a compiler API rewrite hits hardest, because @typescript-eslint/parser is itself a consumer of ts.createProgram and the type checker. If the parser cannot build a program, every typed rule in your codebase degrades to a no-op β€” again, silently.

Writing a rule that resolves a symbol instead of pattern-matching text

A rule that genuinely needs the checker: flag imports that resolve outside a package boundary through a re-export barrel. Text matching cannot see through barrels; symbol resolution can.

// rules/no-internal-reexport.ts



export const noInternalReexport = ESLintUtils.RuleCreator(
  (name) => `https://example.internal/rules/${name}`
)({
  name: "no-internal-reexport",
  meta: {
    type: "problem",
    docs: { description: "Disallow imports that resolve into another package's internals." },
    schema: [],
    messages: {
      outside: "'{{name}}' actually resolves to {{file}}.",
    },
  },
  defaultOptions: [],
  create(context) {
    const services = ESLintUtils.getParserServices(context);
    const checker = services.program.getTypeChecker(); // may be undefined-see below

    return {
      ImportDeclaration(node) {
        for (const spec of node.specifiers) {
          const tsNode = services.esTreeNodeToTSNodeMap.get(spec);
          if (!tsNode || !ts.isImportSpecifier(tsNode)) continue;

          const raw = checker.getSymbolAtLocation(tsNode.name);
          if (!raw) continue; // unresolved module: bail, do not guess

          const sym =
            raw.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(raw) : raw;
          const decl = sym.declarations?.[0];
          if (!decl) continue;

          const file = decl.getSourceFile().fileName;
          if (!file.includes("/internal/")) continue;

          context.report({
            node: spec,
            messageId: "outside",
            data: { name: spec.local.name, file },
          });
        }
      },
    };
  },
});

The getAliasedSymbol call is the entire point. Without it you see import { thing } from "@acme/ui" and stop. With it you see that thing is declared in packages/ui/src/internal/flags.ts.

What breaks first when the underlying API moves

Ranked by what I have seen go wrong, and what I would watch for in the beta:

  1. services.program stops being populated. With some parser configurations you must use services.program carefully, and it can be absent when no type information is requested. A rule that assumes it exists throws or silently returns. Guard it, and make the guard loud in dev.
  2. esTreeNodeToTSNodeMap / tsNodeToESTreeNodeMap return undefined for nodes the parser did not map. New syntax nodes are the usual cause.
  3. Factory arity changes. The 4.x→4.8 factory migration is the precedent; a native rewrite that re-derives node construction could do something similar.
  4. SymbolFlags / TypeFlags value changes. These are bitmasks. A shifted bit makes raw.flags & ts.SymbolFlags.Alias compile fine and evaluate wrong.

Item 4 is the nastiest, because TypeScript cannot catch it for you.

Vue as a First-Class Target in TypeScript 7.1

What "first class" plausibly means for SFC blocks and virtual files

The honest answer: I do not know, and the report does not say. Two readings are plausible. Weak reading β€” TypeScript 7.1's CLI and the Vue language tools work together with no new constraints, i.e. vue-tsc keeps working. Strong reading β€” the compiler itself understands single-file components, so the toolchain no longer needs to synthesize shadow files.

The strong reading would be a large change to the compiler's file model. I would not plan around it until someone shows you a .vue file passed to tsc directly.

The virtual-file indirection that makes SFC tooling hard

A .vue file is three or four languages in a trench coat. <script setup> is TS, <template> is HTML-ish with expressions, <style> is CSS. Today, vue-tsc (from vuejs/language-tools) asks the Volar core to synthesize TypeScript for each block β€” the generated files are named with __VLS_ prefixes β€” and feeds those to the compiler through a custom LanguageServiceHost. Positions are mapped back to the original file for diagnostics.

That indirection is why template type errors are possible at all, and why they are fragile. Your editor and your CI use the same mechanism but not necessarily the same version of it.

Checks to run: template type errors, slot typing, and editor parity

// tsconfig.json (excerpt)
{
  "compilerOptions": {
    "strict": true,
    "noEmit": true
  },
  "vueCompilerOptions": {
    "strictTemplates": true
  }
}
npx vue-tsc --noEmit
## deliberate error in a template expression, e.g. {{ props.count.toFixed() }} on a string
echo "exit=$?"

Record three things: whether strictTemplates still produces template diagnostics (not just script diagnostics), whether a slot prop typed as string | undefined is flagged at the call site, and whether the same error shows up in your editor. A clean CLI with a dirty editor is a version-skew symptom, not a success.

Svelte as a First-Class Target in TypeScript 7.1

The preprocessor boundary and why generated TS is not real TS

Svelte has a harder problem than Vue. svelte2tsx transforms a component into TSX-like output β€” not TSX that tsc would accept unsupervised, and not code anyone writes. Reactivity is a compiler concept: in Svelte 4, $: blocks and $store subscriptions; in Svelte 5, runes like $state, $derived, and $props(). The generated types have to encode all of that.

So the statement "Svelte is first-class" most plausibly means: the language tooling can be ported to the new compiler without losing fidelity. It almost certainly does not mean tsc now parses .svelte files natively.

Narrowing the gap between CLI type-check and editor diagnostics

svelte-check and the Svelte language server share svelte2tsx. When they disagree, the cause is nearly always a version mismatch between the tooling packages, or a tsconfig.json the CLI is reading and the editor is not.

Checks to run: reactive declarations, stores, and component prop inference

npx svelte-check --tsconfig ./tsconfig.json --output human

Deliberately break three things and confirm all three are caught:

  1. Reactive declaration. $: doubled = count * "2"; should error. If it passes, the reactive scope is being widened to any.
  2. Store autosubscription. let n: number = countStore; with countStore: Writable<number> should error under strict.
  3. Prop inference. Pass a prop of the wrong type from a parent and confirm the error lands on the parent, not the child.

If (3) reports at the child component, prop inference has regressed even though the build still fails. That distinction matters: the failure is in the wrong place, which usually means the checker lost the call site.

Build Pipeline and Migration Planning

A staged adoption ladder from read-only CI checks to full emit

Don't jump from "the API is back" to "our build emits with it." Climb:

StageWhat runsBlast radius
0Beta tsc --noEmit in a non-blocking CI jobnone
1Beta as the lint parser in a separate joblint only
2Transforms run in dry-run; emitted output diffed, not shippednone
3Dual emit: stable ships, beta emits to dist-beta/artifacts only
4Beta ships behind a flag on one packageone package
5Beta defaulteverything

Stage 2 is the one people skip, and the one that catches transforms quietly breaking.

Pinning, lockfiles, and the cost of an API that is still in beta

npm i -D -E typescript@beta
npm ls typescript

@beta is a real dist-tag on the typescript package, but it moves. Install with -E so the lockfile pins an exact version, and commit the lockfile. If a framework toolchain insists on a different TypeScript, you will be reaching for:

{
  "pnpm": {
    "overrides": { "typescript": "<exact beta version>" }
  }
}

(overrides for npm, resolutions for Yarn.) Every one of those overrides is a promise you are personally keeping on behalf of a package author who has not tested against TypeScript 7. Track them in an issue with an owner.

Where framework toolchains own the version and you do not

vue-tsc and svelte-check declare peer ranges on typescript. Forcing the beta through an override means the framework tooling is running outside its supported matrix. That is sometimes correct and always your problem, not theirs. File the tracking issue before you flip the override, not after.

Risk Assessment Before You Commit

Silent emit changes are worse than loud failures

The specific scenario: a transformer stops being invoked because the custom-transform hook changed, program.emit still succeeds, result.diagnostics is empty, and your production bundle ships with console.log calls and without your instrumentation. Nothing in the default output tells you.

Defenses, all cheap:

  • Assert on emitted content, not exit code: assert.ok(!emitted.includes("console.log(")).
  • Assert the transformer ran at all, with a per-file counter read in the harness.
  • Diff emitted output against a committed fixture β€” not just against "it compiled."

Supply-chain and maintenance burden for transformer packages

Custom-transform packages are typically one maintainer deep, and many reach into typescript/lib/typescript.js via deep imports. Deep imports are exactly what an implementation rewrite is least likely to preserve. Before you upgrade, run:

grep -rn "typescript/lib" node_modules/*/package.json node_modules/*/dist 2>/dev/null | head

If your transform stack depends on internals rather than the documented factory/visitor surface, budget for a rewrite, not a version bump.

When to stay on the previous toolchain and what that costs you

Stay if: you only want faster type-checking (the CLI path is what improved; the API restoration buys you nothing there), your transform stack uses deep imports, or your framework tooling has not published a supported range.

The cost of staying is bounded β€” you lose the ability to build new tooling on 7.x, and you accumulate a fork point. The cost of moving early is unbounded, because silent emit changes reach production.

A Concrete TypeScript 7.1 Beta Verification Checklist

Commands to run against a scratch project before trusting the beta

## 1. Baseline
mkdir -p scratch && cd scratch
npm init -y
npm i -D typescript@beta
npx tsc --version

## 2. Surface probe
node -e '
const ts = require("typescript");
for (const k of ["createProgram","visitEachChild","visitNode","getPreEmitDiagnostics"]) {
  console.log(k, typeof ts[k]);
}
console.log("factory.createVoidZero", typeof ts.factory?.createVoidZero);
'

## 3. Behavior probe (the transformer harness above)
node scripts/check-transform.mjs

## 4. Compiler self-report
npx tsc -p tsconfig.json --extendedDiagnostics > diag-beta.txt
npx tsc -p tsconfig.json --generateTrace ./trace >/dev/null

What output to capture so a regression is provable later

Capture all of the following and commit them next to the upgrade PR:

  • npx tsc --version output (the exact version string).
  • npx tsc --showConfig output, so compiler options are on record.
  • tsc --extendedDiagnostics for both the old and new toolchain: Files, Types, Memory used, Check time.
  • The emitted diff: git diff --no-index --stat dist-old dist-new.
  • One snapshot fixture whose mismatch message names the API symbol that changed β€” your future self will not remember which assertion was which.
SignalGoodInvestigate
Transformer ranrewrite counter > 0counter 0 with clean exit
Emit diagnostics[]non-empty but non-fatal
Emitted diffonly intended rewriteswhitespace/comment churn
Typed lint rulereports on seeded violationzero reports on seeded violation
Framework CLIerrors in templates/propsclean CLI, dirty editor

My Position on Adopting the TypeScript 7.1 Beta

Where the restored API genuinely unblocks work

Restoring the Compiler API unblocks the whole class of tooling that cannot exist on a CLI alone: typed lint rules that need a real Program, codegen and macros that need a transform pipeline, codemods that need symbolic resolution, API-doc generators that need the checker, and language-service hosts that need to answer editor questions. If your team maintains any of those, a CLI-only future was a dead end, and the beta reopens it. That matters even with every caveat above.

Where first-class framework support is a promise, not a guarantee

"First-class Vue and Svelte" is doing a lot of work in one adjective, and the parser isn't the hard part. The hard parts are virtual files, position mapping back to .vue/.svelte source, editor/CLI parity, and peer-dependency ranges controlled by other teams. The compiler team can only deliver the first half; the framework language-tool teams deliver the rest on their own schedule.

My position: adopt the beta now for read-only and non-emitting work β€” a non-blocking type-check job, a second lint pass, dry-run transforms with output diffing. Do not let it emit your production artifacts, and do not force it through overrides into a framework toolchain until that toolchain's own release notes say it is supported. The API being back is a real win for tooling authors; treating "restored" as "validated for your stack" is how you end up debugging an emitted bundle instead of a failing test.

Further Reading

Share this post

More posts

Loading comments…

Β