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, neverString,Number,ObjectorFunction. - Return
voidfrom 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
unknowntoanyfor values users must inspect, and generics toanyfor 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
typesfirst in eachexportsbranch; ship separate ESM/CJS declarations when dual-publishing export =describesmodule.exports = ...in CommonJS- Follow the do's and don'ts: primitives,
voidcallbacks, specific-first overloads - Test published types with tools like attw and expect-type
Watch out for
- Publishing one
.d.tsfor both ESM and CJS entry points, which makes types resolve wrongly in one of the modes.