- Book: TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I work with maintains a TypeScript SDK that a fleet of
internal services depend on. The CI pipeline for that SDK
spends most of its wall-clock time in one stage: tsc -b
emitting .d.ts files. Type-checking the source is fast.
Bundling the JS is fast. The declaration emit, which has to
walk every exported symbol and run inference on whatever the
author did not annotate, is the part that turns a three-minute
PR into a coffee break.
The fix isn't a new bundler. It's a tsconfig flag that landed
in TypeScript 5.5, sat in the back of release notes for two
years, and is now a sensible default for any library author
who cares about how long their prepublish step takes:
--isolatedDeclarations.
You turn it on. You annotate every exported declaration. From
that point forward, a declaration emitter no longer needs the
type checker. It can be a separate tool, run per-file in
parallel, and be written in Go or Rust. The TypeScript team
built it for exactly that reason, and the external tooling
caught up first.
What the flag actually does
isolatedDeclarations does not change how tsc produces
output. It only changes which programs tsc is willing to
accept. Concretely, the compiler refuses to compile a file
where any exported declaration would force a type emitter to
re-run inference to figure out the public type. The goal,
per the TS 5.5 release notes,
is for exports to be annotated enough that external tools can
generate declaration files without invoking the type checker.
Three patterns get rejected. Exported function return types
that are not annotated:
// error: Function must have an explicit
// return type annotation with --isolatedDeclarations.
export function loadConfig(path: string) {
return JSON.parse(readFileSync(path, "utf8"));
}
Exported variables whose initializer expression requires
inference:
// error: Variable must have an explicit type annotation
// with --isolatedDeclarations.
export const defaults = buildDefaults();
And re-exports of computed shapes — class members whose type
the emitter would have to infer from the body, getter/setter
pairs without matching annotations, computed property names
on exported objects.
What the flag is fine with is everything that a parser can
emit declarations for without thinking. Trivial literals are
allowed, type assertions count as annotations, and so does
the explicit return-type form. Locals stay untouched: a
private module-scope helper used by a public function can be
as inferred as you like.
function buildDefaults(): Config {
const x = readFromEnv();
return { ...x, debug: false };
}
export const defaults: Config = buildDefaults();
export const VERSION = "2.6.1";
export function loadConfig(path: string): Config {
return JSON.parse(readFileSync(path, "utf8"));
}
That file passes isolatedDeclarations. The local x is
inferred. The exported defaults carries an explicit type.
VERSION is a string literal, so the emitter narrows it to
"2.6.1" without help. loadConfig has the return type
spelled out.
Why library authors care more than app authors
If you build an app, the .d.ts files exist for your editor
and your tests. If anyone outside your repo reads them, it is
your future self looking at the rolled-up output of a project
reference. You can drop them, ship JavaScript, and the world
keeps turning.
If you publish a package, the .d.ts is your public contract.
It's what pnpm i your-sdk drops into node_modules, what
every consumer's IDE opens on hover, and what JSR validates
at publish time. Right now, in most build pipelines, it is
also the slowest part of producing the package.
The reason it is slow is the same reason TypeScript itself is
slow: declaration emit goes through the type checker, and the
type checker has to walk imports, resolve generics, run
inference, and reconstruct each exported type. For a 200-file
SDK that imports 40 other internal packages, that walk is the
whole CI minute.
isolatedDeclarations lifts the constraint that says only the
type checker can do that walk. Once that constraint is gone,
external tools step in. The oxc project
ships an isolated-declarations emitter written in Rust. SWC
shipped its own implementation in 2024
to align with tsc. Both are dramatically faster on a single
file because they skip the type checker, and dramatically
faster again across a whole project because the work is
embarrassingly parallel — every file's .d.ts depends only on
that file's source plus the import paths it references.
The TypeScript team also shipped the API that lets you do this
yourself. transpileDeclaration is the declaration-emit
sibling of transpileModule. It takes one source file and
produces one .d.ts, with no project graph and no type
checker. It only works correctly when the input passes
isolatedDeclarations, which is the whole point.
What "turn it on" looks like in practice
The flag has a hard prerequisite: you must also have
declaration: true or composite: true, because a flag about
declaration files is meaningless without declaration files.
Add it to the tsconfig that produces your published output:
{
"compilerOptions": {
"declaration": true,
"isolatedDeclarations": true,
"outDir": "dist"
}
}
Run tsc. The first run is going to fail. On a real codebase
of any size, the first run will produce dozens to hundreds of
errors, all of the same shape: "this exported thing needs a
type." That is the migration cost. There is no way around it,
and any tool that claims to do it for you is going to make
guesses you would have made differently.
The errors cluster into three groups. Walk them in this order.
Group 1: exported functions
Start here. Every export function foo(...) and
every export const foo = (...) => ... needs a return type.
You either know it from looking at the body, or you ask the
editor to write it for you. In Hermes IDE the inline-fix is a
one-key prompt to insert the inferred return type as the
annotation. In plain VS Code, the "Infer return type" quick
fix has been there for years.
A pattern that bites is the function that returns a derived
type:
export function buildSchema(rows: Row[]) {
return rows.reduce((acc, r) => ({
...acc,
[r.name]: r.type,
}), {} as Record<string, ColumnType>);
}
The inferred return type is Record<string, ColumnType>. The
fix is to write that out:
export function buildSchema(
rows: Row[],
): Record<string, ColumnType> {
return rows.reduce(
(acc, r) => ({ ...acc, [r.name]: r.type }),
{} as Record<string, ColumnType>,
);
}
Most of the function errors look like this — a couple of
minutes apiece, once you've done a few.
Group 2: exported constants built from a function call
export const client = createClient({ ... });
The fix is to either annotate the constant with the public
type the factory returns, or to thread the factory's return
type back to the call site:
export const client: ApiClient = createClient({ ... });
If the factory has a return type, the editor will offer to
add it. If the factory does not have a return type, you fix
the factory first. That factory is also one of your exported
declarations, and it's going to error in Group 1 anyway.
Group 3: exported classes with non-trivial members
export class Cache {
// error: getter must have a return type annotation
get size() {
return this.entries.length;
}
}
Annotate the getter. Annotate the matching setter. Annotate
public methods the same way you annotated functions in Group
- Private members are private; they are not part of the emitted declaration and the flag does not police them.
The hardest case in this group is the abstract or generic
class where the inferred member type depends on a type
parameter:
export class Repo<T extends { id: string }> {
byId(id: string) {
return this.items.find(x => x.id === id);
}
}
The return type is T | undefined. Write it. The compiler
needs to see it because the alternative is for it to compute
it, which is the thing the flag exists to avoid.
The cleanup pattern that keeps you honest: fence-and-grow
Migrating an existing library cold-turkey is painful. The
ergonomic version — call it fence-and-grow — is to turn the
flag on for a single subdirectory first, then expand the
boundary outward as each folder gets clean.
Split your tsconfig in two. The root keeps inference for
internal code. A child config in src/public/ (or wherever
your public surface lives) extends the root and adds
isolatedDeclarations. The compiler enforces the rule at the
package boundary, exactly where it matters, and your internal
helpers stay clean.
// src/public/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"isolatedDeclarations": true,
"declaration": true,
"outDir": "../../dist/public"
},
"include": ["./**/*.ts"]
}
The CI pipeline runs tsc -p src/public/tsconfig.json for the
publish target. Type-checking for the whole repo still runs
under the root config. New PRs that add an unannotated export
to the public folder fail at the boundary, where a reviewer
can see the cost before the package goes out.
Once the public folder is clean, expand the boundary one
folder at a time. The flag is monotonic: once a file passes,
it keeps passing as long as no one removes an annotation.
Skip the big-bang migration. Grow a fence instead.
What you get on the other side
The fence buys you the option of swapping out declaration
emit for any of the parallel emitters. You can keep using
tsc and benefit from per-file declaration emit running in
parallel as the compiler picks that up. Or wire oxc / SWC's
isolated-declarations transform into your build script today
and produce the .d.ts outputs without invoking the type
checker at all. Or write your own emit step over
transpileDeclaration with a worker pool — on a 16-core CI
runner, that's the entire reason the flag exists.
The TypeScript team has been explicit
that parallel project building is bottlenecked by the
dependency graph, except where --isolatedDeclarations
enables separate syntactic declaration emit. The flag is the
opt-in that takes the bottleneck off your project.
For a small library, the win is modest. For an SDK that
publishes from a monorepo, where one team's prepublish runs
behind ten other teams' prepublish, the wall-clock saving
compounds. If you ship from a monorepo, the annotation
discipline is a one-time price for getting declaration emit
out of your critical path.
A side benefit: documented exports
There is a non-performance payoff too: annotating every
public export documents the contract.
A reviewer reading a PR that adds a new export function no longer has to scroll into the body
makeRetryWrapper(...)
to find out what it returns; the signature is the answer.
Consumers of the SDK see the intended contract in the
published types instead of whatever the type checker happened
to infer that day. And when a future refactor quietly changes
a return type from Result<User> to User | undefined, the
type-checker catches it on the call site instead of letting
it surface as a runtime null deref three weeks later.
Treat the annotations as the public API written down — because
that's what they are. isolatedDeclarations is the first
compiler flag in years that pays for itself in build time
and gives you better-documented exports as a free byproduct.
If you publish a TypeScript package and you have not turned
this on yet, the fence-and-grow migration is a quiet
afternoon's work. The first PR after that, the one that
deletes a stale return-type-comment and replaces it with a
real annotation, is the one that earns the flag its keep.
If this was useful
isolatedDeclarations is one of the build-time flags
TypeScript in Production covers in detail alongside the
project-references topology, dual ESM/CJS publishing, and the
JSR/Node/Bun publishing dance that decides how your .d.ts
files actually reach a consumer's editor. If your day job is
keeping a published TypeScript package fast and correct,
that's the volume in the set to put on the desk.
For everything that feeds into a clean public surface (types,
narrowing, modules, async, the day-to-day machinery),
TypeScript Essentials is the entry point. The TypeScript
Type System picks up where Essentials ends with the generics,
mapped types, and infer patterns that compose into
library-grade APIs. If you are coming from JVM, Kotlin and
Java to TypeScript makes the bridge; from PHP 8+, PHP to
TypeScript covers the same ground from the other side.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)