Designing and publishing a library
A library is code that other people build on. Once it is published, their code depends on how it behaves, and changing that behaviour can break them without a single line of their code changing. So designing a library means deciding on purpose what it promises, and telling its users clearly when they use it wrongly. Version numbers tell them when a promise changes. Publishing is how the promise reaches them.
1. What a library promises
1.1 The public API
An API, application programming interface, is the part of your code that other code can use. For a library, it is everything a user's code can come to rely on:
| Part | What users rely on |
|---|---|
| Exports | the names they import, and the paths in the exports map they import them from |
| Signatures | which arguments each function takes, in which order, and what it returns |
| Types | the declarations TypeScript checks their code against |
| Behaviour | what each function does with valid input, and what it does with invalid input |
| Errors | what it throws or rejects with, when, and how to tell the cases apart |
| Defaults | the value of every option they don't pass |
| Timing | whether a result arrives straight away or later |
| Environment | the Node versions, module formats and versions of peer packages it works with |
Every row is a promise. Code written against it works for as long as the promise holds. A change to any one of them can make a user's code stop working without that code changing at all, and that is what a breaking change means.
1.2 The promise is bigger than the documentation
Hyrum Wright, then a software engineer at Google, put it in one sentence, now known as Hyrum's law:
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
Users depend on what they can observe, not on what you wrote down: the order results come back in, the exact text of an error message, an empty array returned for bad input where an error should have been. Two consequences shape the rest of this:
- Decide what is observable, on purpose. Few exports, clear behaviour for bad input, errors and defaults written down.
- Fix surprises early. Below version 1.0.0, semver lets a library change anything in any release. npm's users still expect the second number to warn them of a breaking change (5.1), but the version they are on is understood to be unsettled. That is the cheapest moment there will ever be to change your mind.
1.3 Keep the surface small
Every export is something you will have to keep. Adding one later breaks nobody. Removing one breaks everybody who imported it. A helper that isn't exported can be renamed, merged or deleted in any release, because no user can reach it. The same goes for paths in the exports map: each one is a file somebody may import for as long as the package exists.
So start with the smallest set of exports that does the job.
is-promise is a single function, and in 2020 it sat deep in the dependency trees of create-react-app and Angular's command-line tool. Version 2.2.0, a minor release, added an exports map whose targets lacked the leading ./. On every Node version that enforced exports, 12.16 and later, every import and require of the package failed:
Error [ERR_INVALID_PACKAGE_TARGET]: Invalid "exports" main target "index.js" defined in the package config …/node_modules/is-promise/package.json; targets must start with "./"
2.2.2, three hours later, took exports out again. The function never changed. Its package.json was part of the API too. When the author added exports again, correctly this time, it was in 4.0.0, a major version.
2. Designing functions
2.1 Positional parameters, and one options object
Positional parameters are for what the function works on. Everything else goes in one options object.
- Positional: values every call passes, whose meaning is obvious from the function's name. The URL in
fetchJson(url). The items and the callback inmap(items, fn). - The options object: settings, flags, anything optional, and anything you might add later.
Compare the same call written both ways:
fetchJson("/api/users", 3, 5000, true);
fetchJson("/api/users", { retries: 3, timeoutMs: 5000, cache: true });
The options object wins for four reasons:
- The call says what each value means.
3, 5000, truecan't be read without the documentation. - Optional settings can be left out in any combination. To pass only
cachepositionally, the call becomesfetchJson(url, undefined, undefined, true). - Adding an option later breaks nobody. A new key is ignored by every call that doesn't use it. A new positional parameter goes at the end of a list that is already too long, and one inserted in the middle shifts every argument after it.
- Defaults sit in one place, the parameter list.
The function receives the object and takes it apart with destructuring, giving each option its default:
function fetchJson(url, { retries = 3, timeoutMs = 5000 } = {}) {
return { url, retries, timeoutMs };
}
console.log(fetchJson("/api/users")); // ?
console.log(fetchJson("/api/users", { timeoutMs: 1000 })); // ?
Output
{ url: '/api/users', retries: 3, timeoutMs: 5000 }
{ url: '/api/users', retries: 3, timeoutMs: 1000 }
The = {} at the end is the default for the whole object. Without it, a call with no options fails before the function body starts, because there is nothing to take apart:
TypeError: Cannot read properties of undefined (reading 'retries')
= {} only covers an argument that is left out. A caller who passes null gets the same error with null in it.
The web platform made exactly this change. addEventListener(type, listener, useCapture) once took a boolean as its third argument. When browsers needed once and passive, and later signal, a boolean couldn't hold them, so the third argument became an options object: addEventListener("scroll", onScroll, { passive: true, signal }). The boolean still works, because removing it would break the web. fetch(url, { method, headers, body, signal }) has the same shape.
In TypeScript, the options are an interface of their own, with every optional property marked ?:
interface FetchOptions {
retries?: number | undefined;
timeoutMs?: number | undefined;
signal?: AbortSignal | undefined;
}
function fetchJson(url: string, options: FetchOptions = {}) {
// ...
}
A setting that every call needs but whose meaning isn't obvious can still go in the object, as a required property. mapLimit(items, fn, { limit: 4 }) says what the 4 is. mapLimit(items, 4, fn) doesn't.
An options object has one weakness: a misspelt key, { retires: 5 }, is silently ignored, and the default quietly applies. TypeScript catches it in an object literal. For callers without TypeScript, only the library itself can notice.
2.2 Defaults are part of the promise
A default is what everybody who leaves an option out gets. They never wrote the value down, so they don't know they depend on it, until it changes. Three rules follow:
- Choose defaults that are safe for a caller who never read the documentation.
- Write each default next to its option, in the documentation and in the type declarations, where editors show it.
- Treat a new default as a breaking change. Changing
retriesfrom 3 to 5 makes every caller who relied on 3 send more requests and wait longer to hear about a failure.
Sometimes the safe choice is no default at all. A library that runs work concurrently can't know how much concurrency the other end can take, so a required limit is better than a default that guesses.
localhostUp to version 16, Node looked up localhost and used its IPv4 (Internet Protocol version 4) address, 127.0.0.1, first. Version 17 used the operating system's order instead, which on many machines put the IPv6 address, ::1, first. Development tools that connected to localhost started failing with connect ECONNREFUSED ::1:3000, because the server they were talking to only listened on 127.0.0.1. It was announced, in a new major version, and it still broke a great many setups. A default is the setting nobody chose.
2.3 Ask for what you use, return one shape
A parameter's type should say what the function needs, decided by what the API is for. The result should have the same shape every time.
Deciding the inputs is the API designer's job: what the function is for, and how its users will call it. What goes wrong is an input narrowed by accident, by a detail of the implementation that nobody chose. A function that only loops over its input needs something it can loop over. Written with .length or .reduce, it demands an array, and turns away a Set or a generator for no reason. Written with for...of, it takes any iterable, anything such a loop can walk:
function sum(values) {
let total = 0;
for (const v of values) total += v;
return total;
}
function* evens() {
yield 2;
yield 4;
}
console.log(sum([1, 2, 3]), sum(new Set([1, 2, 3])), sum(evens()), sum(new Map([["a", 1]]).values())); // ?
Output
6 6 6 1
If a function really does need indexed access, or the length before it starts, then an array is what it needs, and its type should say so. That is a decision. The other is an accident.
On the way out, one shape:
- Not sometimes a list and sometimes
null. An empty list says "nothing found" without a second case for every caller to handle. - Not sometimes a value and sometimes a promise. Every caller would need two ways of handling the result.
- Not the caller's own input, changed. Return a new array rather than sorting or filling the one you were given.
2.4 Never sometimes synchronous
A function that delivers its result through a callback must always call it before it returns, or always after, never one or the other depending on the situation. And a function that returns a promise must always return a promise.
The rule is older than promises. Isaac Schlueter, the author of npm, wrote it down in 2013, in a post called "Designing APIs for Asynchrony", building on an essay by Havoc Pennington from 2011. Calling back straight away isn't the problem: forEach always does, and nobody minds. Calling back later isn't the problem either: setTimeout always does. The problem is a function that does either, because the code after the call can't know whether the callback has already run. Schlueter called that "releasing Zalgo", after an internet joke about a creature that brings chaos.
Promises were designed to rule it out. Their specification requires that a .then callback never runs before the code that attached it has finished, even when the value is already there, and the code after an await follows the same rule. So an async function can't release Zalgo.
Where it still happens today is a function with a fast path that skips the promise:
const cache = new Map();
function getUser(id) {
if (cache.has(id)) return cache.get(id); // a user
return fetchUser(id).then((user) => {
// a promise of a user
cache.set(id, user);
return user;
});
}
await getUser(1) works either way, because await accepts plain values. getUser(1).then(show) works until the cache is warm, and then fails with TypeError: getUser(...).then is not a function. Its TypeScript return type, User | Promise<User>, hands the same problem to every caller. Declaring it async fixes both: the cached user comes back as a promise like the fetched one.
Callback and event APIs need the same care. Node's own documentation explains why its APIs defer a callback with process.nextTick even when the answer is known at once.
2.5 Keep state with the caller
A variable at the top level of a library module exists once per program, shared by everybody who imports the library. On a server, that means shared by every request from every user. Anything that holds data on a caller's behalf belongs in an object the caller creates.
A small caching helper, with its cache at the top of the module:
const cache = new Map();
function cached(key, load) {
if (!cache.has(key)) cache.set(key, load());
return cache.get(key);
}
// settings.js — somebody's code
const settings = cached("config", () => ({ theme: "dark" }));
// user.js — somebody else's code, in the same program
const user = cached("config", () => ({ name: "Ada" }));
console.log(settings); // ?
console.log(user); // ?
Output
{ theme: 'dark' }
{ theme: 'dark' }
Two pieces of code that have never heard of each other picked the same obvious key, and the user code got the settings. The fix is a function that makes a cache, so that each caller has its own:
function createCache() {
const cache = new Map();
return function cached(key, load) {
if (!cache.has(key)) cache.set(key, load());
return cache.get(key);
};
}
const settingsCache = createCache();
const userCache = createCache();
console.log(settingsCache("config", () => ({ theme: "dark" }))); // ?
console.log(userCache("config", () => ({ name: "Ada" }))); // ?
Output
{ theme: 'dark' }
{ name: 'Ada' }
Libraries you already use are built this way. TanStack Query keeps its cache in a QueryClient that your application creates with new QueryClient(), not in a variable inside the library. DataLoader, a library for batching database lookups on a server, tells its users to create a new instance for every request, so that one user's cached data can never appear in another user's response.
3. Feedback to the caller
A library can't see the code that calls it. All it can do for a caller who uses it wrongly is tell them: an error, a warning, a message. Good feedback says what is wrong and what was expected, reaches the developer before it reaches users, and is no louder than the problem.
3.1 Check arguments at the door
Check the arguments of every exported function at its start, before it does any work. A function that trusts its arguments doesn't fail where the mistake was made. It returns something wrong, and the failure shows up later, somewhere else. A helper that runs an async function over a list, at most limit calls at a time, and trusts what it is given:
async function mapLimit(items, fn, { limit } = {}) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const double = async (x) => x * 2;
console.log(await mapLimit([1, 2, 3], double, { limit: 2 })); // ?
console.log(await mapLimit([1, 2, 3], double, { limit: 0 })); // ?
console.log(await mapLimit([1, 2, 3], double, { limit: NaN })); // ?
console.log(await mapLimit(new Set([1, 2]), double, { limit: 2 })); // ?
Output
[ 2, 4, 6 ]
[ <3 empty items> ]
[ <3 empty items> ]
[ undefined ]
A limit of 0 or NaN starts no workers, and the empty results array comes back as it is. A forgotten limit does the same. A Set has no length, so new Array(undefined) makes a one-item array, and again no worker starts. Nothing fails. The caller finds out later, from a page with an empty table.
The first two are mistakes in the call, and checks at the top turn them into errors that name the mistake. The Set is not a mistake at all: the function was written with .length and indexes, and turned away an iterable for no reason (2.3). It does need indexed access, so it makes its own array from whatever iterable it is given, and rejects only what can't be iterated:
function checkIterable(name, value) {
if (typeof value?.[Symbol.iterator] !== "function") {
throw new TypeError(`${name} must be iterable, got ${value === null ? "null" : typeof value}`);
}
}
function checkFunction(name, value) {
if (typeof value !== "function") {
throw new TypeError(`${name} must be a function, got ${typeof value}`);
}
}
function checkCount(name, value) {
if (!Number.isInteger(value) || value < 1) {
throw new RangeError(`${name} must be a whole number of 1 or more, got ${value}`);
}
}
async function mapLimit(items, fn, { limit } = {}) {
checkIterable("items", items);
checkFunction("fn", fn);
checkCount("limit", limit);
const list = Array.from(items);
const results = new Array(list.length);
let next = 0;
async function worker() {
while (next < list.length) {
const i = next++;
results[i] = await fn(list[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
return results;
}
The same four calls now give:
[ 2, 4, 6 ]
RangeError: limit must be a whole number of 1 or more, got 0
RangeError: limit must be a whole number of 1 or more, got NaN
[ 2, 4 ]
and a forgotten limit gives RangeError: limit must be a whole number of 1 or more, got undefined.
A useful message says what was expected and what arrived, so that the caller can fix the call without opening your source. The error's type says what kind of mistake it is: TypeError for the wrong kind of value, such as a number where a function belongs, RangeError for the right kind outside the sensible values, such as a count of zero.
Adding a check to a published function is itself a change of behaviour: whoever passed NaN and got an empty table now gets an error. One more reason to add them before 1.0.
In a function that returns a promise, a failed check must reject, not throw. Its callers handle failures through the promise, so that is where every failure has to arrive. An async function does that by itself. The trap is a function with no reason to be async, because it only returns another promise:
const getUser = (id) => {
if (!id) throw new TypeError("id is required");
return fetch(`/api/users/${id}`).then((res) => res.json());
};
getUser("").catch(…) never sees that error. It is thrown during the call, before any promise exists, and the program stops. Marking the function async fixes it. The web platform's own guide for promise-based APIs says they "should never synchronously throw errors", and typescript-eslint's promise-function-async rule requires every function that returns a promise to be async.
3.2 Throw, warn, or carry on
Match the response to the consequence. An error stops whatever the caller was doing, and in a web page that can mean a blank screen. Sometimes that is right, and sometimes a warning says enough:
- Throw when carrying on would produce a wrong result, lose data or hide a bug.
- Warn when the call still works but is probably a mistake, or will stop working: a deprecated function, an unknown option, a suspicious value.
- Carry on silently only when the input is genuinely fine.
A misspelt option is a case for a warning. The call works, with the default, but the caller almost certainly meant something else:
const KNOWN_OPTIONS = new Set(["retries", "timeoutMs", "signal"]);
const warnedKeys = new Set();
function fetchJson(url, options = {}) {
for (const key of Object.keys(options)) {
if (!KNOWN_OPTIONS.has(key) && !warnedKeys.has(key)) {
warnedKeys.add(key);
console.warn(`fetchJson: unknown option "${key}"`);
}
}
const { retries = 3, timeoutMs = 5000, signal } = options;
// ...
}
fetchJson("/api/users", { retires: 5 }); // fetchJson: unknown option "retires"
fetchJson("/api/posts", { retires: 5 }); // nothing: already said
React works this way. A list rendered without key props still renders, with a warning. When Next.js 15 turned params and cookies() into promises, code using them the old way kept working for a while, with a warning, instead of breaking on the day of the upgrade.
Warn once for each cause, not on every call. A warning repeated a thousand times is a warning nobody reads. That is what warnedKeys is for. It sits at the top of the module, which 2.5 warns against, but it holds no caller's data: only which warnings this program has already shown.
3.3 Fail early, in development
Make mistakes show up on the developer's machine, not in production. A developer who sees a warning while writing the code can fix it in a minute. A visitor who meets the same mistake in production can do nothing about it. So a library can afford to be stricter and noisier in development: extra checks, warnings about likely mistakes, even deliberate extra work that exposes bugs. React's StrictMode does this in development. It renders every component twice, so that a component with side effects in its body shows them, and runs every effect's setup an extra time, set up, clean up and set up again, so that an effect with a missing cleanup misbehaves on your machine rather than in front of users. That is where the doubled console.log output comes from.
In production, warnings go, because nobody can act on them and they cost bytes and time. Checks that prevent wrong results, such as the argument checks above, stay.
How a library knows which one it is in. The convention every major bundler supports is process.env.NODE_ENV:
if (process.env.NODE_ENV !== "production") {
warnAboutUnknownOptions(options);
}
Who turns that into true or false matters:
- Not the library's own build. Vite's library mode leaves
process.envuntouched, so the check is still there in the published files. It does replaceimport.meta.env, which is why a library shouldn't useimport.meta.env.DEVfor this: the result would be fixed at the moment you built the library, and bundlers other than Vite don't define it at all. - The application's bundler, in every file it bundles,
node_modulesincluded: webpack from itsmode, Vite from the command that is running, esbuild for browser builds. In a production build the condition becomes"production" !== "production", always false, and the bundler removes the whole block. - Nobody, without a bundler. In Node,
NODE_ENVis a real environment variable, unset until somebody sets it, so the check counts as development until a server is started withNODE_ENV=production. In a browser loading the file directly,processdoesn't exist and the check throws, so a library meant for that has to testtypeof process !== "undefined"first, or ship separate files.
React's own package is built around this. Its entry file is:
"use strict";
if (process.env.NODE_ENV === "production") {
module.exports = require("./cjs/react.production.js");
} else {
module.exports = require("./cjs/react.development.js");
}
Two complete builds, one full of warnings and one without, and an entry file that picks one. The application's bundler replaces the condition, keeps one require and drops the other, so only one build ships. Vue's build for bundlers instead keeps its development branches inline, behind process.env.NODE_ENV checks that, as its documentation puts it, "must be replaced by bundler", and ships separate development and production files for browsers without one.
Two other approaches exist. A package can export different files under the development and production export conditions, as the small esm-env package does, and Vite picks the right one according to NODE_ENV. Or the library can simply take an option, such as { debug: true }, which works everywhere and leaves the decision to the caller.
3.4 Checking arguments, and validating data
Two different jobs are both called validation.
Checking the arguments to your own functions catches bugs in the calling code. A few lines are often enough, and TypeScript already rejects most wrong arguments before anything runs. The runtime checks are for the callers TypeScript can't see: plain JavaScript, and values typed any.
Validating data from outside the program is a different problem. A request body, a JSON response, an environment variable, a config file or a form field can contain anything, and TypeScript can't check what doesn't exist until the program runs. A type written over it, (await res.json()) as User, is only a hope. That is the job of a schema library, such as Zod, Valibot or ArkType. Each describes the expected shape once, and gives both a check that runs and the TypeScript type that matches it:
import { z } from "zod";
const User = z.object({
name: z.string().min(1),
age: z.number().int().nonnegative().optional(),
});
type User = z.infer<typeof User>; // the TypeScript type, derived from the schema
const res = await fetch("/api/users/1");
const user = User.parse(await res.json()); // a User, or a ZodError
parse returns the data, typed as User, or throws a ZodError. safeParse returns { success: true, data } or { success: false, error } instead of throwing, for data that is expected to be wrong now and then, such as form input. Type and check come from one definition, so they can't drift apart.
One problem, or all of them. For a function call with a few arguments, throwing at the first wrong one is enough: there is one mistake, at one line. For a whole set of data, such as a form, a config object or a request body, it is better to collect every problem and report them together, so that they can all be fixed in one go instead of one per attempt. That is why a ZodError doesn't stop at the first problem: it carries a list of them.
How a library validates its own input is the author's choice: checks by hand, or a schema library used inside the package. What matters is that the feedback is clear.
A library whose job is to validate its users' data with its users' schemas, such as a form library or an RPC library (remote procedure call: calling a function on the server as if it were local), has one more thing to consider. If it requires Zod, every application using it must use Zod, and one that already uses another validator ends up with two. Standard Schema avoids that: a small interface, written by the authors of Zod, Valibot and ArkType and implemented by all three, as a property named ~standard. A library written against it accepts a schema from any of them:
import type { StandardSchemaV1 } from "@standard-schema/spec";
export async function validate<S extends StandardSchemaV1>(schema: S, input: unknown) {
const result = await schema["~standard"].validate(input);
if (result.issues) throw new ValidationError(result.issues); // an error class of your own
return result.value as StandardSchemaV1.InferOutput<S>;
}
@standard-schema/spec contains only types, so the library gains no runtime code, and every application keeps its own validator. TanStack Form and oRPC are among the libraries that accept schemas this way.
3.5 Errors a caller can act on
Code that catches an error has to decide what to do: retry, show a message, give up. To decide, it needs four things from the error:
- What kind of failure it is, in a form a program can compare: a
code, a fixed string such as"ERR_RETRY_EXHAUSTED". - A readable name and message for logs and stack traces:
nameandmessage. - What caused it: the original error, as
cause, with its own stack trace. - The facts: any data the caller needs, such as how many attempts were made, as fields of their own.
So: throw an instance of a class of your own, with a name, a code, the original error as cause, and any data as fields.
class RetryError extends Error {
constructor(errors) {
super(`all ${errors.length} attempts failed`, { cause: errors.at(-1) });
this.name = "RetryError";
this.code = "ERR_RETRY_EXHAUSTED";
this.errors = errors;
}
}
const err = new RetryError([new Error("timeout"), new Error("timeout"), new Error("server is down")]);
console.log(String(err)); // ?
console.log(err.code, err.errors.length, err.cause.message); // ?
Output
RetryError: all 3 attempts failed
ERR_RETRY_EXHAUSTED 3 server is down
nameis printed in front of the message, in logs and stack traces. A subclass doesn't set it by itself: withoutthis.name, the error prints as a plainError.codeis for programs. A message is for people and will be reworded. A code, once published, doesn't change. Node works this way throughout:ENOENTfor a missing file,ERR_MODULE_NOT_FOUND,ERR_INVALID_PACKAGE_TARGET.causeis part of the language:new Error(message, { cause })sets it, and Node and browsers' developer tools print the whole chain, ending with[cause]: Error: server is down.- The data fields are whatever the caller needs to act. Zod's
ZodErroris a good model: besides its message, it carriesissues, an array with one entry per problem, each with thepathto the bad field and a message of its own, so that a form can show each message next to the right input.
Callers should check err.code === "ERR_RETRY_EXHAUSTED", not err instanceof RetryError. instanceof asks whether the error was made by one particular class object, and a program can contain several copies of the same class. npm installs a second copy of a package whenever two ranges for it have no version in common, as here, for an application on async-kit 0.3 that also uses a package built against 0.2:
app2@1.0.0 …
├── async-kit@0.3.0
└─┬ fetch-json-retry@1.0.0
└── async-kit@0.2.0
An error thrown inside fetch-json-retry comes from its copy's class, and instanceof against the application's copy says false. A package's ES module and CommonJS builds loaded side by side cause the same, and so do iframes and workers. A code is a string, and every copy writes the same one. Say so in the error's documentation.
3.6 Bugs and failures
The errors a library produces come in two kinds, and a caller treats them differently:
- Programmer errors are bugs in the calling code: a limit of 0, a number where a function belongs. No retry fixes them. Throw them as
TypeErrororRangeError, before doing any work, and never wrap them in anything that suggests trying again. - Operational errors are the world failing: a network that is down, a server that is overloaded, a file that isn't there. They are expected, and worth handling: retried, reported, shown to a user.
3.7 Changing an error is a breaking change
Which error a function throws is as much a part of the API as what it returns. Take a retry helper that gives up after its last attempt. It could reject with the last error unchanged, as p-retry does, with an AggregateError holding all of them, as Promise.any does, or with an error class of its own. Each is reasonable. Switching from one to another breaks callers, and quietly: a caller's check, err.name === "TimeoutError", still compiles and runs. It just returns false, and the error goes down the wrong branch.
A class of your own leaves the most room to grow, because a later version can add a field, such as elapsedMs, without breaking anybody.
3.8 Write the contract down
Callers can't handle what they don't know about. The README gets a table, one row per kind of failure. For a fetchJson helper:
| Rejects with | When | Worth retrying? |
|---|---|---|
TypeError | an argument is the wrong kind of value | no: a bug in the caller |
RangeError | retries or timeoutMs is out of range | no: a bug in the caller |
signal.reason | the caller's signal was aborted | no: somebody cancelled |
HttpError, code: "ERR_HTTP_STATUS", status | the server answered with an error status | for 5xx and 429, yes. For other 4xx, no |
RetryError, code: "ERR_RETRY_EXHAUSTED" | every attempt failed | perhaps later: it already tried |
The last column is the one callers need most and libraries most often leave out.
4. Types are part of the API
4.1 The declaration file is the documentation people read
Few people open a README after installing a package. They read what their editor shows when they hover over a function, and that comes from the declaration file. **A comment written as /** … \*/directly above an export is copied into the.d.ts**, so that is where documentation belongs:
export interface FetchOptions {
/** Attempts after the first. @defaultValue 3 */
retries?: number | undefined;
/** Time allowed for each attempt, in milliseconds. @defaultValue 5000 */
timeoutMs?: number | undefined;
/** Cancels the request, and any attempts still to come. */
signal?: AbortSignal | undefined;
}
/**
* Fetches `url` and parses the response as JSON, retrying failed attempts.
*
* @param url - the address to fetch
* @throws {RangeError} if `retries` is negative
*
* @example
* const users = await fetchJson("/api/users", { retries: 5 });
*/
export async function fetchJson(url: string, options: FetchOptions = {}): Promise<unknown> {
Each option is documented on its own property, with its default, because that is what the editor shows while the caller types the key into the object (2.2). The tags come from JSDoc and its TypeScript dialect, TSDoc: @param, @returns, @throws, @example, @defaultValue, and {@link name} for a link to another export. One more changes how editors draw your code. A function marked
/**
* @deprecated Use {@link createCache}, which gives each caller its own cache.
*/
is drawn with a line through its name wherever it is used, and TypeScript reports it as a suggestion: 'cached' is deprecated. It doesn't fail anybody's build. It is feedback in the place where the caller is already looking.
4.2 Type changes that break callers
Accepting more and returning less is safe. Accepting less or returning more breaks callers, even when the JavaScript underneath behaves exactly the same:
| Change in the declarations | For callers |
|---|---|
a parameter accepts more: string → string | number | safe |
a parameter accepts less: string | number → string | breaks |
a function returns less: string | undefined → string | safe |
a function returns more: string → string | undefined | breaks |
| a new optional option | safe |
| an option becomes required | breaks |
| an exported type is renamed or removed | breaks, though nothing changed at runtime |
an optional property loses its | undefined | breaks every caller using exactOptionalPropertyTypes |
"Returning more" includes a new member in a union: a result type that gains a third status breaks every switch that handled the two it had. The last row is why optional options are written signal?: AbortSignal | undefined: under exactOptionalPropertyTypes, an optional property may be left out, but can't be set to undefined unless its type says so.
Types show the shape of an API, not its behaviour. A new default, a new input check or a different error never appears in a declaration file. Those changes are found by tests, by knowing the code, and by writing them in the changelog when they are made. Large libraries add one more safeguard: a tool such as API Extractor writes the public surface to an "API report" file that is committed with the code, so that every change to the API shows up in code review.
5. Version numbers
5.1 Three numbers, three promises
Semantic versioning, or semver, is the convention npm and almost every JavaScript package follow. A version is MAJOR.MINOR.PATCH, and which number you raise tells users what kind of change to expect:
| From → to | Raised | Means |
|---|---|---|
| 1.4.0 → 1.4.1 | patch | something was fixed, and nothing else changed |
| 1.4.0 → 1.5.0 | minor | something was added, and existing code keeps working |
| 1.4.0 → 2.0.0 | major | something existing code relied on has changed |
Three more rules from the specification, at semver.org, matter as much:
- A published version never changes. A fix is a new version.
- 0.y.z is initial development. Anything may change at any time.
- 1.0.0 declares the public API. From there on, the table above applies.
npm adds one rule for 0.x versions: the caret treats the first non-zero number as the major. ^0.2.0 allows 0.2.5, not 0.3.0. So a 0.x library uses its second number for breaking changes and its third for everything else.
5.2 How npm reads a range
npm install doesn't write a version into package.json. It writes a range: npm install react installs 19.3.1 and writes "react": "^19.3.1", meaning 19.3.1 or anything newer below 20.0.0. The semver package, which npm itself uses, can show which versions a range allows:
npx semver --range "^1.4.0" 1.3.9 1.4.0 1.9.3 2.0.0 # ?
npx semver --range "~1.4.0" 1.4.0 1.4.9 1.5.0 # ?
npx semver --range "^0.2.0" 0.2.0 0.2.7 0.3.0 1.0.0 # ?
Output
1.4.0
1.9.3
1.4.0
1.4.9
0.2.0
0.2.7
| Range | Allows | Written by |
|---|---|---|
^1.4.0 | 1.4.0 up to, not including, 2.0.0 | npm install, by default |
^0.2.0 | 0.2.0 up to, not including, 0.3.0 | npm install, for 0.x packages |
~1.4.0 | 1.4.0 up to, not including, 1.5.0: patches only | you, by hand |
1.4.0 | exactly 1.4.0 | npm install --save-exact |
>=1.2.0 <1.5.0 | exactly what it says | you, by hand |
Pre-releases, versions with a hyphen such as 0.3.0-beta.0, stay out of ranges, unless the range itself names a pre-release of the same version. That lets people try what is coming by asking for it, without anybody else getting it by accident:
npx semver --range "^1.4.0" 1.4.1 1.4.44-liberty-2 # ?
npx semver --range "^0.3.0-beta.0" 0.3.0-beta.0 0.3.0-beta.1 0.3.0 0.4.0-beta.0 # ?
Output
1.4.1
0.3.0-beta.0
0.3.0-beta.1
0.3.0
5.3 What the lockfile decides
A range says which versions are allowed. package-lock.json records which ones were actually installed: the exact version of every package in node_modules, with a hash of each.
npm installinstalls what the lockfile says, as long as it still fitspackage.json, and rewrites it when it doesn't.npm ci(clean install) installs exactly the lockfile, and refuses if the two files disagree. That is the command for a build server or a deployment.npm outdatedshows the range at work:
Package Current Wanted Latest Location Depended by
date-fns 3.0.0 3.6.0 4.4.0 node_modules/date-fns c2
react 18.2.0 18.3.1 19.3.1 node_modules/react c2
Current is installed, Wanted is the newest the range allows, Latest is what the authors marked as the version to install. npm update goes to Wanted and never across a major.
For a library author, one fact matters most: npm never puts package-lock.json into a published package. Your users get whatever your ranges allow on the day they install. Your lockfile protects only you.
Two consequences follow:
- Your tests run against versions your users may never get. Have the build server also run them once after a fresh install that ignores the lockfile, against the newest versions your ranges allow. That is what a user installing today receives.
- The one lockfile that is published,
npm-shrinkwrap.json, is not for libraries. It has the same format, and it pins every user to your exact versions. npm's documentation strongly discourages it for libraries, because it takes away the application's control over updates to the packages below yours. It is meant for command-line tools installed on their own.
^1.4.0 means "whatever the author publishes next, below 2.0.0, without asking me". In January 2022, the author of colors, a package for coloured terminal output, deliberately published 1.4.1 with an endless loop in it, and every fresh install on ^1.4.0 got it. In September 2025, an attacker phished the maintainer of chalk and debug and published malicious patch versions of 18 of his packages, downloaded more than two billion times a week between them. They were removed within about two hours. In both cases, projects that installed from a committed lockfile with npm ci never received the bad versions.
5.4 What counts as breaking
More changes break users than the word suggests:
| Change | Breaking? |
|---|---|
| removing or renaming an export | yes |
adding an exports map, or removing a path from one | yes: other import paths stop working |
| dropping the CommonJS build | yes, for everyone who requires it |
| raising the oldest supported Node version | yes, for everyone on the older one |
| checking input that used to be accepted | yes, for whoever passed it |
| a different default | yes, for everyone who relied on it |
| a different error thrown | yes, and silently |
| a function that returned a value now returns a promise | yes, for everyone who used the value |
| supporting only a newer major of a peer dependency | yes, for applications still on the old one |
| a new required peer dependency | yes, for applications that don't have it |
| a parameter type that accepts less, a return type that returns more | yes, for TypeScript callers |
| a new export, a new optional option | no |
| a new warning | no, unless a test fails on console output |
| a clearer error message | no, unless somebody matched on the text |
| running faster | no, unless somebody depended on the timing |
The last three rows are Hyrum's law again. A version number states what you meant to change.
5.5 Deprecate before you remove
Don't remove part of the API in the same release that replaces it. Deprecate it in one release, and remove it in the next breaking one, so users get a warning before they get an error. There are three places to say it:
-
In the types, with
@deprecated, so editors strike the name through. -
At runtime, with a warning printed the first time it is used. Once, because a warning on every call floods a log until people learn to ignore it:
const sharedCache = createCache();
let warned = false;
export function cached(key, load) {
if (!warned) {
warned = true;
console.warn("cached() is deprecated and will be removed in 2.0.0. Use createCache().");
}
return sharedCache(key, load);
}The shared cache at the top of the module is deliberate. It is the problem 2.5 describes, but a deprecated function has to keep behaving exactly as it did until it is removed, and fixing it would be a breaking change of its own.
Node does the same for its own deprecations, through
process.emitWarningwith a code such asDEP0005. A library that also runs in browsers can't rely onprocessexisting, and usesconsole.warn. -
On the registry, for versions already published, with
npm deprecate:npm deprecate async-kit@"<0.2.0" "returns empty slots for a limit of 0. Upgrade to 0.2.0 or later"From then on, everybody who installs those versions sees the message:
npm warn deprecated async-kit@0.1.0: returns empty slots for a limit of 0. Upgrade to 0.2.0 or later
You have probably seen one of these on your own installs:
npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
Nothing was removed. The old version still installs and works as well as it ever did. Its author used the one channel that reaches every user.
5.6 Writing down what changed
A changelog, CHANGELOG.md, tells users what changed in each version, newest first, in words meant for them. The Keep a Changelog format, at keepachangelog.com, groups entries under Added, Changed, Deprecated, Removed, Fixed and Security.
It can be written by hand, or generated, if every commit message says what kind of change it is. Conventional Commits is a format for commit messages that does exactly that:
type(scope)!: what changed
BREAKING CHANGE: what callers have to do differently.
fixasks for a patch release,featfor a minor one.!after the type, or aBREAKING CHANGE:line, asks for a major one.docs,chore,refactor,testand other types say what kind of work it was, and are left out of the changelog.(scope)is optional: which part of the code changed.
commit-and-tag-version is a tool that reads the commits since the last version's git tag, chooses the next version from their types, writes the changelog, updates package.json, commits and tags. Run on async-kit 0.1.0, whose commits since then include the breaking input checks:
npx commit-and-tag-version
√ bumping version in package.json from 0.1.0 to 0.2.0
√ bumping version in package-lock.json from 0.1.0 to 0.2.0
√ created CHANGELOG.md
√ outputting changes to CHANGELOG.md
√ committing package-lock.json and package.json and CHANGELOG.md
√ tagging release v0.2.0
i Run `git push --follow-tags origin main && npm publish` to publish
Below 1.0, it moves everything down one place, as npm's caret expects: a breaking change raises the second number, and a feat only the third. With only feat and fix commits, the same run would have made 0.1.1.
The tool only counts what you told it. A commit that adds an input check is easy to label fix:, and in a 1.x library that makes a patch:
√ bumping version in package.json from 1.4.0 to 1.4.1
But a new check breaks whoever was passing that input. With fix!: and a BREAKING CHANGE: line, the same commit makes a major:
√ bumping version in package.json from 1.4.0 to 2.0.0
A tool can turn commit types into a version number. Deciding what a change breaks is still the author's job, done at the moment of writing the commit message, when the author knows most about the change. npm's own npm version minor does the bump, the commit and the tag without a changelog.
Changesets, the usual choice in repositories that hold several packages, works the other way round: with each change, the author adds a small file saying which packages it affects, whether it is a patch, minor or major change, and the line for the changelog, and at release time the tool collects those files into version numbers and changelogs.
6. Publishing
6.1 What publishing does
npm publish packs the package into a tarball, a compressed archive of exactly the files npm pack lists, and uploads it with its package.json to a registry, a server that stores packages and hands them out by name and version. From then on:
- The version is permanent. It can't be overwritten. On npmjs.com, its number can never be used again, even if it is removed. Other registries, Verdaccio among them, let a removed number be published again, and then one version number means two different things: anybody who installed the old contents keeps them, and a lockfile that recorded them no longer matches what the registry hands out.
- It is installable by anybody who can reach the registry, with the ranges and lockfiles described above.
- The
latesttag points at it, unless you say otherwise, which makes it what a plainnpm install <name>gets. Pre-releases and fixes to older versions need a tag of their own (6.7).
npmjs.com is the public registry. GitLab and GitHub run registries for each project, companies run private ones, and you can run one on your own machine.
6.2 Try it before anybody else does
There are four ways to install a package of your own, each closer to what users will get:
| Way | Command | It tests |
|---|---|---|
| a folder | npm install ../async-kit | your folder, as it is right now |
| a tarball | npm pack, then npm install ../async-kit/async-kit-0.2.0.tgz | exactly the files that would be published |
| a registry of your own | npm publish to a local registry | versions, ranges, tags, deprecation |
| a shared registry | npm publish to GitLab or npmjs.com | the real thing |
A folder install is quick, but npm puts a symbolic link to your folder in node_modules, a file that points to another place on disk, so nothing that packing does is tested. A files field that names the wrong folder, "files": ["build"], still works through the link:
[ 2, 4 ]
and fails from the tarball, which contains only package.json:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/node_modules/async-kit/dist/index.js' imported from …/main.js
npm link is a folder install by another route, and hides the same mistakes.
6.3 What package.json says to users
Beyond exports and files, publishing adds the fields users and registries read:
| Field | Why it is there |
|---|---|
description, keywords | what registries show and search |
license | a licence's short name from SPDX, the Software Package Data Exchange list. Without one, nobody but the author may legally use, copy or change the code |
repository | where the source is. Registries link to it, generated changelogs build their links from it |
engines | the Node versions you support |
dependencies | what users get installed with your package, as ranges |
peerDependencies | what your package needs the application to provide, and in which versions |
devDependencies | what you need to build and test it. Users never get any of it |
publishConfig | settings for npm publish only, such as which registry to send it to |
README.md and LICENSE are always included in the package. CHANGELOG.md is included only if files lists it.
engines is a statement, not a lock. npm warns and installs anyway, unless --engine-strict is set:
npm warn EBADENGINE Unsupported engine {
npm warn EBADENGINE package: 'async-kit@0.2.0',
npm warn EBADENGINE required: { node: '>=26' },
npm warn EBADENGINE current: { node: 'v24.21.0', npm: '11.21.0' }
npm warn EBADENGINE }
A peer dependency is for something your library works with but must not bring its own copy of. A React component library can't ship its own React, because two copies of React in one application break every hook. So it lists the versions it supports, "react": "^18.0.0 || ^19.0.0", and lets the application provide one. If the application's version isn't in that range, npm refuses to install:
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: c4@1.0.0
npm error Found: react@19.3.1
npm error
npm error Could not resolve dependency:
npm error peer react@"^16.8.5 || ^17.0.0 || ^18.0.0" from react-beautiful-dnd@13.1.1
--legacy-peer-deps, which that error suggests, doesn't make the two compatible. It tells npm to stop checking, and npm ls still marks the result invalid.
6.4 What runs when you publish
npm runs scripts with special names at fixed points. With each one printing its own name:
$ npm pack --dry-run
[prepack]
[prepare]
[postpack]
$ npm publish
[prepublishOnly]
[prepack]
[prepare]
[postpack]
[publish]
[postpublish]
prepackruns before every pack and publish. Put the build there, and a tarball can never contain an old one.prepublishOnlyruns before a publish only, and first. Put checks there: types now, tests later.preparealso runs after a plainnpm installin the package's own folder, and when somebody installs it straight from git, which npm 12 only does when the installing project has allowed both git dependencies and that package's scripts. Keep it light.
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit",
"prepack": "npm run build",
"prepublishOnly": "npm run typecheck"
}
npm publish --dry-run runs all of it and uploads nothing: the last look before the real thing. All of it includes publish and postpublish, so a postpublish script that pushes git tags or announces the release really does it, during a dry run, for a version that doesn't exist.
6.5 A registry of your own
Publishing is permanent, so practise on a registry nobody else uses. Verdaccio is a registry server you run yourself. It speaks the same protocol as npmjs.com, so npm talks to it the same way. Its configuration says, for each package name, who may read and publish it, and whether to fetch it from npmjs.com:
# A registry on your own machine, for practising publishing.
# Start it in this folder with: npx verdaccio --config ./verdaccio.yaml
storage: ./storage # published packages are kept here
auth:
htpasswd:
file: ./htpasswd # accounts made with `npm adduser`
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
# Ours. Never looked up on npmjs.com, even though
# packages with the same names may exist there.
"async-kit":
access: $all
publish: $authenticated
"fetch-json-retry":
access: $all
publish: $authenticated
# Everything else is fetched from npmjs.com and kept here.
"**":
access: $all
publish: $authenticated
proxy: npmjs
log: { type: stdout, format: pretty, level: warn }
The whole workflow, in four steps:
-
Start it, in a terminal of its own:
npx verdaccio --config ./verdaccio.yaml. It listens on http://localhost:4873/, and a browser shows what it holds. -
Make an account on it:
npm adduser --registry http://localhost:4873/. npm keeps the login as a token in.npmrcin your home folder, on a line for that address only://localhost:4873/:_authToken=…. -
Point the library at it, in its
package.json, and publish:"publishConfig": {
"registry": "http://localhost:4873/"
}npm notice Publishing to http://localhost:4873/ with tag latest and default access
+ async-kit@0.1.0With this field,
npm publishin that folder can't send the package to npmjs.com by accident. Other commands don't read it:npm viewin the same folder still asks npmjs.com. -
Point an application at it, with an
.npmrcnext to itspackage.json:registry=http://localhost:4873/Everything that application installs now comes through Verdaccio, which fetches from npmjs.com whatever it doesn't hold itself.
6.6 Names and scopes
Package names on a registry are first come, first served. On npmjs.com, async-kit has belonged to somebody else since 2014:
async-kit@2.2.4 | MIT | deps: 2 | versions: 55
A simple and powerful async abstraction lib for easily writing Node.js code.
That is why the Verdaccio configuration lists our names explicitly. A registry that looked up every name on npmjs.com as well would hand out the stranger's 2.2.4 to anybody installing async-kit, because it is the higher version. This is an attack with a name, dependency confusion: in 2021, the security researcher Alex Birsan published packages under the names of companies' private packages, with higher versions, and the build systems of more than 35 companies, among them Apple and Microsoft, installed his.
A scope prevents it, but only on a registry where you own it. A scoped name, @uni-id/async-kit, has two parts: the scope, @uni-id, and the package's own name. On npmjs.com, a scope is a user or organisation, and nobody else can publish under it. One .npmrc line can send a whole scope to one registry:
@uni-id:registry=https://gitlab.proxy.itcollege.ee/api/v4/projects/<project-id>/packages/npm/
Owning a scope on one registry says nothing about another, and registering it on the public one is your job. If @uni-id exists only in GitLab, or only in your company's private registry, the name is still free on npmjs.com. Anybody can register it there and publish @uni-id/async-kit, with a version number higher than yours. This is scope hijacking, dependency confusion one level up, and every install that asks npmjs.com for your scope gets the stranger's package:
- a machine or build job whose
.npmrclacks the scope line, or a project that was cloned without it - a mistyped project ID or registry address in the scope line
- a package or version that isn't in your GitLab project, because GitLab passes requests for packages it doesn't hold on to npmjs.com, unless a group owner turns that off
The .npmrc line only routes requests. The only thing that stops the stranger is owning the name: create the npmjs.com user or organisation with your scope's name, even if you never publish anything public under it. After Birsan's report, that is what companies did with their internal scopes.
6.7 Tags and pre-releases
npm install <name> installs whatever the dist-tag latest points to, not the highest version. A dist-tag is a name that points at one version. Every npm publish moves latest, unless told otherwise, so a pre-release needs a tag of its own:
npm publish --tag next
npm view async-kit dist-tags --registry http://localhost:4873/
{ latest: '0.2.0', next: '0.3.0-beta.0' }
Whoever wants it asks by tag, npm install async-kit@next. Everybody else still gets 0.2.0.
npm 11, which comes with Node 24, refuses to publish a pre-release without --tag:
npm error You must specify a tag using --tag when publishing a prerelease version.
npm 10, which comes with Node 22, doesn't. It publishes the beta as latest, and the next application to run npm install async-kit gets the beta and writes ^0.3.0-beta.0 into its package.json. The repair is npm dist-tag add async-kit@0.2.0 latest, and everybody who installed in between keeps the beta.
A fix to an older major needs a tag too. Publishing 1.4.2 after 2.0.0, with npm 10, moves latest back to 1.4.2, and every new install gets the old major. npm 11 refuses:
npm error Cannot implicitly apply the "latest" tag because previously published version 2.0.0 is higher than the new version 1.4.2. You must specify a tag using --tag.
So a maintenance release goes out under a tag of its own, such as npm publish --tag v1-maint. A tag can't look like a version range, so --tag v1 is refused.
6.8 When you have published a mistake
Publishing the same version again is refused:
npm error You cannot publish over the previously published versions: 0.2.0.
Removing it is restricted. npmjs.com allows npm unpublish within 72 hours, if no other public package depends on it. After that, only if nothing depends on it, it had fewer than 300 downloads in the last week, and it has a single owner. The rules exist because of left-pad: in 2016, its author unpublished it with all his other packages, and builds that depended on those 11 lines, several levels down, failed around the world until npm restored it.
So the normal repair is a new version with the fix, and npm deprecate on the bad one, with a message saying what to install instead.
6.9 GitLab's package registry
Every GitLab project has a package registry of its own. Packages published there appear under Deploy → Package registry, visible to everybody who can see the project. The address contains the project's numeric ID, shown on its overview page or under Copy project ID in the ⋮ menu:
https://gitlab.proxy.itcollege.ee/api/v4/projects/<project-id>/packages/npm/
-
Give the package a scoped name,
@uni-id/async-kit, with your own uni-id, so that one.npmrcline can route it. Then claim the same scope on npmjs.com, as a user or organisation, so that nobody else can publish under it there (6.6). -
Create a token that can publish and nothing else: in the project, Settings → Repository → Deploy tokens, with the scopes
read_package_registryandwrite_package_registry. A personal access token with theapiscope also works, but it can do everything your account can, and a token that leaks should be able to do as little as possible. -
Give npm the token, once per machine. This writes it to
.npmrcin your home folder, never into a project where it could be committed:npm config set -- //gitlab.proxy.itcollege.ee/api/v4/projects/<project-id>/packages/npm/:_authToken=<token> -
Put the address in
publishConfig, under the scope's key, because npm looks for a scope's own registry before the general one, and runnpm publish:"publishConfig": {
"@uni-id:registry": "https://gitlab.proxy.itcollege.ee/api/v4/projects/<project-id>/packages/npm/"
}
An application installs it with the same scope line in its own .npmrc, @uni-id:registry=…, and then npm install @uni-id/async-kit. Only @uni-id/… goes to GitLab. Everything else still comes from npmjs.com. GitHub Packages works the same way.
6.10 npmjs.com
The public registry adds a few steps:
- An account at npmjs.com, with two-factor authentication: a second step at login, beyond the password. A security key or passkey is safer than a code from an app, because a fake login page can ask for a code but can't use a key that is bound to the real site. Publishing a new package requires it.
npm login, which opens a browser to sign in, and leaves a session that expires after some hours.- A scoped name,
@your-npm-username/async-kit, because most unscoped names are taken. npm publish --access publicthe first time, because scoped packages are private by default, and private packages need a paid account.
Then check it as a stranger would: npm view, an install into an empty folder, and npx @arethetypeswrong/cli --from-npm @your-npm-username/async-kit against the published package. Without --from-npm, it looks for a file of that name.
6.11 Who can publish your package
A package is only as safe as the account and tokens that can publish it. Both large npm attacks of September 2025 used stolen credentials, not bad code. The second was Shai-Hulud, a worm: a program that spreads by itself. Its install script searched each machine for npm tokens and used them to publish infected versions of every package they could reach. GitHub, which runs npm, removed more than 500 packages.
What npm changed in response, from December 2025:
- Classic tokens are gone. All of them were revoked on 9 December 2025.
npm logingives a session that lasts two hours, and the granular tokens that remain are limited in scope, and in lifetime: 90 days at most for one that can publish. - Two-factor authentication is the default for new packages, and publishing from a login session requires it. Granular tokens for automation can still be set to skip it. From around January 2027, such tokens can no longer publish directly, only stage a version (below).
- Trusted publishing: instead of a stored token, npmjs.com is told "this package may be published by this workflow in this repository", and the build service proves who it is on each run with a short-lived OIDC (OpenID Connect) token. Nothing long-lived exists to steal. It works with GitHub Actions, GitLab.com's own build machines and CircleCI.
- Provenance: a package published that way carries a signed record of the commit and build that produced it, shown on its npmjs.com page and checked by
npm audit signatures. - Staged publishing, announced in May 2026 and generally available since August:
npm stage publishuploads a version that only becomes installable when a maintainer approves it with two-factor authentication. It needs npm 11.15 or later, and has worked for brand-new packages since October 2026.
Each of these closes a different door:
| Measure | What it stops |
|---|---|
| two-factor with a security key or passkey | phishing. The chalk maintainer used codes from an app, and the fake login page asked for one and got it. A key only answers the real site |
| trusted publishing | token theft. There is no long-lived token on any machine for a worm like Shai-Hulud to find |
| staged publishing | a stolen token or a compromised build publishing on its own. The most it can do is queue a version, and a maintainer has to approve it with two-factor authentication |
On the installing side, npm 12 (July 2026) no longer runs dependencies' install scripts unless the project allows each one, which closes the door Shai-Hulud came through. Node 24 still comes with npm 11, so this needs installing: npm install -g npm@12. GitLab pipelines have the same idea as trusted publishing built in: every job gets a CI_JOB_TOKEN, valid only while the job runs, that can publish to the project's own registry.
Take these 5 things from here
- Everything users can observe is part of the API, types and errors included. Decide it on purpose, keep it small, and fix surprises before 1.0.
- What a function works on goes by position. Everything else goes in one options object. Ask for what the function needs, return one shape, and be asynchronous always or never.
- Give the caller meaningful feedback, and let mistakes surface in development. Check at the door, throw when carrying on would be wrong, warn when it would only be unwise. Errors carry a
code, acauseand the facts. - The version number says what you meant to change. Ranges and lockfiles decide what users get. Deprecate before you remove, and decide yourself what a change breaks.
- A published version is permanent. Practise on a registry of your own, publish pre-releases under a tag, claim your scope on the public registry, and protect whatever can publish.
You should now be able to
- List what a library's API consists of, and explain Hyrum's law with an example.
- Decide which parameters of a function go by position and which in an options object, and explain why, including what
= {}does in the parameter list. - Choose defaults, and explain why changing one is a breaking change.
- Choose a parameter's type from what the function needs, and give a function one predictable return shape.
- Explain the rule "never sometimes synchronous", where it comes from, and fix a function that returns a value or a promise.
- Explain how module-level state in a library leaks between callers, and replace it with a factory.
- Validate arguments with
TypeErrorandRangeError, with messages that say what was expected and what arrived. - Explain why a function that returns a promise should report every failure as a rejection, and which callers a throw during the call breaks.
- Decide between throwing, warning and carrying on.
- Explain why a library is stricter in development than in production, how a
process.env.NODE_ENVcheck in a published library is resolved by the application's bundler, and how React's entry file uses it. - Tell checking a function's arguments from validating data that comes from outside, explain when to report every problem at once, what a schema library such as Zod adds, and when a library should accept any Standard Schema.
- Write an error class with
name,codeandcause, and explain whycodeworks across two copies of a package wheninstanceofdoesn't. - Tell programmer errors from operational errors, and write a library's error contract.
- Say which changes to a declaration file break TypeScript callers, and which changes no declaration file shows.
- Read a range, including for 0.x versions and pre-releases, and explain what the lockfile decides and why a library's lockfile never reaches its users.
- Classify a change as patch, minor or major, including changes to defaults, input checks, errors,
exportsandengines. - Deprecate an export in the types, at runtime and on the registry.
- Write Conventional Commits, generate a version and a changelog from them, and say what the tool can't decide.
- Publish to a registry of your own and to GitLab's, publish a pre-release or a maintenance release under a dist-tag, and recover from publishing the wrong thing.
- Explain why a scope used only in a private registry must also be claimed on the public one.
- Explain how npm credentials were attacked in 2025, and what two-factor authentication, trusted publishing and staged publishing each protect against.
Reading
Required
- Semantic Versioning 2.0.0 — the specification, short enough to read in full
- npm: About semantic versioning — how npm reads ranges
- npm: package.json — every field, including
files,engines,peerDependenciesandpublishConfig - npm: Scripts — which script runs when
- GitLab: npm packages in the package registry
Recommended
- Hyrum's law
- MDN:
addEventListeneroptions — the platform's own move from a boolean to an options object - Isaac Z. Schlueter, "Designing APIs for Asynchrony" — the Zalgo post, and Havoc Pennington, "Callbacks, synchronous and asynchronous", the essay it builds on
- W3C TAG, "Writing Promise-Using Specifications" — how the web platform designs promise-based APIs, including why they never throw synchronously
- typescript-eslint:
promise-function-async - React:
StrictMode— what React does twice in development, and why - Zod — the most widely used schema library, with
parse,safeParseandz.infer - Standard Schema — the shared interface, and how a library accepts it
- Keep a Changelog and Conventional Commits
- commit-and-tag-version and Changesets
- Semantic Versioning for TypeScript Types — what counts as breaking in a declaration file, worked out in detail
- API Extractor — the API report, for libraries large enough to need one
- Snyk, "Why did is-promise happen and what can we learn from it"
- npm blog, "kik, left-pad, and npm"
- Alex Birsan, "Dependency Confusion"
- npm: Unpublish policy
- npm: Trusted publishing
- Verdaccio