- Book: TypeScript in Production
- 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 take a tiny TypeScript library. Call it string-tools. A few hundred lines counting tests. You publish to both registries: npm because Node lives there, and JSR because by early 2026 it has crossed 40,000 packages and the Deno team keeps adding features the npm side has been asking for since 2018.
The npm publish goes through. npm publish returns happy output. You install it from a fresh project. It works.
The JSR publish does not. The CLI prints four categories of warnings, refuses to upload, and points you at a documentation page titled "slow types." You look at your exports map, which npm accepted without comment, and discover three things wrong with it that npm never mentioned. Then you run attw against the npm version you already shipped, and attw agrees. Your npm package is broken; npm never noticed.
This is the dual-publishing experience for any TypeScript library author in 2026.
Why JSR exists at all
The npm publishing pipeline for a TypeScript library has accumulated three persistent annoyances over fifteen years.
The first is the @types/* problem. If your package is JS-only, types live in a separate package maintained by DefinitelyTyped, sometimes by people who do not own the source. Versions drift. Type definitions go stale. Consumers install two packages to use one library.
The second is the transpile-before-publish habit. You write .ts, you run tsc (or tsup, or unbuild, or whatever bundler your team picked this quarter), you publish the .js and the .d.ts. The source the user sees is generated. Sourcemaps help when they line up, which is less often than you would like.
The third is the question nobody can answer at install time: are this package's types actually right? Does the exports map line up with what the bundler produced? Will it work in CJS, in ESM, in the browser, in Bun, in a moduleResolution: "node16" project, in a "bundler" project? You will not know until a consumer files a bug.
JSR launched in open beta in March 2024 from the Deno team and reframed all three. It accepts TypeScript natively: you publish .ts files, JSR generates declaration files at the registry. There is no @types/* because there does not need to be. Versions are immutable, scoped, and JSR refuses to publish packages whose types it cannot resolve quickly. That last constraint is the slow-types restriction. It is the bouncer at the door for many of the libraries that try to dual-publish.
By early 2026 JSR has passed 40,000 packages. That is well below npm's package count (which sits in the millions, with versions in the tens of millions), but it is past the point where you can dismiss it as a Deno toy. Frameworks publish there. Standard-library projects too. Libraries that started on npm now keep a second home there.
The npm side: why it works against you
Your string-tools package starts with a package.json that any TypeScript library author would recognize.
{
"name": "@you/string-tools",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./slugify": {
"types": "./dist/slugify.d.ts",
"import": "./dist/slugify.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts src/slugify.ts --format esm,cjs --dts",
"prepublishOnly": "npm run build"
}
}
You run npm publish. It succeeds. A minute later you can npm install @you/string-tools from another project. The package is real.
The trouble is hidden in the exports map.
The most obvious problem is the ./slugify subpath: it has no require condition, but the package ships both ESM and CJS builds. A CJS consumer that imports the subpath gets a runtime error like Error [ERR_REQUIRE_ESM] or a missing-module failure depending on the resolver.
Worse, the condition order is wrong. types must come before import and require. Some bundler outputs flip this, and TypeScript's resolution picks the wrong condition when the order drifts.
And on top of that, main, module, and types are still set at the top level alongside exports. Modern resolvers mostly ignore them, but not every tool does. Some bundlers fall back. Some IDEs read them. Their presence creates a parallel resolution path that drifts from the exports map you actually maintain.
npm publishes all of this without complaint. The package "works" for the happy-path consumer in a modern ESM project. The CJS consumer of the subpath does not, the node16 consumer with strict resolution does not, and the bundler that prefers module over exports.import does not.
This is what JSR refuses to ignore.
The JSR side: native TypeScript and a stricter contract
JSR's publish flow is built around jsr.json (or deno.json from a Deno project). The shape is deliberately simpler than package.json.
{
"name": "@you/string-tools",
"version": "1.0.0",
"exports": {
".": "./src/index.ts",
"./slugify": "./src/slugify.ts"
},
"publish": {
"include": ["src/", "README.md", "LICENSE"]
}
}
The exports map points at .ts source files, not at a built dist/ directory. There is no types field, because JSR generates declarations from the source at publish time. There is no separate main / module / types triplet, because the registry has decided there is one canonical answer.
You run npx jsr publish (or deno publish from a Deno project). And then you read warnings.
warning[slow-types]: missing explicit return type
╭─ src/slugify.ts:4:17
│
4 │ export function slugify(input) {
│ ^^^^^^^
│
= hint: add an explicit return type annotation
warning[slow-types]: missing explicit type for exported constant
╭─ src/index.ts:12:14
│
12 │ export const VERSION = "1.0.0";
│ ^^^^^^^
error[slow-types]: 2 problems found, refusing to publish
(Sample output, exact wording varies by jsr version.)
This is the slow-types restriction. JSR's documented policy: every exported symbol must have a type that the registry can resolve without re-running the TypeScript checker on the consumer side. That means explicit return types on every exported function, explicit annotations on exported constants whose inferred type is non-trivial, no exported symbols whose type comes from a complex generic inference chain.
The reason is mechanical. JSR generates .d.ts from your source. If your exported types depend on full type-inference, every consumer of your package would have to type-check your source as part of using it, which is exactly what the JS world tried to escape with @types/*. Slow-types forces library authors to write boundary annotations even when TypeScript would have inferred them. Internal code can stay fully inferred. The exported surface cannot.
You add the annotations. You re-run jsr publish. It uploads. The package page on jsr.io shows the source as actual .ts, syntax-highlighted and browseable, alongside the auto-generated documentation pulled from your JSDoc comments. No separate docs build, no @types/string-tools, one canonical artifact that the registry already understands.
attw: are the types wrong (yes, probably)
The npm side is published. You think it works. You check anyway with @arethetypeswrong/cli, called attw for short.
npx @arethetypeswrong/cli @you/string-tools
Output:
@you/string-tools v1.0.0
Module Resolution
"node10": ✓
"node16 (CJS)": ⚠ Masquerading as CJS
"node16 (ESM)": ✓
"bundler": ✓
Resolved Files
node16 (CJS): dist/index.cjs (CJS) -> wrong types
node16 (ESM): dist/index.js (ESM) -> dist/index.d.ts
Problems
⚠ CJS module masquerades as ESM in package.json
⚠ Subpath "./slugify" has no CJS condition
⚠ Types do not match runtime resolution under node16-cjs
(Sample output; attw formatting varies by version. Run it yourself for the canonical view.)
attw catches what JSR shouts about, but for the npm world. It runs your published package through every meaningful resolution mode (node10, node16 CJS and ESM, bundler) and reports which resolutions return the right types.
"Masquerading as CJS" is one of attw's most-cited diagnostics. It happens when exports.require points at a .js file that is actually ESM (because "type": "module" is set at the top level). Or the reverse. The runtime sees a mismatch and either crashes or silently picks the wrong file.
attw is now part of standard library-author CI. The pattern most repos converge on:
- name: Check published types
run: |
npm pack
npx @arethetypeswrong/cli --pack
--pack runs attw against the tarball npm pack produces, so you check the publishable artifact, not the source tree. The tarball is what consumers actually install. If attw flags the tarball, the next npm install will hit the same problem.
publint: the other half of the lint
publint is attw's sibling tool. Where attw audits types, publint audits the package.json itself: the exports map, the files array, the bin entries, the deprecated fields.
npx publint
Sample output for the string-tools package as written above (formatting varies by publint version):
publint @you/string-tools
Suggestions
pkg.main is set, but it is not needed when pkg.exports is used.
pkg.module is set, but it is not needed when pkg.exports is used.
pkg.types is set, but it is not needed when pkg.exports is used.
Warnings
pkg.exports["./slugify"] is missing a "require" condition.
pkg.exports["."] should have "default" condition as the last condition.
Every line points at a real problem. The legacy fields create a parallel resolution path. The missing require condition is the same sin JSR would refuse to publish. The missing default is what every modern bundler asks for when none of the named conditions match.
publint.dev runs the same checks in the browser against any published npm package. Together with arethetypeswrong.github.io, those are the two pages to check before installing any TypeScript library you don't already know is clean.
The standard CI shape for any serious library in 2026 is:
- run: npm pack
- run: npx publint
- run: npx @arethetypeswrong/cli --pack
Three lines. They catch what npm's own validation does not.
What the dual-publish forces you to fix
The corrected package.json for string-tools, after going through both registries:
{
"name": "@you/string-tools",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./slugify": {
"types": "./dist/slugify.d.ts",
"import": "./dist/slugify.js",
"require": "./dist/slugify.cjs",
"default": "./dist/slugify.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts src/slugify.ts --format esm,cjs --dts",
"lint:pkg": "publint && attw --pack",
"prepublishOnly": "npm run build && npm run lint:pkg"
}
}
The legacy main / module / types triplet is gone, modern resolvers do not need it, and removing it eliminates the parallel resolution path. Every subpath has all four conditions in the right order: types, import, require, default last. The prepublishOnly script runs both publint and attw against the packed tarball before the publish gets a chance to escape.
The corrected jsr.json is unchanged in shape (JSR was already strict), but the source files now have explicit return types on every exported function and explicit annotations on every exported constant. Slow-types diagnostics go away. Both registries accept the package.
The real payoff of dual-publishing is that the JSR side acts as a stricter linter for the npm side, and attw plus publint fill in the rest.
When to dual-publish and when one is enough
Dual-publishing is not free. You maintain two manifest files, run two publish commands, handle two version histories (JSR's are immutable, which is great for trust and inconvenient for typos). The decision rule that has shaken out:
npm only when the library is Node-runtime-coupled: depends on node:fs, node:net, node:cluster, native addons, anything that is not portable. JSR will accept the package, but you spend the dual-publish budget for an audience that does not exist.
JSR only when the library is Deno-native: uses Deno.* APIs, JSR-scoped imports, the Deno standard library. npm will accept a transpiled version, but the result reads as a port nobody asked for.
Both when the library is runtime-agnostic: pure logic, types, parsing, hashing, formatting, validation. The string-tools shape. Anything that runs the same in Node, Deno, Bun, the browser, and Cloudflare Workers. This is where dual-publishing pays for itself.
Where to start tomorrow
If you are about to ship a TypeScript library, do one thing this week: add publint and @arethetypeswrong/cli to your prepublishOnly script. Five-minute change, and it catches the exports-map mistakes that have been silently shipping for years. If your library is runtime-agnostic, write a jsr.json next and try jsr publish --dry-run. Read every slow-types diagnostic. Those diagnostics point at the same boundary annotations that make the npm package's .d.ts files smaller, and they will keep pointing at them every time you ship.
If this was useful
Library authoring across runtimes (tsconfig, build matrices, exports maps, dual ESM/CJS, JSR alongside npm) is the focus of TypeScript in Production, the production layer of The TypeScript Library. The other four books in the collection cover the path you take to get there.
The reader-journey rule for the collection: pick books 1 and 2 if TypeScript is your first serious typed language. Substitute in 3 or 4 if you are bridging from JVM or PHP. Add book 5, TypeScript in Production, once you are shipping TS at work, which is exactly the moment dual-publishing, attw, and publint start mattering.
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point. Types, narrowing, modules, async, daily-driver tooling. Amazon
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive. Generics, mapped/conditional types, infer, template literals, branded types. Amazon
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — variance, null safety, sealed→unions, coroutines→async/await. Amazon
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — sync→async paradigm, generics, discriminated unions. Amazon
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR. Amazon

Top comments (0)