Skip to content

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.

json
{
  "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:

  1. `types` must come first in every condition block

    Conditions are matched in order. If import precedes types, TypeScript resolves the JavaScript file and your consumers get any — with no error to tell them why.

  2. Ship a .d.ts per condition, not one shared file

    An ESM and a CJS build have different type shapes. One .d.ts for both produces errors that are extremely hard for a consumer to diagnose.

  3. Export ./package.json

    Bundlers and tools read it. Omitting it from exports makes it unreachable, and the resulting error names your package without saying why.

  4. `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.

EntryGzippedContents
the whole package44.7 kBeverything
/core11.6 kBencoding only
/render4.5 kBSVG output
/terminal0.4 kBtext output
From one of our packages. The whole toolkit is 44.7 kB gzipped; a consumer who only needs terminal output pays 0.4 kB.

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:

ts
import { scan } from 'teiqr/verify';
import 'teiqr/jpeg';   // +3.4 kB, only for the people who need it

Measure 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.

js
// 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.

  1. Count the transitive tree, not the direct list

    Three direct dependencies can be ninety installed packages. That is what your consumer actually takes on.

  2. 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 commander there would more than double the install size.

  3. 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

  1. 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.

  2. 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.

  3. 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.

yaml
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 public
  1. Publish with provenance

    It cryptographically links the artefact to the workflow run and commit that produced it, and it is one flag.

  2. 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.

  3. Run the same validate command CI runs

    A release that skips the gate is a release nobody checked.

  4. 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.

  1. Working code in the first ten lines

    Install, then three lines that do something real. Not a feature list, not a badge wall.

  2. 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…".

  3. 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.

  4. Document the extension points

    Every registry that is open to extension is one fewer fork.

The checklist

  1. 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.

  2. Subpath entries so consumers take only what they use

    And opt-in side-effect imports for anything large and optional.

  3. Measure tree-shaking in CI and publish the table

    An unmeasured size claim goes false at the first refactor.

  4. Count the transitive tree before adding a dependency

    It becomes your consumer's problem, not yours.

  5. 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.