Vivian Voss

TypeScript: The Build Tax

typescript javascript tooling

Performance-Fresser ■ Episode 10

ECMAScript is a complete language. It has been for some time. Types are not missing from JavaScript: they were never part of the specification, which is a rather different thing. Some teams want them enforced at authoring time. That is a perfectly legitimate preference. But a preference and a deficiency are not the same, and the distinction matters because one of them requires a compiler and the other does not.

The Same Information, Two Pipelines

Consider a function. A greeting, since the industry appears to have standardised on those for demonstration purposes. Here it is in TypeScript:

function greet(name: string): string {
    return `Hello, ${name}`;
}

And here is the same function annotated with JSDoc:

/** @param {string} name @returns {string} */
function greet(name) {
    return `Hello, ${name}`;
}

Identical type information. Identical IDE support: the same IntelliSense, the same autocompletion, the same refactoring tools. Visual Studio Code processes both through the same TypeScript language server. The diagnostic output is the same. The developer experience is the same.

The difference is what happens next.

TYPESCRIPT PIPELINE source.ts authored code tsc compiler strip types, emit JS source.js shipped code tsc --watch runs permanently ■ 18 s for 334 files ■ 100+ config options JSDOC PIPELINE source.js authored code no build step source.js shipped code same file ■ same types ■ same IDE support ■ zero compilation

The TypeScript file must pass through a compiler before the browser can execute it. The JSDoc file is the shipping artefact. It runs as written. The type annotations live in comments, which the engine ignores and the tooling reads. Nothing is stripped. Nothing is transpiled. Nothing can go wrong between authoring and execution because there is no "between."

The Build Tax

Every change to a TypeScript file requires compilation. tsc --watch runs permanently in the background, consuming CPU cycles and file-system watchers while you work. For small projects, the delay is negligible. For large codebases, it is not.

One team documented eighteen seconds to compile 334 files. Eighteen seconds of dead air every time the build runs. Multiply that by the number of developers, the number of commits, the number of CI pipelines, and the figure stops being a rounding error.

Microsoft's response to the performance problem is instructive. Rather than questioning whether compilation is the right approach, they are rewriting the compiler in Go for a promised tenfold speedup. One does admire the engineering. One also notes that the fastest compiler is still slower than no compiler at all.

The Configuration Labyrinth

The tsconfig.json specification offers north of one hundred options. Strict mode, module resolution strategy, target ECMAScript version, path aliases, project references, incremental compilation, declaration maps, composite builds. The list continues with the quiet determination of a Victorian novel.

Every team debates which flags to enable. Every project inherits a configuration that someone chose eighteen months ago for reasons no one can reconstruct. The configuration file becomes load-bearing infrastructure: touch it and things break in ways that have nothing to do with your code and everything to do with the compiler's opinion of your code.

JSDoc requires no configuration file. The types live in the source. The editor reads them. That is the entire workflow.

The Type Gymnastics

TypeScript's type system is Turing-complete, which is a polite way of saying it can express anything, including things no reasonable person would wish to read. Consider:

Partial<Pick<Omit<T, 'id'>, keyof U>>

This is not a pathological example constructed to make a point. This is Tuesday afternoon in a mature TypeScript codebase. The type system rewards cleverness over clarity, and clever types have a way of accumulating until the cognitive overhead of reading the type signature exceeds the cognitive overhead of reading the code it describes. At that point, the types are no longer documentation. They are a second codebase that happens to share the same file.

The Third-Party Lottery

DefinitelyTyped hosts over eight thousand community-maintained type definitions for JavaScript libraries that do not ship their own. The definitions are often excellent. They are also, on occasion, outdated, incomplete, or quietly wrong, because the types are maintained separately from the library, by different people, on a different release cadence.

The result is a lottery. Install a library, install its types, and discover at some indeterminate future point that the two have drifted apart. The compiler reports no error because the type definition says everything is fine. The runtime disagrees. The compiler was never going to win this argument, because the compiler is not present at runtime.

The Runtime Reality

This is the structural irony at the heart of the TypeScript proposition. The types exist exclusively at compile time. They are stripped before execution. The shipped JavaScript contains no trace of them. The compiler can verify that your code conforms to its own model of the world, but it cannot verify that the world conforms to its model.

An API that returns { "age": "twenty-five" } where the type definition expects number will sail through compilation without a murmur. The error surfaces at runtime, in production, on a Friday afternoon: precisely the scenario the type system was supposed to prevent.

Runtime validation requires runtime code. Zod, io-ts, Ajv: these exist because the compiler cannot do what the marketing implies it does. If you need runtime safety, you need runtime checks. The types were always a suggestion.

The Alternative That Already Exists

JSDoc type annotations have been supported by the TypeScript language server since TypeScript 2.3, released in 2017. The same engine that powers TypeScript's IDE experience (autocompletion, diagnostics, refactoring, go-to-definition) reads JSDoc annotations in plain JavaScript files. The tooling is identical. The developer experience is identical. The difference is the absence of a build step.

This is not a fringe position. The Svelte framework migrated from TypeScript to JSDoc. Webpack has always used JSDoc. HTMX uses JSDoc. These are not small projects maintained by people unaware of TypeScript's existence. They are large, widely-used codebases whose maintainers evaluated the trade-off and concluded that a compiler was not worth the tax.

TypeScript for apps, JSDoc for libraries.

That is Rich Harris, creator of Svelte, drawing a line that the industry has been reluctant to draw. The suggestion is not that TypeScript has no place. It is that its place is smaller than its adoption would imply, and that the build step it demands is not free.

The Compounding Cost

Build steps do not exist in isolation. They interact. TypeScript compilation feeds into bundling, which feeds into minification, which feeds into source-map generation, which feeds into deployment. Each stage adds latency, configuration surface, and failure modes. The TypeScript Performance wiki is an entire document dedicated to managing the cost of the compiler: project references, incremental builds, skipLibCheck, isolatedModules. An ecosystem of workarounds for a problem that need not exist.

The build step you skip is the build step that cannot break. The configuration you omit is the configuration that cannot drift. The compiler you do not run is the compiler that never stands between your code and your user.

TypeScript solved a genuine problem: large teams benefit from enforced type contracts. The question is not whether the problem is real. The question is whether the solution (a compiler, a configuration file, a build pipeline, a type system complex enough to be Turing-complete) is proportionate to the problem it addresses.

For some teams, the answer is yes. For rather more than will admit it, the answer is a comment block and a language server that was there all along.