Writing robust .d.ts for libraries

TypeScript · Expert · 18 min

Your types are part of your public API; broken or loose declarations hurt every user and are hard to change later.

If your library is written in TypeScript, let tsc emit declarations (declaration: true) and check the published result. When you hand-write .d.ts for JavaScript code, or need to review generated ones, these rules matter.

Wire up `package.json` correctly. Modern resolvers use "exports". Put the types condition first in each branch, and give ESM and CommonJS entry points their own declarations, because a .d.ts is interpreted as ESM or CJS according to its extension and the package type:

"exports": {
  ".": {
    "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
    "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
  }
}

A CommonJS module that assigns module.exports = fn is typed with export = fn, not export default. The Are The Types Wrong tool (@arethetypeswrong/cli) checks these mismatches against every resolution mode.

API design rules (from the official do's and don'ts):

  • Use string, number, object, never String, Number, Object or Function.
  • Return void from callbacks whose result you ignore; don't make callback parameters optional just because callers may ignore them.
  • Order overloads specific-first; don't write overloads that differ only in trailing parameters.
  • Prefer unknown to any for values users must inspect, and generics to any for values that pass through.

Compatibility. Every change to a declaration can break users: adding a required property to an input type, narrowing a return type, or bumping the minimum TypeScript version needed by new syntax. Treat type changes under semver, and if you depend on another package's types in your public API, list its @types package as a dependency, not a devDependency.

Test the types. Use tsd, expect-type or @ts-expect-error assertions in a test project compiled against the built output.

Example

// index.d.ts: hand-written declarations for a small JavaScript library
export interface RetryOptions {
  /** Maximum attempts, including the first. Default 3. */
  retries?: number;
  /** Called before each retry; the return value is ignored. */
  onRetry?: (error: unknown, attempt: number) => void;
}

/** Calls fn until it resolves or attempts run out. */
export declare function retry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;

export declare function parse(input: string): Record<string, unknown>;
export declare function parse(input: string, reviver: (key: string, value: unknown) => unknown): unknown;

export declare class Cache<K, V> {
  constructor(maxSize: number);
  get(key: K): V | undefined;
  set(key: K, value: V): this;
  readonly size: number;
}

// For a CommonJS module that does `module.exports = createClient`:
// declare function createClient(url: string): Client;
// export = createClient;

Remember

  • Put types first in each exports branch; ship separate ESM/CJS declarations when dual-publishing
  • export = describes module.exports = ... in CommonJS
  • Follow the do's and don'ts: primitives, void callbacks, specific-first overloads
  • Test published types with tools like attw and expect-type

Watch out for

  • Publishing one .d.ts for both ESM and CJS entry points, which makes types resolve wrongly in one of the modes.

Check yourself

  1. A CommonJS library does module.exports = function connect() {...}. Which declaration is correct?
  2. Why should a callback type ignore its result with void rather than any?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html