Packaging a Typed Library

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Compiling gives you files. Packaging decides which of those files consumers can reach, which type declarations their editor sees, and which runtime branch Node or a bundler loads. A typed library is not "done" when dist/ exists; it is done when the package boundary resolves correctly for real consumers.

This lesson continues the library from 18.1. The examples are read-only package.json shapes, build scripts, and terminal checks. 18.3 will move from one package to monorepos and project references.

Exports maps and the types field

For a simple ESM-only package, the package boundary can be small and explicit:

{
  "name": "@acme/pricing",
  "version": "1.2.0",
  "type": "module",
  "files": ["dist", "README.md", "LICENSE"],
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./package.json": "./package.json"
  }
}

main is the older single-entry signpost. Keep it when you want older tools to find the package root. exports is the modern public surface: it can define the root entry, subpath entries, and condition-specific entries. Once exports exists, unlisted deep paths are private. That is the point.

types is the top-level declaration signpost. TypeScript can often discover declarations through exports, but a top-level types field is still a clear compatibility signal and keeps the package listing visibly typed. Inside exports, put "types" before runtime branches so type tools can resolve the declaration file before Node or bundlers follow "default", "import", or "require".

That package map says:

import "@acme/pricing"              -> ./dist/index.js
TypeScript types for the root       -> ./dist/index.d.ts
subpath "@acme/pricing/package.json" is public -> ./package.json
import "@acme/pricing/dist/index.js" -> not public

If users previously imported deep files, adding exports without preserving those paths is a breaking change. The runtime outcome is concrete: Node throws ERR_PACKAGE_PATH_NOT_EXPORTED for an unlisted subpath. Do not add an export map casually to an already-published package; list every public path you mean to keep.

The dual-package hazard

A dual package serves ESM consumers through one branch and CommonJS consumers through another:

{
  "name": "@acme/pricing",
  "version": "1.2.0",
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

This map assumes a verified build that writes dist/index.js as ESM, dist/index.cjs as CommonJS, dist/index.d.ts for the ESM branch, and dist/index.d.cts for the CommonJS branch. It is a map, not a guarantee of sameness. import "@acme/pricing" and require("@acme/pricing") can evaluate two different files. If the package is pure functions over arguments, that may be acceptable. If the package has constructors, registries, plugin hooks, caches, symbols, or mutable singletons, two builds can become two identities.

The declaration side needs the same care. In a "type": "module" package, index.d.ts can describe the ESM index.js branch, while index.d.cts describes the CommonJS index.cjs branch. If both branches point to one mismatched declaration file, TypeScript may accept an import shape the runtime branch does not provide. That is exactly the kind of packaging mistake validators catch better than a normal app typecheck.

Build and validate the tarball

tsup is a common legacy library-bundling shape you will see in many packages. If your project already uses a pinned, verified tsup setup, a script might look like this:

{
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap",
    "check:package": "npm run build && npm publish --dry-run && npx publint && npx --yes @arethetypeswrong/cli --pack ."
  }
}

Treat that as a version-specific shape, not a sacred tool choice. Verify the actual dist/ filenames before writing exports, and be ready to split declaration emit back to tsc or use another maintained builder when your TypeScript or bundler version changes. Rollup, Vite library mode, plain tsc, or another builder can be correct if the package boundary validates. The output matters more than the bundler badge.

The validation loop is:

npm run build
npm publish --dry-run
npx publint
npx --yes @arethetypeswrong/cli --pack .

npm publish --dry-run shows what npm would publish without uploading it. Use it to catch missing dist/ files, accidental test fixtures, or a files list that forgot declarations.

publint checks package metadata and entry points for compatibility problems across environments such as Node, bundlers, and common tooling.

Are The Types Wrong? checks the packed package's TypeScript resolution story, especially ESM/CJS and module-resolution edge cases. The local CLI can pack the current directory, analyze the tarball, and report whether consumers get types for the entry points you expose.

A good transcript is boring:

npm notice Publishing dry run complete
publint v0.3.x
All good!
@arethetypeswrong/cli
No problems found

Do not publish until the boring transcript is real. A package can pass tsc --noEmit locally and still be broken after packing because exports, files, declaration filenames, or module conditions point at paths that never made it into the tarball.

npm and JSR

npm publishes the package described by package.json. The release command is simple:

npm publish --access public

Use --access public for a first public scoped package. After a name and version are published, that exact pair cannot be reused, even if you unpublish later. That is why the dry run, packed-package validation, and real consumer smoke test happen before the final command.

JSR is a separate registry with a different model. It supports and encourages publishing TypeScript source directly, and JSR packages are ESM modules. A small JSR config looks like this:

{
  "name": "@acme/pricing",
  "version": "1.2.0",
  "exports": "./src/index.ts",
  "publish": {
    "include": ["LICENSE", "README.md", "src/**/*.ts"]
  }
}

The dry run is explicit:

npx jsr publish --dry-run

Do not assume the npm tarball and the JSR package are the same artifact. npm usually receives built JavaScript plus declarations. JSR is happier with ESM TypeScript source and verifies package rules during publishing. Dual-publishing can be a good choice, but it is two release targets and two validation loops.

Semantic versioning for types

Types are API. If a change makes correct consumer code stop type-checking, treat it as breaking even when the emitted JavaScript still runs.

Breaking type changes include removing an exported type, removing a subpath export, renaming a property, making an input property required, narrowing an accepted parameter type, tightening a generic constraint, or adding a new member to a discriminated union that consumers switch over exhaustively.

Non-breaking type changes are usually additive: a new named export, a new optional input property, better documentation comments, or a more precise return type that remains assignable to the old one. Even then, be careful with public inference. A "more precise" generic return can break type tests or downstream wrappers if callers depended on the old shape.

The practical release rule is simple: run a small consumer fixture before versioning. Install the packed tarball in a throwaway app, import it as ESM if you support ESM, require it as CJS if you support CJS, and type-check a few realistic calls. If a public declaration change would force existing users to edit code, it belongs in a major release.

Next lesson scales this from one package to many: workspaces, project references, shared configs, and the circular-dependency problems that show up when packages depend on each other.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion