Back to TypeScript
TypeScript
easy
mid

What are the strategies for publishing type definitions for a JavaScript library?

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.

4 min read·~10 min to think through

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:

json
// 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:

ts
src/
  index.js
  index.d.ts
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)

json
"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:

ts
// 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 type

Or expect-type for in-codebase type assertions:

ts
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

  • any everywhere — defeats the point.
  • Hand-authored .d.ts out 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:

ts
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.

Senior engineer discussion

Seniors gate type changes in CI with type tests, version them per semver, and curate the public surface vs internal types.

Related questions