Type definitions for a JS library — strategies
Three strategies: (1) write the library in TypeScript and ship `.d.ts` from build — best for new code; (2) hand-author `.d.ts` alongside JS — pragmatic for existing JS libs; (3) DefinitelyTyped (`@types/foo`) — community-maintained, used when you don't control the library. Use `tsd` / `expect-type` for type tests; treat types as part of the API surface.
Why ship types
Untyped libraries are second-class citizens in TS projects. Editor autocomplete, refactors, and type checking all rely on declarations.
Strategy 1 — TypeScript source
Author the library in TypeScript; build emits both JS and .d.ts:
// package.json
{
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}Build with tsup / unbuild / TS compiler. Single source of truth.
Strategy 2 — hand-authored .d.ts
For existing JS libraries you don't want to rewrite:
src/
index.js
index.d.ts// index.d.ts
export interface Options { timeout?: number; retries?: number; }
export function load(url: string, opts?: Options): Promise<Response>;Pragmatic; risk is types drifting from JS. Mitigate with type tests.
Strategy 3 — DefinitelyTyped
When you don't control the library: @types/lodash etc. Maintained by the community on the DefinitelyTyped repo.
Per-subpath types (exports map)
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./button": { "types": "./dist/button/index.d.ts", "default": "./dist/button/index.js" },
"./hooks/*": { "types": "./dist/hooks/*.d.ts", "default": "./dist/hooks/*.js" }
}Each subpath has its own types entry. Modern bundlers and TS moduleResolution: "Bundler" or "NodeNext" honor this.
Type tests
Types are an API surface; test them:
// types.test-d.ts (with tsd)
import { load } from "../src";
import { expectType, expectError } from "tsd";
expectType<Promise<Response>>(load("/x"));
expectError(load(123)); // bad arg typeOr expect-type for in-codebase type assertions:
expectTypeOf(load).parameters.toEqualTypeOf<[string, Options?]>();CI runs the type tests so type changes can't slip in unnoticed.
Stability
A type change is a semver-relevant change:
- Narrowing return type, widening accepted parameters → minor.
- Widening return type, narrowing accepted parameters → MAJOR (consumers break).
- Adding new exports → minor.
- Removing/renaming exports → major.
Common pitfalls
anyeverywhere — defeats the point.- Hand-authored
.d.tsout of sync with JS. - Missing exports map so subpath imports lack types.
- Default vs namespace exports confusion in CJS interop.
- CJS + ESM dual publish types mismatches — use conditional exports carefully.
Generics + DX
For a library with rich shapes:
function defineSchema<T extends Record<string, Type>>(shape: T): Schema<T> { ... }Infer through generics so consumers don't manually type. Test that inference works with expectTypeOf.
Interview framing
"Three strategies: author the library in TypeScript and emit .d.ts (best for new libraries — single source of truth), hand-author .d.ts alongside JS (pragmatic for existing untyped libs), or DefinitelyTyped (@types/foo) for third-party libs you don't control. The exports map in package.json must include types per subpath so modern resolvers pick up declarations. Treat types as part of the API surface — test them with tsd or expect-type in CI. A narrowing return type or widening required parameter is a breaking change deserving a major bump. Avoid any and CJS/ESM dual-publish type drift."
Follow-up questions
- •Compare authored TS vs hand-authored .d.ts.
- •Why does a type narrowing count as breaking?
- •How do exports maps interact with TS resolution?
Common mistakes
- •any everywhere.
- •No type tests.
- •Missing exports map.
- •Hand-authored .d.ts drift.
Performance considerations
- •Generated .d.ts can balloon — use `isolatedDeclarations` or strip internals.
Edge cases
- •CJS + ESM dual publish.
- •React JSX namespace types for component libraries.
- •Conditional exports for dev vs prod.
Real-world examples
- •Radix UI ships its own types; React types from DefinitelyTyped; Zod ships types from TS source.