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.
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:
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:
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
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.
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:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"noEmit": true,
"verbatimModuleSyntax": true
}
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmitOnError": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
extendscopies every setting from the main file, sostrictand the module settings cannot drift apart between checking and building.rootDir/outDir:src/index.tsbecomesdist/index.js, notdist/src/index.js.declarationwrites the.d.tsfiles;declarationMapandsourceMapwrite the two kinds of.mapfile.noEmitOnError: a type error means nodistat all, so a broken build can never be published.excludekeeps test files out of the package.
npx tsc -p tsconfig.build.json ls dist index.d.ts index.d.ts.map index.js index.js.map
/** 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
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:
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:
- Which files must be in the tarball for an
importto run? For the types to work? For "go to definition" to reach the source? Which files must never be in it? - Which import paths do you promise to keep working? What happens to a user who imports
@naija-shop/naira/dist/index.jsdirectly, if you later rename that file? - Who might load the package with
requireinstead ofimport? What Node.js versions do they run? - When TypeScript and Node.js both read the same
exportsmap, can they disagree about which file an import means?
Show the reasoning
- Running needs
dist/index.js. Types needdist/index.d.ts. "Go to definition" needs the.d.ts.mapplussrc/index.ts; stack traces through the package need the.js.map. Tests,tsconfigfiles,.envfiles and build caches must stay out. An allow-list infilesis the only safe way to get this right. - Only the paths you list in
exports. If deep imports are possible, someone will use them, and your internal file names become public API. Anexportsmap makes every unlisted path fail, in Node.js and in TypeScript. - Older services written in CommonJS, and tools that still
requiretheir plugins. On Node.js 24 (and 22.12+, 20.19+),requirecan load an ES module without top-levelawait. The package should say which Node.js versions it supports. - 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
{
"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.jsfiles indistare ES modules, and the.d.tsfiles 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 bothimportandrequirereach 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 getERR_PACKAGE_PATH_NOT_EXPORTED."files"is the allow-list. A!pattern removes matches again, here the tests. npm always addspackage.json, the README and the licence file."prepublishOnly"runs automatically beforenpm publish: the check and a fresh build, so what you upload is never an old or brokendist.
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:
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));
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.
import { parseNaira } from "@naija-shop/naira/dist/index.js";
console.log(parseNaira("₦5"));
npx tsc --noEmit printsdeep.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:1Refused, 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?
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:
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:
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
exportsat 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 couldrequireES 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 theenginesfield 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.
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:
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(…):
const naira: typeof import("@naija-shop/naira") = require("@naija-shop/naira");
console.log(naira.formatNaira(125050), naira.parseNaira("₦99.99"));
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:
{
"name": "@naija-shop/naira",
"version": "1.0.1",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
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:
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);
}
npx tsx probe.tsERR_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:
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:
{
"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:
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;
}
"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:
export declare class PaymentError extends Error {
readonly code: string;
constructor(code: string, message: string);
}
export declare function isPaymentError(error: unknown): error is PaymentError;
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:
{ "type": "commonjs" }
{
"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:
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);
npx tsx hazard.tsfalse 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:
- 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. - If you need a CommonJS entry, make it a thin wrapper that
requires the ESM build, so both conditions share one copy. - If two copies are unavoidable, keep state in one of them, and check errors by a stable property (
error.name, acode, or aSymbol.forbrand) rather than byinstanceof.
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":
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:
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;
import { formatNaira, type FormatOptions } from "./naira.js";
const noSymbol: FormatOptions = { symbol: false };
console.log(formatNaira(250_000, noSymbol));
npx tsc --noEmit printsdashboard.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:3No 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 types | Release |
|---|---|
| Fix a declaration that was plainly wrong, with no working code affected | Patch |
| Add an export, an overload, or an optional property to an options object | Minor |
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 export | Major |
Widen a return type (string becomes string | null): every caller must now handle the new case | Major |
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:
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.tsdid above, so that theexportsmap and the builtdistare exercised. Run them after the build. - Type tests pin the public types:
@ts-expect-errorlines, orexpectTypeOf, 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.tgzinto a scratch project, runtsc --noEmitand one small script there. Addpublintandattw --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.nodeto what you test. - Put
"types"first and"default"last in everyexportsentry, and list only the subpaths you will support for a whole major version. - Use a
filesallow-list, and readnpm pack --dry-runbefore each release. Never rely on.npmignorealone. - Build from clean in
prepublishOnlyor CI, withnoEmitOnError, so stale files indistare never uploaded. (Deletingdistfirst 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.mapfiles so users' stack traces can point into your code. - Treat type changes as API changes. A red build after
npm updateis 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
/** 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);
}
/** 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);
}
/** 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;
{
"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"]
}
import { parseNaira } from "@naija-shop/naira/parse";
console.log(parseNaira("₦45,000"), parseNaira("forty-five"));
npx tsx invoice.ts4500000 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
filesfield: 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 whileindex.d.tssits besideindex.js; publint reports it. Put"types"first."import"instead of"default": CommonJS users getERR_PACKAGE_PATH_NOT_EXPORTED, while their type check passes."main": "src/index.ts": tools that readmainwould try to load TypeScript fromnode_modules. Remove it or point it atdist/index.js.@zudojs/loggerindevDependencies: if the published declarations mention its types (or the code imports it), users get "Cannot find module '@zudojs/logger'". It belongs independencies.- No build step before publishing: add
buildandprepublishOnlyscripts, andengines,licenseanddescriptionwhile 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
=== nulland 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.tsfiles, and optionally maps and source. Build it with a separatetsconfig.build.jsonthat extends the checking config and turns ondeclaration, maps andnoEmitOnError. - In
exports, conditions are read in order:"types"first,"default"last. Unlisted paths are private, in Node.js and in TypeScript. filesis 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,
requirecan 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.