Writing
Writing npm packages people can depend on: exports, tree-shaking and release flow
What separates a package that is safe to add from one that is a liability — an exports map that does not break anyone, tree-shaking you have measured rather than assumed, and a release process where the changelog cannot lie.
6 min read
We publish eight packages across npm and pub.dev. This is what we have learned about the parts that are invisible when they work and expensive when they do not.
The exports map is the most consequential file you write
main and module are legacy fields with loose resolution. exports is strict — and strict
is what you want, because it means the thing that resolves is the thing you intended.
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./validate": {
"types": "./dist/validate.d.ts",
"import": "./dist/validate.js",
"require": "./dist/validate.cjs"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": ["dist"]
}Four details, each of which breaks somebody if you get it wrong:
`types` must come first in every condition block
Conditions are matched in order. If
importprecedestypes, TypeScript resolves the JavaScript file and your consumers getany— with no error to tell them why.Ship a .d.ts per condition, not one shared file
An ESM and a CJS build have different type shapes. One
.d.tsfor both produces errors that are extremely hard for a consumer to diagnose.Export ./package.json
Bundlers and tools read it. Omitting it from
exportsmakes it unreachable, and the resulting error names your package without saying why.`sideEffects: false` is a promise, and it must be true
It tells bundlers they may drop unused modules. If any module registers something on import, that promise is false and you will break consumers in ways that only appear in production builds. List the exceptions instead of lying.
Subpath exports are how a large package stays small
A single entry point forces every consumer to depend on your largest possible surface. Subpaths let them take what they use.
| Entry | Gzipped | Contents |
|---|---|---|
| the whole package | 44.7 kB | everything |
| /core | 11.6 kB | encoding only |
| /render | 4.5 kB | SVG output |
| /terminal | 0.4 kB | text output |
Opt-in entries are worth designing for deliberately. A large lookup table or a decoder that most consumers never need belongs behind a side-effect import they choose:
import { scan } from 'teiqr/verify';
import 'teiqr/jpeg'; // +3.4 kB, only for the people who need itMeasure tree-shaking; do not assume it
sideEffects: false and named exports are necessary and not sufficient. A single module-level
new Map() in a shared file, or a class with a static initialiser, can retain a subtree.
The check is mechanical: bundle a single import and grep the output for markers that should only appear in code you expected to be dropped.
// scripts/measure-bundles.mjs
const out = await esbuild.build({
stdin: { contents: `import { encode } from './dist/index.js'; console.log(encode);` },
bundle: true, minify: true, format: 'esm', write: false,
});
const code = out.outputFiles[0].text;
assert(!code.includes('DEFLATE_MARKER'), 'PNG encoder leaked into an encode-only bundle');
console.log('encode:', gzipSync(code).length, 'bytes gzipped');Zero dependencies is a feature you can sometimes afford
Every dependency you add becomes your consumer's dependency, their audit finding, and their upgrade problem.
Count the transitive tree, not the direct list
Three direct dependencies can be ninety installed packages. That is what your consumer actually takes on.
Small, well-understood code is often cheaper to own than to depend on
An argument parser inside a CLI whose entire pitch is that it installs nothing is worth writing. Adding
commanderthere would more than double the install size.Peer dependencies for frameworks, always optional where possible
A React component package should peer-depend on React, never bundle or hard-depend on it.
Version numbers are a promise about breakage
A type-level breaking change is a breaking change
Narrowing a parameter type or making a field required breaks compilation for consumers. It is major, even though the runtime behaviour is unchanged.
Adding a subpath export is a minor; changing one is a major
The exports map is public API. Removing a path that anyone imports is breakage.
If you stay on 0.x, say what that means
"Early, but not experimental" plus a written stability policy is honest. An unexplained 0.x that has been stable for two years is a signal nobody can read.
A release flow where the changelog cannot lie
Generate the version and the changelog from commits, so the description of a release is the same artefact as the release.
on:
push:
tags: ['v*']
permissions:
contents: write
id-token: write # provenance
jobs:
publish:
steps:
- uses: actions/checkout@…
- uses: actions/setup-node@…
with: { registry-url: 'https://registry.npmjs.org' }
- run: yarn install --immutable
- run: yarn validate # the same command developers run
- run: yarn build
- run: npm publish --provenance --access publicPublish with provenance
It cryptographically links the artefact to the workflow run and commit that produced it, and it is one flag.
Use OIDC rather than a long-lived npm token
A publish token in repository secrets is a permanent credential in a system that runs code from pull requests.
Run the same validate command CI runs
A release that skips the gate is a release nobody checked.
Publish from a tag, never from a branch
So the published artefact corresponds to an immutable reference.
The README is part of the API
For a library, the README is where adoption is decided, and most of them answer the wrong question first.
Working code in the first ten lines
Install, then three lines that do something real. Not a feature list, not a badge wall.
Say what it does not do
A limitations section builds more trust than any feature list, and it prevents the issues that begin "I assumed…".
Publish the numbers you claim
Bundle size per entry, runtime support, what is tested. If you claim it runs on Workers, say how that is verified.
Document the extension points
Every registry that is open to extension is one fewer fork.
The checklist
exports map with types first, per-condition .d.ts, and ./package.json exported
Then verify resolution with a tool rather than by trying it once.
Subpath entries so consumers take only what they use
And opt-in side-effect imports for anything large and optional.
Measure tree-shaking in CI and publish the table
An unmeasured size claim goes false at the first refactor.
Count the transitive tree before adding a dependency
It becomes your consumer's problem, not yours.
Release from a tag, with provenance and OIDC, running the same gate
And generate the changelog from commits so it cannot drift from the code.
Start here
Tell us what you are building
Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.