- Book: TypeScript in Production — Tooling, Build, and Library Authoring
- 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
You publish a small utility package. It compiles. The README
example runs on your laptop. You npm publish and move on.
A week later an issue lands. A user on a CommonJS project
imports your package, and their bundler pulls in the ESM copy
of your code. Somewhere else in their tree, a transitive
dependency pulls in the CommonJS copy. Now there are two
instances of your module in one process. Your instanceof
checks fail across the boundary. Your module-level cache holds
two separate maps. The user's Set of your error classes
silently misses half its members.
That is the dual-package hazard, and the
Node.js docs name it explicitly.
It is the trap waiting for every library author who tries to
support both module systems at once.
Here is the checklist to run before publishing a dual-format
package. Every item is verifiable — you can check it with a
command, not a vibe.
1. The exports map is the contract
The single field that decides everything is exports in
package.json. Node and modern bundlers read it to pick which
file a consumer gets. The legacy main field is a fallback for
very old tooling; the exports map is the real contract.
Here is the shape for a package that ships both formats and
types for each.
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
Read it as a lookup table. A consumer using import gets the
ESM file and the ESM types. A consumer using require gets the
CommonJS file and the CommonJS types. Each condition carries
its own types entry, because the type file has to match the
module format of the code file next to it.
2. The types condition goes first
This is the rule that bites the most people, and it has a
mechanical reason.
Condition keys inside exports are matched in the order they
are written, top to bottom. The first key that matches wins.
TypeScript reads the map looking for a types condition. If
default sits above types, the default key matches first
and TypeScript never sees the type file.
So types is always the first key inside each branch.
{
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
Put default first and the types go missing for that consumer.
The code still runs; the editor just stops knowing anything
about it. This failure is invisible in your own repo because
your local build resolves types a different way. It only shows
up for the person who installs the published package.
3. The .d.cts and .d.ts files are not interchangeable
A common shortcut is to emit one index.d.ts and point both
the import and require branches at it. That works until it
does not.
A declaration file describes the module syntax of the code it
sits beside. index.d.ts describes an ESM module:
export default, named export. index.d.cts describes a
CommonJS module: export =. When you point the require
branch at an ESM .d.ts, TypeScript on the consumer side
reports a default export that the runtime require() does not
actually hand back the same way. The classic symptom is
esModuleInterop rescuing it in some configs and not others.
Emit a matching declaration file per format. The build tools in
the next section do this for you.
4. Let the build tool own the dual emit
You can hand-roll two tsc passes with two tsconfigs. It works
and it is tedious to keep in sync. Two tools take the job off
your plate.
tsup wraps esbuild and emits both formats from one entry.
// tsup.config.ts
{
"entry": ["src/index.ts"],
"format": ["esm", "cjs"],
"dts": true,
"clean": true
}
That produces index.js, index.cjs, index.d.ts, and
index.d.cts in one run. You still write the exports map by
hand.
tshy goes further: it generates the exports map for you
from your source layout. You declare your entry points, and
tshy writes the dual-format exports block into package.json
on every build, with the types-first ordering already
correct.
// package.json
{
"tshy": {
"exports": {
".": "./src/index.ts"
}
}
}
The trade-off is control. tsup gives you a fast build and
leaves the map to you. tshy owns the map and enforces the
layout it expects. For a new library where you have not
memorised the condition-order rule yet, tshy removing the
manual map is worth the constraint.
5. Guard the dual-package hazard at the seam
Emitting both formats is the easy half. The hazard from the
opening is the half that ships bugs.
The root cause: when both copies of your code can load in one
process, anything that depends on object identity breaks across
the seam. instanceof, a WeakMap keyed on your classes, a
module-level singleton, a registry.
Two defenses, in order of preference.
First, avoid identity-dependent state at the public boundary.
Do not export an instanceof-based type guard as your only
check. Add a structural one.
export class AppError extends Error {
readonly _tag = "AppError";
}
// Survives the dual-package seam:
export function isAppError(e: unknown): e is AppError {
return (
typeof e === "object" &&
e !== null &&
"_tag" in e &&
(e as { _tag?: unknown })._tag === "AppError"
);
}
The _tag check matches by shape, so it holds even when the
ESM AppError and the CommonJS AppError are different class
objects.
Second, if you have genuinely shared state, isolate it in an
internal package that ships in one format only, and have both
your ESM and CJS entry points depend on that single copy.
6. Verify before you publish
Here is the part that turns the whole checklist from belief
into fact. Two commands.
arethetypeswrong resolves your published package the way each
consumer would and reports every condition that points at the
wrong file or the wrong format.
npx @arethetypeswrong/cli --pack
It flags the exact failures this post describes: types
condition in the wrong position, a .d.ts served to a
require consumer, masquerading ESM. A clean run is the signal
that your exports map is correct for real consumers, not just
for your local resolver.
Then publint checks the rest of the manifest — files,
missing exports paths, fields that point at files that are
not in the tarball.
npx publint
Run both against the packed tarball, not the source tree. The
tarball is what the user installs.
7. JSR is the other distribution channel
If your package is TypeScript-first, JSR
changes the calculus. JSR publishes your TypeScript source and
generates the type declarations and the format transforms on
its side. You publish .ts, and JSR serves the right shape to
Node, Deno, Bun, and bundlers.
That removes the hand-built exports map and the dual-emit
build step from your repo for JSR consumers. It does not
replace npm for the broad ecosystem yet, so the realistic 2026
setup for a widely-used library is both: JSR for the
TypeScript-native channel, npm with a verified dual-format
exports map for everyone else.
The checklist, condensed
Run this list before every publish:
-
exportsmap present, withimportandrequirebranches. -
typesis the first key inside each branch. - A matching declaration file per format:
.d.tsfor ESM,.d.ctsfor CJS. - Build tool owns the dual emit — tsup for control, tshy for the generated map.
- Public boundary does not depend on object identity that the dual-package seam can break.
-
arethetypeswrong --packis clean. -
publintis clean. - JSR considered for the TypeScript-native channel.
The two verification commands in steps 6 and 7 are the ones
that matter most. Everything above them is how you get to a
green run; those two are how you know you got there.
If this was useful
Dual publishing is one corner of the work that starts after the
types compile — the build, the manifest, the resolution rules
each runtime applies. TypeScript in Production walks the whole
path, from the exports map to monorepo layout to authoring
across Node, Bun, and Deno. If the checklist above is the kind
of thing you want laid out end to end, that is the book it
comes from.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)