LEVEL 6 · LESSON 18 OF 22

Libraries and large projects Advanced

Publishing TypeScript packages

Build a TypeScript library into JavaScript plus declarations, write package.json exports and files, check the tarball like a user, handle ESM and CommonJS, and version types.

  • 55 min to read and try
  • You need: Declaration files, Modules in TypeScript, npm and packages, The npm ecosystem in depth and Module systems in depth
  • You build: A naira formatting package, @naija-shop/naira, with a separate build config, declaration and source maps, a checked exports map, a CommonJS consumer, a dual ESM and CommonJS build that shows the dual-package hazard, and a release checklist
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Build a library with a separate build tsconfig that emits JavaScript, declarations and maps from src to dist
  • Write an exports map with types first, and a files allow-list, then prove the result with npm pack, publint and attw
  • Consume the package as a user would, from ESM, from CommonJS and from a tarball
  • Explain the dual-package hazard and choose between an ESM-only and a dual build
  • Decide whether a change to a package's types is a patch, minor or major release

It worked in our repository

The shop team turned the formatter from Declaration files into a proper TypeScript package, @naija-shop/naira, so that the checkout, the admin dashboard and the invoicing service could share it. In their monorepo it was perfect: every app imported it and got full types. They published version 1.0.0. The first message from another team read:

The other team's terminal
npm install ../naira/naija-shop-naira-1.0.0.tgz
…
npx tsc --noEmit
checkout.ts:1:29 - error TS7016: Could not find a declaration file for module '@naija-shop/naira'. '/home/you/shop-app/node_modules/@naija-shop/naira/dist/index.js' implicitly has an 'any' type.
  Try `npm i --save-dev @types/naija-shop__naira` if it exists or add a new declaration (.d.ts) file containing `declare module '@naija-shop/naira';`

1 import { formatNaira } from "@naija-shop/naira";
                              ~~~~~~~~~~~~~~~~~~~


Found 1 error in checkout.ts:1

(Throughout this lesson the package is installed from the tarball that npm pack produces, which is byte for byte what the registry would serve, so nothing has to be published for real.) A TypeScript library, published by a TypeScript team, arrived without types. The team looked at what they had actually uploaded:

Terminal on your computer
npm pack --dry-run
npm notice Tarball Contents
npm notice 850B dist/index.js
npm notice 489B package.json
…
npm notice total files: 2

Only the JavaScript. Their package.json said "files": ["dist/*.js"], which left out dist/index.d.ts. Inside the monorepo nobody noticed, because a workspace links the whole folder and the .d.ts file was right there on disk. The registry only ever sees the tarball.

Publishing a TypeScript package means shipping two products at once: JavaScript that runs, and declarations that describe it. This lesson builds @naija-shop/naira properly, checks it the way a user would receive it, and deals with the questions every library author meets: ES modules or CommonJS, what goes in exports, and when a type change needs a new major version.

What a published TypeScript package contains

Nobody runs your .ts files. Node.js refuses to strip types inside node_modules (you saw that in What the TypeScript compiler does), bundlers expect JavaScript there, and the user's tsc should not type-check your source with their settings. So a package ships the compiler's output:

  src/index.ts ──tsc -p tsconfig.build.json──▶ dist/index.js        code that runs
                                               dist/index.d.ts      types for users
                                               dist/index.js.map    stack traces → src/index.ts
                                               dist/index.d.ts.map  "go to definition" → src/index.ts
  package.json   "exports" points users at dist/index.js and dist/index.d.ts
               "files" decides which of these reach the registry
A TypeScript package is compiled JavaScript plus declarations, described by package.json.

Every @zudojs/* package has exactly this shape: a dist folder of .js and .d.ts files, and an exports map pointing at them.

A separate build configuration

Here is the library. It is small on purpose: one file with two functions and one interface. Real packages have many files and a barrel index.ts, as you built in Modules in TypeScript; everything below works the same.

src/index.ts
export interface FormatOptions {
  /** Put the ₦ sign in front. Default: true. */
  symbol?: boolean;
}

/** Formats whole kobo as naira text: 125050 becomes "₦1,250.50". */
export function formatNaira(kobo: number, options: FormatOptions = {}): string {
  if (!Number.isSafeInteger(kobo)) throw new RangeError(`kobo must be a whole number, got ${kobo}`);
  const sign = kobo < 0 ? "-" : "";
  const abs = Math.abs(kobo);
  const naira = Math.floor(abs / 100).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const rest = String(abs % 100).padStart(2, "0");
  return `${sign}${options.symbol === false ? "" : "₦"}${naira}.${rest}`;
}

/** Reads text such as "₦1,250.50" into kobo, or returns null if it is not an amount. */
export function parseNaira(text: string): number | null {
  const cleaned = text.replace(/[₦,\s]/g, "");
  if (!/^-?\d+(\.\d{1,2})?$/.test(cleaned)) return null;
  return Math.round(Number(cleaned) * 100);
}

A library usually has two TypeScript configurations. tsconfig.json is what your editor and npm run check use: it covers everything, tests and scripts included, and never emits. tsconfig.build.json extends it and produces the package, from src only, without the tests:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"],
    "noEmit": true,
    "verbatimModuleSyntax": true
  }
}
tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noEmitOnError": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}
  • extends copies every setting from the main file, so strict and the module settings cannot drift apart between checking and building.
  • rootDir/outDir: src/index.ts becomes dist/index.js, not dist/src/index.js.
  • declaration writes the .d.ts files; declarationMap and sourceMap write the two kinds of .map file.
  • noEmitOnError: a type error means no dist at all, so a broken build can never be published.
  • exclude keeps test files out of the package.
Terminal on your computer
npx tsc -p tsconfig.build.json
ls dist
index.d.ts  index.d.ts.map  index.js  index.js.map
dist/index.js
/** Formats whole kobo as naira text: 125050 becomes "₦1,250.50". */
export function formatNaira(kobo, options = {}) {
    if (!Number.isSafeInteger(kobo))
        throw new RangeError(`kobo must be a whole number, got ${kobo}`);
    const sign = kobo < 0 ? "-" : "";
    const abs = Math.abs(kobo);
    const naira = Math.floor(abs / 100).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    const rest = String(abs % 100).padStart(2, "0");
    return `${sign}${options.symbol === false ? "" : "₦"}${naira}.${rest}`;
}
/** Reads text such as "₦1,250.50" into kobo, or returns null if it is not an amount. */
export function parseNaira(text) {
    const cleaned = text.replace(/[₦,\s]/g, "");
    if (!/^-?\d+(\.\d{1,2})?$/.test(cleaned))
        return null;
    return Math.round(Number(cleaned) * 100);
}
//# sourceMappingURL=index.js.map
dist/index.d.ts
export interface FormatOptions {
    /** Put the ₦ sign in front. Default: true. */
    symbol?: boolean;
}
/** Formats whole kobo as naira text: 125050 becomes "₦1,250.50". */
export declare function formatNaira(kobo: number, options?: FormatOptions): string;
/** Reads text such as "₦1,250.50" into kobo, or returns null if it is not an amount. */
export declare function parseNaira(text: string): number | null;
//# sourceMappingURL=index.d.ts.map

Three details are worth noticing. The JSDoc comments survive in both files, so users see them on hover. The default value options = {} became options? in the declaration: callers only need to know the argument is optional. And each file ends with a sourceMappingURL comment naming its map. The declaration map is a small JSON file whose sources field points back at your TypeScript:

Terminal on your computer
cat dist/index.d.ts.map
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,8CAA8C;…"}

When a user presses "go to definition" on formatNaira, their editor follows that map to ../src/index.ts, but only if that file is in the package. That is why the files list below includes src.

Before you write package.json

REASON IT OUT

What will a user of @naija-shop/naira actually get?

You are about to describe the package to npm, to Node.js and to TypeScript. Before writing any field, answer:

  1. Which files must be in the tarball for an import to run? For the types to work? For "go to definition" to reach the source? Which files must never be in it?
  2. Which import paths do you promise to keep working? What happens to a user who imports @naija-shop/naira/dist/index.js directly, if you later rename that file?
  3. Who might load the package with require instead of import? What Node.js versions do they run?
  4. When TypeScript and Node.js both read the same exports map, can they disagree about which file an import means?
Show the reasoning
  1. Running needs dist/index.js. Types need dist/index.d.ts. "Go to definition" needs the .d.ts.map plus src/index.ts; stack traces through the package need the .js.map. Tests, tsconfig files, .env files and build caches must stay out. An allow-list in files is the only safe way to get this right.
  2. Only the paths you list in exports. If deep imports are possible, someone will use them, and your internal file names become public API. An exports map makes every unlisted path fail, in Node.js and in TypeScript.
  3. Older services written in CommonJS, and tools that still require their plugins. On Node.js 24 (and 22.12+, 20.19+), require can load an ES module without top-level await. The package should say which Node.js versions it supports.
  4. Yes. TypeScript follows the "types" condition, Node.js ignores it and follows "import", "require" or "default". If the conditions are in the wrong order, or point at mismatched files, the types can describe one file while Node runs another. You must check both.

package.json for a TypeScript library

package.json
{
  "name": "@naija-shop/naira",
  "version": "1.0.1",
  "description": "Format and parse naira amounts stored in kobo",
  "license": "MIT",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist", "src", "!src/**/*.test.ts"],
  "engines": { "node": ">=24" },
  "scripts": {
    "check": "tsc --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "prepublishOnly": "npm run check && npm run build"
  }
}
  • "type": "module": the .js files in dist are ES modules, and the .d.ts files describe ES modules.
  • "exports" is the package's front door, as you learned in Module systems in depth. Each entry is an object of conditions, checked in the order written. "types" is a condition only TypeScript uses, and it must come first: TypeScript also matches "import" and "default", so if one of those came first it would stop there. "default" matches everything and must come last.
  • Using "default" rather than "import" for the JavaScript means both import and require reach the same file. You will see why that matters below.
  • "./package.json" is exported because some tools read a package's version through it; without this line they get ERR_PACKAGE_PATH_NOT_EXPORTED.
  • "files" is the allow-list. A ! pattern removes matches again, here the tests. npm always adds package.json, the README and the licence file.
  • "prepublishOnly" runs automatically before npm publish: the check and a fresh build, so what you upload is never an old or broken dist.

Older guides also add top-level "main" and "types" fields. Tools that understand exports ignore them; they are only a fallback for very old resolvers. If you keep them, they must point at the same files as exports.

Use the package as a user would

A package may import itself by its own name, through its own exports map. That lets you test the published entry points from inside the project, with no install:

consumer.tsNode.js only
import { formatNaira, parseNaira, type FormatOptions } from "@naija-shop/naira";

const plain: FormatOptions = { symbol: false };
const kobo = parseNaira("₦1,250,000.50");
console.log(kobo === null ? "invalid" : formatNaira(kobo));
console.log(formatNaira(99, plain));
Output of npx tsx consumer.ts
₦1,250,000.50
0.99

TypeScript resolved "@naija-shop/naira" through the "types" condition to dist/index.d.ts, and Node.js through "default" to dist/index.js. Now try what a curious user might do: reach past the front door.

deep.ts
import { parseNaira } from "@naija-shop/naira/dist/index.js";

console.log(parseNaira("₦5"));
What npx tsc --noEmit prints
deep.ts:1:28 - error TS2307: Cannot find module '@naija-shop/naira/dist/index.js' or its corresponding type declarations.

1 import { parseNaira } from "@naija-shop/naira/dist/index.js";
                             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


Found 1 error in deep.ts:1

Refused, because ./dist/index.js is not a key in exports. You are free to rename and reorganise everything inside dist in a minor release, since nobody can depend on it. Anything you do list becomes a promise; a subpath export such as "./parse" gets its own types and default pair (exercise 1).

Check the tarball, not the folder

The mistake in the first section survived because everyone tested the folder. Three checks look at the tarball instead, and each catches things the others miss.

1. npm pack --dry-run: what is in it?

Terminal on your computer
npm pack --dry-run
npm notice Tarball Contents
npm notice 456B dist/index.d.ts
npm notice 336B dist/index.d.ts.map
npm notice 850B dist/index.js
npm notice 1.0kB dist/index.js.map
npm notice 561B package.json
npm notice 945B src/index.ts
npm notice Tarball Details
npm notice name: @naija-shop/naira
npm notice version: 1.0.1
…
npm notice total files: 6

Code, declarations, both maps and the source, and no test file. Read this list before every release; it takes ten seconds.

2. publint: is package.json consistent?

publint is a linter for package metadata. It packs the package and checks every path and condition in package.json against the files that would really be published. Run on the broken 1.0.0 from the first section (the one with default before types and "files": ["dist/*.js"]), it finds all three mistakes:

Terminal on your computer
npx publint
Running publint v0.3.24 for @naija-shop/naira...
Packing files with `npm pack`...
Linting...
Errors:
1. pkg.exports["."].types should be the first in the object as conditions are order-sensitive so it can be resolved by TypeScript.
2. pkg.exports["."].default should be the last in the object so it doesn't take precedence over the keys following it.
3. pkg.exports["."].types is ./dist/index.d.ts but the file is not published. Is it specified in pkg.files?

On the fixed package it prints All good!.

3. Are the types wrong? (attw)

attw ("Are the Types Wrong?", run as npx @arethetypeswrong/cli) resolves the package the way each TypeScript module setting would, and compares the declaration file it finds with the JavaScript that would run. Here is the fixed package:

Terminal on your computer
npx @arethetypeswrong/cli --pack --no-emoji --no-color

@naija-shop/naira v1.0.1

Import failed to resolve to type declarations or JavaScript files. https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/NoResolution.md

A require call resolved to an ESM JavaScript file, which is an error in Node and some bundlers. CommonJS consumers will need to use a dynamic import. https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/CJSResolvesToESM.md


┌───────────────────┬───────────────────────────┬──────────────────────────────────┐
│                   │ "@naija-shop/naira"       │ "@naija-shop/naira/package.json" │
├───────────────────┼───────────────────────────┼──────────────────────────────────┤
│ node10            │ Resolution failed         │ OK (JSON)                        │
├───────────────────┼───────────────────────────┼──────────────────────────────────┤
│ node16 (from CJS) │ ESM (dynamic import only) │ OK (JSON)                        │
├───────────────────┼───────────────────────────┼──────────────────────────────────┤
│ node16 (from ESM) │ OK (ESM)                  │ OK (JSON)                        │
├───────────────────┼───────────────────────────┼──────────────────────────────────┤
│ bundler           │ OK                        │ OK (JSON)                        │
└───────────────────┴───────────────────────────┴──────────────────────────────────┘

Each row is a kind of consumer, and each column an entry point (the exported package.json is JSON, so it always resolves). ESM code on Node.js and bundlers get the right types. The two complaints need interpreting rather than obeying:

  • node10 is the resolver of old tools that do not understand exports at all. TypeScript 7 has removed that mode. If you must support such tools, add top-level "types" and "main" fields; otherwise ignore the row.
  • node16 (from CJS) says CommonJS code must use import(). That was true before Node.js could require ES modules. attw 0.18.5 bundles TypeScript 5.6, which predates that change, so the warning is out of date for a package that requires Node.js 24 (which the engines field says). The next section proves it.

For an ESM-only package, --profile esm-only ignores both rows and exits with 0. One more trap: run on the broken 1.0.0, attw printed This package does not contain types. and still exited with 0. In CI, publint's non-zero exit code is what stops that release.

NOTE

publint and attw are not part of npm. npx downloads them on first use (publint 0.3.24 and attw 0.18.5 here); in a real project, add them as dev dependencies so CI uses fixed versions.

4. Install the tarball in a real project

The final proof is the one from The npm ecosystem in depth: install the packed file into a separate project and use it.

Terminal on your computer
npm pack --silent
naija-shop-naira-1.0.1.tgz
cd ../shop-app
npm install ../naira/naija-shop-naira-1.0.1.tgz
…
npx tsc --noEmit
checkout.ts:4:35 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

4 console.log(`Total: ${formatNaira(priceFromForm)}`);
                                    ~~~~~~~~~~~~~


Found 1 error in checkout.ts:4

The same checkout.ts that got TS7016 from version 1.0.0 now gets a real type error: the types arrived. After fixing the call with parseNaira, it runs:

Terminal on your computer
node checkout.ts
Total: ₦2,500.00

ES modules, CommonJS and dual packages

The package is ESM-only: it ships only ES modules. For years, that shut out every CommonJS user, and libraries shipped two builds instead. Node.js 24 changes the calculation. A CommonJS file can require an ES module, and TypeScript understands it with module: "NodeNext". In a .cts file (always CommonJS), write a plain require call and borrow the types with typeof import(…):

legacy.ctsNode.js only
const naira: typeof import("@naija-shop/naira") = require("@naija-shop/naira");

console.log(naira.formatNaira(125050), naira.parseNaira("₦99.99"));
Terminal on your computer
npx tsc --noEmit
node legacy.cts
₦1,250.50 9999

A CommonJS module loaded the ES module package, with full types. (TypeScript also has the form import naira = require("@naija-shop/naira"), which verbatimModuleSyntax accepts in .cts files. It is not erasable syntax, though: node legacy.cts rejects it with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, so prefer the require call.)

This works because the exports entry uses "default", which matches require as well as import. Many packages write "import" instead. Watch what that does:

package.json
{
  "name": "@naija-shop/naira",
  "version": "1.0.1",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}
Terminal on your computer
npx tsc --noEmit
node legacy.cts
node:internal/modules/cjs/loader:679
    throw e;
    ^

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in /home/you/naira/package.json imported from /home/you/naira/legacy.cts
…

The type check still passes, because the "types" condition matches every kind of import, require included. Node.js finds no condition for require and refuses. Any CommonJS loader behaves the same way, including createRequire inside an ES module:

probe.tsNode.js only
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
try {
  const naira = require("@naija-shop/naira") as typeof import("@naija-shop/naira");
  console.log(naira.formatNaira(125050));
} catch (error) {
  console.log((error as NodeJS.ErrnoException).code);
}
Output of npx tsx probe.ts
ERR_PACKAGE_PATH_NOT_EXPORTED

TypeScript was satisfied and Node.js was not: exactly the "types and runtime disagree" problem that attw exists to catch. Use "default" for the JavaScript in an ESM-only package.

Dual packages and the dual-package hazard

If you must support Node.js versions that cannot require ES modules, or tools that insist on CommonJS, you publish a dual package: two builds of the same code, one ESM and one CommonJS, selected by the "import" and "require" conditions. Here is a payments package with one error class:

src/errors.ts
export class PaymentError extends Error {
  readonly code: string;

  constructor(code: string, message: string) {
    super(message);
    this.name = "PaymentError";
    this.code = code;
  }
}

export function isPaymentError(error: unknown): error is PaymentError {
  return error instanceof PaymentError;
}

The CommonJS build needs its own settings. In TypeScript 7 that means "module": "CommonJS" with "moduleResolution": "bundler" (the old node10 resolution was removed), and verbatimModuleSyntax off, because it would refuse export in a CommonJS output:

tsconfig.cjs.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "rootDir": "src",
    "outDir": "cjs",
    "declaration": true,
    "module": "CommonJS",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": false
  },
  "include": ["src"]
}

Building twice, once with the normal build settings into esm/ and once with tsc -p tsconfig.cjs.json into cjs/, gives two different JavaScript files:

esm/errors.js
export class PaymentError extends Error {
    code;
    constructor(code, message) {
        super(message);
        this.name = "PaymentError";
        this.code = code;
    }
}
export function isPaymentError(error) {
    return error instanceof PaymentError;
}
cjs/errors.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PaymentError = void 0;
exports.isPaymentError = isPaymentError;
class PaymentError extends Error {
    code;
    constructor(code, message) {
        super(message);
        this.name = "PaymentError";
        this.code = code;
    }
}
exports.PaymentError = PaymentError;
function isPaymentError(error) {
    return error instanceof PaymentError;
}

and two declaration files with the same text:

esm/errors.d.ts
export declare class PaymentError extends Error {
    readonly code: string;
    constructor(code: string, message: string);
}
export declare function isPaymentError(error: unknown): error is PaymentError;
cjs/errors.d.ts
export declare class PaymentError extends Error {
    readonly code: string;
    constructor(code: string, message: string);
}
export declare function isPaymentError(error: unknown): error is PaymentError;

The package is "type": "module", so a tiny cjs/package.json tells Node.js and TypeScript that everything in cjs/ is CommonJS, and the exports map gives each condition its own types and JavaScript:

cjs/package.json
{ "type": "commonjs" }
package.json
{
  "name": "@naija-shop/payments",
  "version": "2.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": { "types": "./esm/errors.d.ts", "default": "./esm/errors.js" },
      "require": { "types": "./cjs/errors.d.ts", "default": "./cjs/errors.js" }
    }
  },
  "files": ["esm", "cjs"]
}

Now imagine an application that imports the package, while one of its older dependencies requires it. Both builds get loaded, and there are two PaymentError classes in memory:

hazard.tsNode.js only
import { createRequire } from "node:module";
import { PaymentError, isPaymentError } from "@naija-shop/payments";

const require = createRequire(import.meta.url);
const legacy = require("@naija-shop/payments") as typeof import("@naija-shop/payments");

const declined = new legacy.PaymentError("card_declined", "Card declined");

console.log(declined instanceof PaymentError, isPaymentError(declined));
console.log(legacy.isPaymentError(declined), declined.name, declined.code);
Output of npx tsx hazard.ts
false false
true PaymentError card_declined

This is the dual-package hazard. The error was created by the CommonJS copy, so the ESM copy's instanceof and type guard both say no, and a catch block that handles PaymentError lets a declined card through as an unknown error. TypeScript cannot help: both copies have identical declarations, so to the compiler they are the same class. Anything stateful is affected the same way: a registry, a cache, a singleton, a Symbol created with Symbol().

The defences, in order of preference:

  1. Ship ESM only, with a "default" condition, and require Node.js 20.19 or later. One copy, no hazard. This is what the ZudoJS packages do.
  2. If you need a CommonJS entry, make it a thin wrapper that requires the ESM build, so both conditions share one copy.
  3. If two copies are unavoidable, keep state in one of them, and check errors by a stable property (error.name, a code, or a Symbol.for brand) rather than by instanceof.

attw checks that each condition gets matching declarations. Give both conditions the ESM declarations (a common shortcut, with one top-level "types") and it reports the CommonJS side as "Masquerading as ESM":

Terminal on your computer
npx @arethetypeswrong/cli --pack --no-emoji --no-color
…
Import resolved to an ESM type declaration file, but a CommonJS JavaScript file. https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseESM.md
…
│ node16 (from CJS) │ Masquerading as ESM    │
├───────────────────┼────────────────────────┤
│ node16 (from ESM) │ OK (ESM)               │

With the nested map above, that row reads OK (CJS).

Your declarations are part of your API

A user's code compiles against your .d.ts files. Three consequences follow.

Types your declarations mention must be installable

If dist/index.d.ts says import type { Logger } from "@zudojs/logger", every user's compiler must be able to find @zudojs/logger. A package mentioned in your published declarations belongs in dependencies (or peerDependencies), even if your JavaScript only uses it as a type. If it sits in devDependencies, your tests pass and your users get "Cannot find module". The same goes for @types/node when your declarations mention Buffer or IncomingMessage.

Internal types leak

Every exported function's parameter and return types appear in the .d.ts, including helper types you never meant to publish. Name them deliberately, export the ones users need (so they can write const options: FormatOptions), and keep everything else unexported.

Type changes follow semantic versioning

Semantic versioning (The npm ecosystem in depth) says a major version is needed when existing users' code can break. For a TypeScript package, "break" includes "no longer compiles". Suppose version 1.1.0 adds a locale option and, to make users choose, makes it required:

naira.d.ts
export interface FormatOptions {
  symbol?: boolean;
  locale: "en-NG" | "en-US";
}
export declare function formatNaira(kobo: number, options?: FormatOptions): string;
export declare function parseNaira(text: string): number | null;
dashboard.ts
import { formatNaira, type FormatOptions } from "./naira.js";

const noSymbol: FormatOptions = { symbol: false };
console.log(formatNaira(250_000, noSymbol));
What npx tsc --noEmit prints
dashboard.ts:3:7 - error TS2741: Property 'locale' is missing in type '{ symbol: false; }' but required in type 'FormatOptions'.

3 const noSymbol: FormatOptions = { symbol: false };
        ~~~~~~~~

  naira.d.ts:3:3 - 'locale' is declared here.
    3   locale: "en-NG" | "en-US";
        ~~~~~~


Found 1 error in dashboard.ts:3

No JavaScript behaviour changed for this user, yet their build is red after a minor update. That is a breaking change, and it needs version 2.0.0, or better, a design that keeps locale optional with a default. A few rules of thumb:

Change to the published typesRelease
Fix a declaration that was plainly wrong, with no working code affectedPatch
Add an export, an overload, or an optional property to an options objectMinor
Accept a wider parameter type (number becomes number | bigint, backed by code)Minor
Add a required property to an input type, narrow a parameter, remove or rename an exportMajor
Widen a return type (string becomes string | null): every caller must now handle the new caseMajor
Raise the minimum TypeScript version your declarations need (newer syntax in the .d.ts)Major for most libraries; say it in the changelog

Adding a property to an output type is safe for callers, but breaks users who implement your interface (for example a fake in their tests), because their object now lacks the property. Think about both directions whenever an exported interface changes.

Releasing

With the checks in place, a release is short. npm publish --dry-run runs the prepublishOnly script and shows what would be sent, without sending it:

Terminal on your computer
npm publish --dry-run

> @naija-shop/naira@1.0.1 prepublishOnly
> npm run check && npm run build


> @naija-shop/naira@1.0.1 check
> tsc --noEmit


> @naija-shop/naira@1.0.1 build
> tsc -p tsconfig.build.json

npm notice Tarball Contents
…
npm notice total files: 6
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access (dry-run)
+ @naija-shop/naira@1.0.1

For the first real publish of a scoped public package, add --access public. In CI, publish with provenance, as described in The npm ecosystem in depth. For many packages in one repository, Changesets automates version numbers and changelogs; the ZudoJS packages are released that way, with pnpm publish so that "workspace:*" dependencies are rewritten to real version ranges (the subject of TypeScript monorepos).

Testing a package

  • Unit tests run against src, like any other code (Testing TypeScript).
  • Entry-point tests import the package by its own name, as consumer.ts did above, so that the exports map and the built dist are exercised. Run them after the build.
  • Type tests pin the public types: @ts-expect-error lines, or expectTypeOf, that fail if an export's type changes by accident. They are your early warning for an unplanned major version.
  • A tarball smoke test in CI: npm pack, install the .tgz into a scratch project, run tsc --noEmit and one small script there. Add publint and attw --profile esm-only (or the profile that matches what you support) to the same job.

In production

  • Ship ESM only unless you have a named consumer who cannot use it. Set engines.node to what you test.
  • Put "types" first and "default" last in every exports entry, and list only the subpaths you will support for a whole major version.
  • Use a files allow-list, and read npm pack --dry-run before each release. Never rely on .npmignore alone.
  • Build from clean in prepublishOnly or CI, with noEmitOnError, so stale files in dist are never uploaded. (Deleting dist first also removes files for sources you have deleted.)
  • Publish declaration maps and the source if you want "go to definition" to be useful; publish .js.map files so users' stack traces can point into your code.
  • Treat type changes as API changes. A red build after npm update is a broken promise, whatever the JavaScript does.

Practice

TRY IT YOURSELF

Add a subpath export

Users of the invoicing service only need the parser. Move parseNaira into src/parse.ts, export it as the subpath @naija-shop/naira/parse with its own types, and show that importing the subpath works.

Show a solution
src/parse.ts
/** Reads text such as "₦1,250.50" into kobo, or returns null if it is not an amount. */
export function parseNaira(text: string): number | null {
  const cleaned = text.replace(/[₦,\s]/g, "");
  if (!/^-?\d+(\.\d{1,2})?$/.test(cleaned)) return null;
  return Math.round(Number(cleaned) * 100);
}
dist/parse.js
/** Reads text such as "₦1,250.50" into kobo, or returns null if it is not an amount. */
export function parseNaira(text) {
    const cleaned = text.replace(/[₦,\s]/g, "");
    if (!/^-?\d+(\.\d{1,2})?$/.test(cleaned))
        return null;
    return Math.round(Number(cleaned) * 100);
}
dist/parse.d.ts
/** Reads text such as "₦1,250.50" into kobo, or returns null if it is not an amount. */
export declare function parseNaira(text: string): number | null;
package.json
{
  "name": "@naija-shop/naira",
  "version": "1.1.0",
  "type": "module",
  "exports": {
    "./parse": {
      "types": "./dist/parse.d.ts",
      "default": "./dist/parse.js"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist", "src"]
}
invoice.tsNode.js only
import { parseNaira } from "@naija-shop/naira/parse";

console.log(parseNaira("₦45,000"), parseNaira("forty-five"));
Output of npx tsx invoice.ts
4500000 null

Each subpath has its own types/default pair, in that order. (This solution shows only the new entry; in the real package, keep "." as well, with src/index.ts re-exporting parseNaira from ./parse.js.) Adding a subpath is a minor release; removing one later would be a major one.

TRY IT YOURSELF

Review a package.json

A colleague asks you to review this before the first publish. List every problem, and say how a user would notice each one.

{
  "name": "@naija-shop/receipts",
  "version": "1.0.0",
  "type": "module",
  "main": "src/index.ts",
  "exports": {
    ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }
  },
  "devDependencies": { "@zudojs/logger": "^1.0.0", "typescript": "^7.0.0" }
}
Show a solution
  • No files field: everything in the folder is published, including tests and any .env. Users would not notice; attackers might. Add "files": ["dist", "src"].
  • "types" after "import": TypeScript matches "import" first and never reads "types". It works by luck while index.d.ts sits beside index.js; publint reports it. Put "types" first.
  • "import" instead of "default": CommonJS users get ERR_PACKAGE_PATH_NOT_EXPORTED, while their type check passes.
  • "main": "src/index.ts": tools that read main would try to load TypeScript from node_modules. Remove it or point it at dist/index.js.
  • @zudojs/logger in devDependencies: if the published declarations mention its types (or the code imports it), users get "Cannot find module '@zudojs/logger'". It belongs in dependencies.
  • No build step before publishing: add build and prepublishOnly scripts, and engines, license and description while you are there.

TRY IT YOURSELF

Patch, minor or major?

For each change to @naija-shop/naira's published types, choose the release type: (a) add an optional decimals?: 0 | 2 to FormatOptions; (b) change parseNaira's return type from number | null to number | null | undefined; (c) rename the exported interface FormatOptions to NairaFormatOptions; (d) fix a JSDoc comment; (e) let formatNaira accept number | bigint, with code that handles both.

Show a solution
  • (a) Minor: existing calls still compile; new callers get a new ability.
  • (b) Major: the return type got wider, so code that checked === null and then used the number now fails to compile ('kobo' is possibly 'undefined').
  • (c) Major, because users who wrote import type { FormatOptions } break. Or keep both names for a while (export type FormatOptions = NairaFormatOptions, marked @deprecated) and make it minor.
  • (d) Patch: documentation only.
  • (e) Minor: a wider parameter accepts everything it accepted before. It is only safe because the JavaScript really handles bigint, the lesson from Declaration files.

Recap

  • A TypeScript package ships compiled JavaScript, .d.ts files, and optionally maps and source. Build it with a separate tsconfig.build.json that extends the checking config and turns on declaration, maps and noEmitOnError.
  • In exports, conditions are read in order: "types" first, "default" last. Unlisted paths are private, in Node.js and in TypeScript.
  • files is an allow-list. Check the tarball, not the folder: npm pack --dry-run, publint, attw, and a real install of the .tgz.
  • On Node.js 24, require can load ES modules, so ESM-only with a "default" condition serves both. Dual builds create two copies of every class and every piece of state: the dual-package hazard.
  • Published declarations are API. Types they mention must be real dependencies, and type changes follow semver: a new required input field or a wider return type is a major release.

Next: TypeScript monorepos, where several packages like this one live and build together in one repository.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.