Testing TypeScript Compiler Components: From Unit to E2E Tests
The TypeScript compiler (tsc) is a complex piece of software composed of many interacting components: the scanner, parser, binder, checker, and emitter. Each of these components has distinct responsibilities, and a bug in any one of them can cascade into incorrect type checking, broken emit, or misleading diagnostics. Testing these components properly requires a layered strategy that ranges from isolated unit tests to full end-to-end (E2E) tests that compile real projects. This tutorial walks through how to test TypeScript compiler components effectively, with practical examples you can adapt to your own compiler plugin, custom transformer, or fork of the TypeScript compiler itself.
Why Testing Compiler Components Matters
Compilers are deterministic systems where correctness is paramount. A subtle off-by-one error in the scanner can misclassify tokens, a missed symbol binding can produce false type errors, and an emitter bug can silently produce invalid JavaScript. Because the compiler is the foundation of every TypeScript developer's workflow, regressions are expensive. A robust test suite lets you refactor aggressively, add language features confidently, and ship patches without fear of breaking downstream users.
Testing also serves as documentation. A unit test for the parser demonstrates exactly how a given syntax is expected to be represented in the AST. An E2E test shows what emitted output a particular source file should produce. New contributors can read these tests to understand expected behavior faster than by reading prose documentation.
Understanding the TypeScript Compiler Architecture
Before writing tests, you need to understand the pipeline. The TypeScript compiler processes source code through several stages:
- Scanner — converts source text into a stream of tokens.
- Parser — converts tokens into an Abstract Syntax Tree (AST).
- Binder — walks the AST and creates symbols, building the symbol table.
- Checker — performs type checking using the symbol table and AST.
- Emitter — generates JavaScript output and declaration files.
- Transformer — applies transformations to the AST during emit.
Each stage has a clear input and output contract, which makes them natural boundaries for testing. The TypeScript team exposes these components through the typescript package, so you can import and exercise them directly.
Setting Up the Test Environment
Start by creating a project with the TypeScript compiler as a dependency and a test runner. Jest works well, but Vitest or Node's built-in test runner are equally suitable. Install the dependencies:
npm init -y
npm install typescript jest ts-jest @types/jest --save-dev
npx ts-jest config:init
Your tsconfig.json should target a modern Node version and include the test files:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"types": ["jest", "node"]
},
"include": ["src/**/*", "tests/**/*"]
}
With the environment ready, you can begin writing tests at each layer of the testing pyramid.
Unit Testing Individual Compiler Components
Unit tests focus on a single component in isolation. For compiler components, this usually means feeding a known input into one stage and asserting on its output. Let's look at examples for the scanner, parser, binder, and checker.
Testing the Scanner
The scanner converts source text into tokens. You can create a scanner using ts.createScanner and step through tokens manually. This is useful when you are building custom syntax or debugging tokenization issues.
import * as ts from "typescript";
describe("Scanner", () => {
it("should tokenize a simple variable declaration", () => {
const sourceText = "const x = 42;";
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
true,
ts.LanguageVariant.Standard,
sourceText
);
const tokens: { kind: string; text: string }[] = [];
while (scanner.scan() !== ts.SyntaxKind.EndOfFileToken) {
tokens.push({
kind: ts.SyntaxKind[scanner.getToken()],
text: scanner.getTokenText(),
});
}
expect(tokens).toEqual([
{ kind: "ConstKeyword", text: "const" },
{ kind: "Identifier", text: "x" },
{ kind: "EqualsToken", text: "=" },
{ kind: "NumericLiteral", text: "42" },
{ kind: "SemicolonToken", text: ";" },
]);
});
it("should handle template literal tokens", () => {
const sourceText = "`Hello ${name}!`";
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
true,
ts.LanguageVariant.Standard,
sourceText
);
const kinds: string[] = [];
while (scanner.scan() !== ts.SyntaxKind.EndOfFileToken) {
kinds.push(ts.SyntaxKind[scanner.getToken()]);
}
expect(kinds).toContain("TemplateHead");
expect(kinds).toContain("TemplateMiddle");
expect(kinds).toContain("TemplateTail");
});
});
Notice that we assert on the kind and text of each token. This keeps the test resilient to internal scanner refactors as long as the observable token stream remains correct.
Testing the Parser
The parser produces an AST from tokens. To test it, parse a source string and traverse the resulting tree. A common technique is to use ts.forEachChild to walk the tree and collect node kinds, or to assert on specific node properties.
import * as ts from "typescript";
function parseSource(sourceText: string): ts.SourceFile {
return ts.createSourceFile(
"test.ts",
sourceText,
ts.ScriptTarget.Latest,
true
);
}
describe("Parser", () => {
it("should parse a function declaration correctly", () => {
const sourceFile = parseSource("function add(a: number, b: number): number { return a + b; }");
const statements = sourceFile.statements;
expect(statements).toHaveLength(1);
const funcDecl = statements[0] as ts.FunctionDeclaration;
expect(funcDecl.kind).toBe(ts.SyntaxKind.FunctionDeclaration);
expect(funcDecl.name?.text).toBe("add");
expect(funcDecl.parameters).toHaveLength(2);
const firstParam = funcDecl.parameters[0];
expect(firstParam.name.getText(sourceFile)).toBe("a");
expect(firstParam.type?.kind).toBe(ts.SyntaxKind.NumberKeyword);
});
it("should parse an interface with optional members", () => {
const sourceFile = parseSource("interface User { id: number; name?: string; }");
const iface = sourceFile.statements[0] as ts.InterfaceDeclaration;
expect(iface.kind).toBe(ts.SyntaxKind.InterfaceDeclaration);
expect(iface.members).toHaveLength(2);
const nameMember = iface.members[1] as ts.PropertySignature;
expect(nameMember.questionToken).toBeDefined();
expect(nameMember.name.getText(sourceFile)).toBe("name");
});
it("should attach correct trivia positions", () => {
const sourceFile = parseSource("// leading comment\nconst x = 1;");
const varDecl = sourceFile.statements[0] as ts.VariableStatement;
const leadingComments = ts.getLeadingCommentRanges(
sourceFile.text,
varDecl.pos
);
expect(leadingComments).toBeDefined();
expect(leadingComments!.length).toBeGreaterThan(0);
expect(leadingComments![0].text).toBe(" leading comment");
});
});
These tests verify the structural shape of the AST. When testing the parser, prefer asserting on observable properties like node kind, identifier text, and modifier flags rather than internal node IDs or positions, which may change between TypeScript versions.
Testing the Binder and Symbol Table
The binder creates symbols and associates them with declarations. To test it, you need a full Program because the binder runs as part of program creation. You can then inspect the symbols attached to nodes.
import * as ts from "typescript";
function createProgram(sourceText: string): ts.Program {
const sourceFile = ts.createSourceFile(
"test.ts",
sourceText,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.TS
);
const compilerHost: ts.CompilerHost = {
getSourceFile: (fileName) => (fileName === "test.ts" ? sourceFile : undefined),
getDefaultLibFileName: () => "lib.d.ts",
writeFile: () => {},
getCurrentDirectory: () => "",
getCanonicalFileName: (f) => f,
useCaseSensitiveFileNames: () => true,
getNewLine: () => "\n",
fileExists: (f) => f === "test.ts",
readFile: () => "",
};
return ts.createProgram(
["test.ts"],
{ noLib: true, noResolve: true },
compilerHost
);
}
describe("Binder", () => {
it("should create symbols for declared variables", () => {
const program = createProgram("const greeting = 'hello';");
const sourceFile = program.getSourceFile("test.ts")!;
const checker = program.getTypeChecker();
const varStatement = sourceFile.statements[0] as ts.VariableStatement;
const declaration = varStatement.declarationList.declarations[0];
const symbol = checker.getSymbolAtLocation(declaration.name);
expect(symbol).toBeDefined();
expect(symbol!.name).toBe("greeting");
});
it("should bind duplicate declarations as errors", () => {
const program = createProgram("let x = 1; let x = 2;");
const diagnostics = ts.getPreEmitDiagnostics(program);
const hasDuplicateError = diagnostics.some(
(d) => d.code === 2451 || d.code === 2300
);
expect(hasDuplicateError).toBe(true);
});
});
Testing the Type Checker
The checker is the most complex component. Unit tests for the checker typically involve compiling a small snippet and asserting on the inferred types or the diagnostics produced. This is where most compiler bugs surface.
import * as ts from "typescript";
function getTypeOfExpression(
sourceText: string,
expressionText: string
): string {
const program = createProgram(sourceText);
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile("test.ts")!;
let typeString = "";
function visit(node: ts.Node) {
if (ts.isExpression(node) && node.getText(sourceFile) === expressionText) {
const type = checker.getTypeAtLocation(node);
typeString = checker.typeToString(type);
return;
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return typeString;
}
describe("Type Checker", () => {
it("should infer string type for a string literal", () => {
const type = getTypeOfExpression("const x = 'hello';", "'hello'");
expect(type).toBe('"hello"');
});
it("should infer union types from conditional expressions", () => {
const type = getTypeOfExpression(
"const x = Math.random() > 0.5 ? 'a' : 'b';",
"Math.random() > 0.5 ? 'a' : 'b'"
);
expect(type).toBe('"a" | "b"');
});
it("should report type errors for incompatible assignments", () => {
const program = createProgram("const x: number = 'string';");
const diagnostics = ts.getPreEmitDiagnostics(program);
const error = diagnostics.find(
(d) => d.code === 2322 && ts.isDiagnosticWithLinePosition(d)
);
expect(error).toBeDefined();
expect(error!.messageText).toContain("Type 'string' is not assignable to type 'number'");
});
});
Integration Testing Compiler Stages
Integration tests verify that multiple components work together correctly. For example, you might test that the parser, binder, and checker collectively produce the right diagnostics for a file that uses generics and type narrowing. These tests are coarser than unit tests but catch interaction bugs that unit tests miss.
import * as ts from "typescript";
function compileAndGetDiagnostics(sourceText: string): ts.Diagnostic[] {
const program = createProgram(sourceText);
return ts.getPreEmitDiagnostics(program);
}
describe("Integration: generics and type narrowing", () => {
it("should narrow a discriminated union correctly", () => {
const source = `
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number };
function area(shape: Shape): number {
if (shape.kind === 'circle') {
return Math.PI * shape.radius ** 2;
}
return shape.size ** 2;
}
`;
const diagnostics = compileAndGetDiagnostics(source);
expect(diagnostics).toHaveLength(0);
});
it("should enforce generic constraints", () => {
const source = `
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
const len = getLength(42);
`;
const diagnostics = compileAndGetDiagnostics(source);
const constraintError = diagnostics.find((d) => d.code === 2345);
expect(constraintError).toBeDefined();
});
it("should correctly resolve mapped types", () => {
const source = `
type Readonly<T> = { readonly [P in keyof T]: T[P] };
interface Point { x: number; y: number; }
const p: Readonly<Point> = { x: 1, y: 2 };
p.x = 5;
`;
const diagnostics = compileAndGetDiagnostics(source);
const readonlyError = diagnostics.find((d) => d.code === 2540);
expect(readonlyError).toBeDefined();
});
});
Integration tests like these are the sweet spot for compiler testing. They exercise real-world patterns and catch bugs that arise from component interactions without the overhead of a full E2E setup.
Testing the Emitter
The emitter transforms the AST into JavaScript. Testing it involves compiling a source file and asserting on the emitted output. This is critical when you are writing custom transformers or modifying emit behavior.
import * as ts from "typescript";
function emit(sourceText: string): string {
const program = createProgram(sourceText);
const sourceFile = program.getSourceFile("test.ts")!;
let output = "";
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
output = printer.printFile(sourceFile);
return output;
}
describe("Emitter", () => {
it("should strip type annotations during emit", () => {
const result = emit("const x: number = 42;");
expect(result).toContain("const x = 42;");
expect(result).not.toContain(": number");
});
it("should downlevel async functions when targeting ES5", () => {
const sourceFile = ts.createSourceFile(
"test.ts",
"async function fetch() { return 1; }",
ts.ScriptTarget.ES5,
false
);
const result = ts.transpileModule(sourceFile.text, {
compilerOptions: { target: ts.ScriptTarget.ES5, module: ts.ModuleKind.CommonJS },
});
expect(result.outputText).toContain("__awaiter");
});
it("should emit decorators metadata when configured", () => {
const result = ts.transpileModule(
`
function Log(target: any) {}
@Log
class MyClass {}
`,
{
compilerOptions: {
target: ts.ScriptTarget.ES5,
experimentalDecorators: true,
emitDecoratorMetadata: true,
},
}
);
expect(result.outputText).toContain("design:type");
});
});
End-to-End Testing with the Compiler API
E2E tests compile complete projects and assert on the overall result: emitted files, diagnostics, and program-level behavior. These tests are slower but provide the highest confidence that the compiler works correctly in realistic scenarios. The TypeScript compiler itself uses a test runner called ts-runner with baselines stored in tests/cases, but you can build a lightweight version for your own projects.
Compiling a Multi-File Project
import * as ts from "typescript";
import * as path from "path";
import * as fs from "fs";
describe("E2E: multi-file project compilation", () => {
const projectRoot = path.join(__dirname, "fixtures", "sample-project");
it("should compile without errors and produce expected output", () => {
const configPath = ts.findConfigFile(
projectRoot,
ts.sys.fileExists,
"tsconfig.json"
);
expect(configPath).toBeDefined();
const configFile = ts.readConfigFile(configPath!, ts.sys.readFile);
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
projectRoot
);
const program = ts.createProgram({
rootNames: parsedConfig.fileNames,
options: parsedConfig.options,
});
const diagnostics = ts.getPreEmitDiagnostics(program);
expect(diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error))
.toHaveLength(0);
let emitResult: ts.EmitResult;
const emittedFiles: Record<string, string> = {};
const host = ts.createCompilerHost(parsedConfig.options);
const originalWriteFile = host.writeFile;
host.writeFile = (fileName, content) => {
emittedFiles[fileName] = content;
};
emitResult = program.emit(undefined, undefined, undefined, false, undefined);
expect(emitResult.emitSkipped).toBe(false);
const mainJs = Object.entries(emittedFiles).find(([name]) =>
name.endsWith("index.js")
);
expect(mainJs).toBeDefined();
expect(mainJs![1]).toContain("console.log");
});
});
Testing Diagnostic Output Against Baselines
A common E2E pattern is to compare diagnostic output against a stored baseline file. This makes it easy to review changes in compiler behavior. If the output changes intentionally, you update the baseline; if it changes unexpectedly, the test fails.
import * as ts from "typescript";
import * as fs from "fs";
import * as path from "path";
function formatDiagnostics(diagnostics: ts.Diagnostic[]): string {
return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCurrentDirectory: () => process.cwd(),
getCanonicalFileName: (f) => f,
getNewLine: () => "\n",
});
}
describe("E2E: diagnostic baselines", () => {
const fixturesDir = path.join(__dirname, "fixtures", "diagnostics");
it("should match the stored baseline for type-error fixture", () => {
const sourcePath = path.join(fixturesDir, "type-error.ts");
const baselinePath = path.join(fixturesDir, "type-error.baseline.txt");
const sourceText = fs.readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(
sourcePath,
sourceText,
ts.ScriptTarget.Latest
);
const program = ts.createProgram(
[sourcePath],
{ strict: true, noLib: false },
{
...ts.createCompilerHost({ strict: true }),
getSourceFile: (fileName) =>
fileName === sourcePath
? sourceFile
: ts.createSourceFile(
fileName,
fs.readFileSync(fileName, "utf8"),
ts.ScriptTarget.Latest
),
}
);
const diagnostics = ts.getPreEmitDiagnostics(program);
const actual = formatDiagnostics(diagnostics);
if (!fs.existsSync(baselinePath)) {
fs.writeFileSync(baselinePath, actual);
// First run creates the baseline
return;
}
const expected = fs.readFileSync(baselinePath, "utf8");
expect(actual).toBe(expected);
});
});
Testing Custom Transformers End-to-End
If you are writing a custom transformer (for example, to strip a custom decorator or inline constants), E2E tests should verify that the transformer produces the expected JavaScript for representative inputs.
import * as ts from "typescript";
const stripLogTransformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
return (sourceFile) => {
function visit(node: ts.Node): ts.Node {
if (
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
ts.isPropertyAccessExpression(node.expression.expression) &&
node.expression.expression.name.text === "log"
) {
return context.factory.createEmptyStatement();
}
return ts.visitEachChild(node, visit, context);
}
return ts.visitNode(sourceFile, visit) as ts.SourceFile;
};
};
describe("E2E: custom transformer", () => {
it("should remove console.log statements", () => {
const source = `
const x = 1;
console.log(x);
const y = 2;
`;
const result = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020 },
transformers: { before: [stripLogTransformer] },
});
expect(result.outputText).not.toContain("console.log");
expect(result.outputText).toContain("const x = 1");
expect(result.outputText).toContain("const y = 2");
});
});
Best Practices for Testing Compiler Components
Prefer Observable Contracts Over Internals
Assert on token kinds, AST node shapes, type strings, diagnostic codes, and emitted JavaScript. Avoid asserting on internal node IDs, cache states, or private fields. Internal APIs change between TypeScript versions, but observable contracts are stable.
Use Diagnostic Codes for Error Assertions
When asserting that a specific error is produced, check the diagnostic code rather than matching the full message text. Message text can change between versions, but diagnostic codes are stable identifiers. For example, 2322 is the code for "Type X is not assignable to type Y."
Keep Test Inputs Minimal
Each test should include only the code necessary to exercise the behavior under test. Large source files make tests harder to understand and slower to run. If a test needs context, use a shared fixture file loaded once.
Snapshot Test Emitted Output Sparingly
Snapshot tests are tempting for emitted JavaScript, but they can become a maintenance burden. Prefer explicit assertions on key fragments of the output. Reserve snapshots for cases where the full output is important and changes are expected to be reviewed carefully.
Test Across Compiler Options
Compiler behavior often depends on options like strict, target, module, and experimentalDecorators. Create parameterized tests that run the same source through multiple option combinations to catch option-specific bugs.
describe.each([
{ target: ts.ScriptTarget.ES5, label: "ES5" },
{ target: ts.ScriptTarget.ES2020, label: "ES2020" },
{ target: ts.ScriptTarget.ESNext, label: "ESNext" },
])("emit for $label", ({ target }) => {
it("should emit arrow functions correctly", () => {
const result = ts.transpileModule("const f = () => 1;", {
compilerOptions: { target },
});
expect(result.outputText).toContain("f");
});
});
Isolate the Compiler Host in Tests
When creating a Program in tests, use an in-memory compiler host instead of touching the real file system. This makes tests deterministic, fast, and independent of the working directory. The examples earlier in this tutorial use a custom host that returns source from a string, which is the recommended approach.
Layer Your Tests Deliberately
Use unit tests for scanner, parser, and individual checker behaviors. Use integration tests for multi-component interactions. Use E2E tests for full project compilation and emit. This layering keeps the test suite fast at the bottom and comprehensive at the top. A good ratio is roughly 60% unit, 30% integration, and 10% E2E.
Handle TypeScript Version Differences
If your tests run against multiple TypeScript versions (for example, in a library that supports TS 4.x and 5.x), guard version-specific assertions. You can check ts.versionMajorMinor and skip or adjust expectations accordingly.
const [major, minor] = ts.versionMajorMinor.split(".").map(Number);
(major >= 5 ? describe : describe.skip)("TypeScript 5+ features", () => {
it("should support the 'satisfies' operator", () => {
const diagnostics = compileAndGetDiagnostics("const x = {} satisfies Record<string, unknown>;");
expect(diagnostics).toHaveLength(0);
});
});
Conclusion
Testing TypeScript compiler components requires understanding the compiler's internal pipeline and choosing the right testing strategy for each stage. Unit tests give you precise control over individual components like the scanner and parser, integration tests verify that the binder and checker cooperate correctly, and E2E tests confirm that real projects compile and emit as expected. By asserting on observable contracts, using stable diagnostic codes, isolating the compiler host, and layering your tests deliberately, you can build a robust test suite that catches regressions early and gives you confidence when modifying compiler behavior. Whether you are maintaining a fork of TypeScript, writing a custom transformer, or building a language tool, this layered approach will keep your compiler-related code reliable and maintainable over time.