Compiling for Consumers
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
Publishing TypeScript means compiling for people who will not have your editor, your source tree, or your local assumptions. A consumer-facing build needs runnable JavaScript, .d.ts files for the checker, and maps that point debugging back to the TypeScript you wrote.
This lesson is tool-shaped. The examples are read-only build config, reference source, and terminal shapes you would adapt in a real library project. The next lesson handles package.json exports, types, bundling, and package validation; here we focus only on what TypeScript emits.
The runtime promise: target and lib
Most app projects in this course have used TypeScript as the checker while a framework or bundler owns runtime output. A published library often needs a separate build config that lets tsc write files.
For the Node ESM build shape below, assume the package opts into ESM:
{
"type": "module"
}Then the build config can use NodeNext and keep TypeScript honest about Node's ESM rules:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"inlineSources": true,
"isolatedDeclarations": true,
"strict": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}target is about emitted JavaScript syntax. If you target an older runtime, TypeScript can rewrite some newer syntax into older JavaScript. If you target a modern runtime, TypeScript can leave more syntax alone.
lib is about the built-in names the checker believes exist: Map, Promise, Array.prototype.flat, browser globals, and so on. It is not a polyfill installer. If your public API mentions ReadableStream, Request, or a newer built-in, consumers need a runtime and type environment that understands those names.
That split matters when supporting older runtimes. Lowering target can make syntax older, but a method call such as items.toSorted() is still a method call in the emitted JavaScript. TypeScript will not invent that method. Choose a lib that matches what you actually require, or make the runtime requirement explicit.
For non-browser libraries, be cautious about "DOM" in lib. DOM types can leak into your public declarations. That is fine for browser packages and wrong for a small server utility whose consumers should not need browser globals just to type-check an import.
Declaration emit is the type API
Consumers usually type-check against the .d.ts files your build emits, not your source files. That makes declaration output part of your public API, not a leftover artifact.
Reference source:
export type PricingPlan = "free" | "pro";
export type Price = {
plan: PricingPlan;
cents: number;
currency: "USD";
};
const centsByPlan = new Map<PricingPlan, number>([
["free", 0],
["pro", 2000],
]);
export function priceFor(plan: PricingPlan): Price {
const cents = centsByPlan.get(plan) ?? 0;
return {
plan,
cents,
currency: "USD",
};
}With the build config above, the normal command is:
npx tsc -p tsconfig.build.jsonA healthy output tree has clear jobs:
dist/
index.js
index.js.map
index.d.ts
index.d.ts.mapindex.js is what runs. index.d.ts is what TypeScript consumers read. index.js.map maps running JavaScript back to TypeScript source. index.d.ts.map maps declarations back to source, so editor navigation like Go to Definition can land on the original TypeScript when the source is available.
declaration: true creates the .d.ts files. declarationMap: true creates the .d.ts.map files. When source is available, declaration maps are often the difference between "jump to a generated declaration" and "jump to the code that explains the API."
The old course rule still holds: annotate boundaries, infer the middle. In library code, exported functions, exported classes, and exported constants with meaningful object shapes are boundaries. Local variables can stay inferred; public declarations should read like an API you meant to support.
isolatedDeclarations and explicit exports
Declaration emit is normally a whole-program job. To write a .d.ts, the compiler may need to chase implementation details across files to discover an exported value's type. That is accurate, but it limits how easily other build tools can emit declarations one file at a time.
isolatedDeclarations turns that into an early check. It reports files whose exported API is not explicit enough for isolated declaration generation. It requires declaration or composite, and it does not make ordinary tsc magically parallel. The value is compatibility with faster declaration strategies: if every file carries enough public type information, a declaration generator can avoid asking the whole program to rediscover it.
Reference shape:
import { readCatalog } from "./catalog.js";
export type FeaturedPlan = {
id: string;
label: string;
monthlyCents: number;
};
const catalog = readCatalog();
// Fails under isolatedDeclarations:
export function featuredPlan() {
return catalog.featured;
}
// Better: the exported boundary says exactly what consumers get.
export function featuredPlanExplicit(): FeaturedPlan {
return catalog.featured;
}Under that config, the first exported function is not merely a style miss. It is a compiler error:
src/featured.ts(12,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.The point is not to annotate every local. catalog can stay inferred because it is an implementation detail. The exported function is different: consumers depend on its return type, so the declaration emitter should not have to inspect everything behind readCatalog() to describe the API.
This option also changes your design taste. If an exported object or function needs a complicated inferred type, that is a signal to name the public shape. A named type is easier for declaration emit, easier for docs, easier for semantic versioning, and easier for consumers to read in hovers.
Source maps for readable failures
When consumers run your compiled package, failures happen in JavaScript files. A stack trace from dist/index.js is much easier to act on when source maps can translate generated JavaScript positions back to TypeScript source positions.
For a library build, sourceMap: true emits .js.map files next to .js files and writes a source-map comment into the JavaScript. inlineSources: true puts the original TypeScript source text inside the map file. That can make debugger views useful even when the consumer does not have your repository checked out.
Source maps are not free of product judgment. They can expose original source text, file paths, and comments. For open-source packages, that is often acceptable and helpful. For closed packages, you may keep maps internal instead of including them in a public tarball. The compiler can emit the maps; your release process decides where they belong.
The build checklist for this lesson is short:
- Pick
targetandlibfrom the oldest runtime and built-ins you truly support. - Emit
.d.tsfiles withdeclaration. - Emit
.d.ts.mapfiles when source navigation matters. - Use
isolatedDeclarationswhen your build wants explicit, per-file public types. - Emit source maps when consumers or support teams need readable failures.
- Inspect
dist/before publishing.
Next lesson turns those files into a package boundary: exports maps, types, ESM/CJS decisions, package validators, and the surprisingly real problem of versioning type changes.
Checkpoint
Answer all three to mark this lesson complete