Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~24 min· topic 6 of 6

Topic 16.6

Designing Types for a Library

In one line

When other people call your code, your types are the user interface. Choose overloads or generics deliberately, make inference do the work, shape the error messages callers will read, retire old APIs with @deprecated, and keep declaration files fast with isolatedDeclarations.

Think of it like this

A vending machine is designed for strangers. Good ones have clear buttons, show the price, and say "exact change only" when you push the wrong coin. Nobody has to read a manual. A library's types are the same: callers should get the right answer from autocomplete, and when they press the wrong button the message should say what to do. You are designing for people who will never read your source.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Overload
One of several call signatures for a single function.
Overload resolution
The compiler tries each signature in order and uses the first that fits.
Public API
The names and types a library exports for other people to use.
Inference-friendly
Designed so that callers do not have to write type arguments, because the checker can work them out.
@deprecated
A JSDoc tag that marks something as outdated. Editors show a strikethrough.
Suggestion diagnostic
A hint from the language service that is not an error, like a deprecation or an unused variable.
isolatedDeclarations
A flag that makes every export self-describing, so declaration files can be built one file at a time.
Declaration file
.d.ts: only types, shipped with a library so users get autocomplete.

Step by step

01Pick the shape of the signature

Write the call sites first: how should parse be used? If each input kind gives a different output and the list is short, overloads read well. If callers hold unions, one generic signature with a conditional return is friendlier.

parse.tswhole filets
function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]): number | number[] {
  return typeof input === "string" ? Number(input) : input.map(Number);
}

02Tie parameters together with a key

In on<K extends keyof Events>(name: K, cb: (payload: Events[K]) => void), K is learned from name and then used for the callback. The caller writes only the event name and gets a fully typed callback.

Tie parameters together with a keydiagram
Rendering diagram…

03Say what to do in the error

If the caller breaks a rule you can't express as a plain type, resolve the parameter type to a short message string. The error then shows the message.

04Retire gently

Add /** @deprecated Use newApi instead. */ above the old function and keep it working. Editors strike it through, and a lint rule can warn in CI.

05Make declaration files cheap

Turn on isolatedDeclarations in the library's tsconfig. Fix each error by adding the annotation it asks for. The generated .d.ts is now a straight copy of your signatures.

Try it yourself

  1. 1

    Add a third overload

    In the first example, add an overload function parse(input: number): number; before the implementation. Does the implementation still need to change? Read the error: TS2394: This overload signature is not compatible with its implementation signature. It appears on the new overload until the implementation's parameter becomes string | string[] | number (and its body handles a number).

  2. 2

    Add an event

    In the second example, add save: { ok: boolean } to Events. Without changing on, call on("save", (p) => console.log(p.ok)) and hover p.

  3. 3

    Read your own message

    In the error-message example, change the message string in shout to something you would want to read at 5 pm on a Friday, and check the Types panel shows it.

Code & diagrams

Overloads or one generic signature

Overloads give each call its own precise result. The generic version accepts a union argument and distributes: a string | string[] gives number | number[]. Calling parse(pick()) would fail with the overloads: nothing matches a union (shown below as text, because TypeScript 6.0 and 7.0 word that error differently).

Console

(nothing was printed)

Expected console output

7 [ 1, 2 ] 7 [ 3 ]
An API that infers its callback from the event name

With on, hover p: it is { file: string }, and a misspelled event is an error that names the type keyof Events. onLoose accepts the same typo in the payload (p.fil) silently and prints undefined. The empty object cast is only so the example can run.

Console

(nothing was printed)

Expected console output

undefined
undefined
{}
undefined
Error messages written for the caller

When the argument is wrong, the parameter type resolves to a string literal that reads like an instruction. Compare with the plain message you would get from a bare constraint. The rule fires for a value whose type is the empty tuple []. A bare first([]) is not caught, because an empty array literal is inferred as never[], which is not []: a custom message only reports the cases your conditional type can tell apart.

Console

(nothing was printed)

Expected console output

HI
isolatedDeclarations: annotate what you export TypeScript 5.5+

With isolatedDeclarations on, an exported array literal and a class field with no type are errors, because the declaration file cannot be written without running the type checker. The as const array and the annotated arrow function are fine. Fix: numbers: number[] and items: string[] = [].

Console

(nothing was printed)

Expected console output

[ 1, 2, 3 ] [ 'a' ] 4 []
The overload error that differs between TypeScript 6.0 and 7.0 (non-live)ts
function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]): number | number[] {
  return typeof input === "string" ? Number(input) : input.map(Number);
}
declare const either: string | string[];
const mixed = parse(either);

// TypeScript 6.0 prints one block per overload:
// index.ts(7,21): error TS2769: No overload matches this call.
//   Overload 1 of 2, '(input: string): number', gave the following error.
//     Argument of type 'string | string[]' is not assignable to parameter of type 'string'.
//       Type 'string[]' is not assignable to type 'string'.
//   Overload 2 of 2, '(input: string[]): number[]', gave the following error.
//     Argument of type 'string | string[]' is not assignable to parameter of type 'string[]'.
//       Type 'string' is not assignable to type 'string[]'.
//
// TypeScript 7.0 prints only the last overload's reason:
// index.ts(7,21): error TS2769: No overload matches this call.
//   The last overload gave the following error.
//     Argument of type 'string | string[]' is not assignable to parameter of type 'string[]'.
//       Type 'string' is not assignable to type 'string[]'.
isolatedDeclarations error codes in 6.0 and 7.0 (non-live)ts
// lib.ts, compiled with "isolatedDeclarations": true and "declaration": true
export function add(a: number, b: number) {
  return a + b;
}

// TypeScript 6.0:
// lib.ts(1,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.
// TypeScript 7.0, same file:
// lib.ts(2,10): error TS9013: Expression type can't be inferred with --isolatedDeclarations.
// (the code and the position differ: 7.0 points at the returned expression a + b)

// With the annotation both versions are clean and write the same declaration file:
export function add2(a: number, b: number): number {
  return a + b;
}
// lib.d.ts:
// export declare function add2(a: number, b: number): number;
@deprecated: what the editor shows (non-live)ts
/** @deprecated Use `newApi` instead. */
function oldApi() { return 1; }
function newApi() { return 2; }
const x = oldApi();

// tsc prints nothing. The language service (TypeScript 6.0) reports a suggestion, which editors draw with a strikethrough:
// 6387 "The signature '(): number' of 'oldApi' is deprecated."
// To fail the build instead, enable typescript-eslint's no-deprecated rule.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Loosen the API

In the second example, replace the on call on("opne", (p) => console.log(p)); with onLoose("opne", (p) => console.log(p));.

terminal
$ Types panel
── what you'll see ──
index.ts(9,13): (parameter) p: {
file: string;
}

Myth vs fact

Myth

Overloads are always the right way to type a function that accepts different inputs.

Fact

Overloads can't take a union argument, and each call must match exactly one signature. A generic with a conditional return type accepts unions. Choose by how callers will hold their values.

Myth

@deprecated stops people from using the function.

Fact

It only draws a strikethrough in the editor. A build fails only if a lint rule (like typescript-eslint no-deprecated) is turned on.

Myth

isolatedDeclarations makes your code type-check faster.

Fact

It makes declaration file generation possible without the full checker, so tools can produce .d.ts in parallel. Your normal type-check still does the same work.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Library types are an API with the same compatibility rules as the code: a change that makes a parameter type narrower, or a return type wider, can break callers' builds even when the runtime is identical. Treat a type change as a possible breaking change and test it with the downstream types that matter (Topic 15.6).

  • ▸

    Prefer one type parameter per piece of information, and avoid type parameters that appear only once in a signature (they add nothing; a lint rule no-unnecessary-type-parameters flags that). When two arguments should agree, give them the same parameter; when one should decide and the other only conform, use NoInfer (Topic 16.2).

  • ▸

    Error messages are produced from your type names. Giving a type alias a clear name (UserId, not string & { __brand: true }) improves every message, because the checker prints aliases when it can. Test the exact text of your most important errors with type tests so refactors don't silently make them worse.

Remember this

  1. 1

    The problem this solves: Your own code is read by you, but a library's types are read by strangers, through autocomplete and error messages, without opening your source. Good type design decides whether those strangers get help or confusion. (Recall from Topic 2.5: overloads are several signatures for one function. Recall from Topic 7.3: a constraint such as K extends keyof Events limits what a type parameter may be.) Overloads or generics? An overload list gives a function several separate signatures; callers see each one in the editor, and the compiler picks the first that matches. Use overloads when the result type really depends on which signature matches and the cases are few and unrelated (parse(string): number and parse(string[]): number[]). A generic with a conditional return type is one signature that also accepts a union argument, so a caller who holds a string | string[] is not stuck. The cost: conditional types are harder to read and the implementation needs a cast. Overloads have a sharp edge: a call that matches none of them gives TS2769 (No overload matches this call), and TypeScript 6.0 prints one block per overload while 7.0 prints only the last one.

  2. 2

    Inference-friendly APIs. The best generic API is one the caller never has to annotate. Tie the parts together with type parameters so the checker can fill them in from what the caller already wrote. In on(name, callback), a type parameter K extends keyof Events is learned from the first argument ("open") and the callback's parameter becomes Events[K] automatically: the editor shows p.file with no annotation. A looser design (name: string, payload: any) accepts anything, so a typo like p.fil goes unnoticed and a misspelled event name is accepted. Constrain type parameters to the keys you have, not to string.

  3. 3

    Good error messages. When a caller makes a mistake, the message names the types in your signature. You can shape it. A plain K extends keyof Events error says Argument of type '"opne"' is not assignable to parameter of type 'keyof Events': readable. For custom rules, make the parameter type a conditional that resolves to a message string when the input is wrong. shout(42) then says the argument is not assignable to the type "Error: expected a string", which tells the caller what to do. Keep these messages short and imperative.

  4. 4

    **Retiring an API with @deprecated.** A JSDoc comment with @deprecated marks a function, method, property or type. Editors draw the name with a strikethrough and show the reason. It does not break any build: the language service (the part of TypeScript that powers editors) reports it as a suggestion (code 6387 for a function signature), not an error. To make a build fail on use, enable the typescript-eslint rule no-deprecated (it exists in typescript-eslint 8). Write what to use instead in the comment: `@deprecated Use newApi instead.`.

  5. 5

    **isolatedDeclarations (TypeScript 5.5) requires that every exported thing has a type that can be written down without looking at other files**. When it is on, the compiler (or a fast tool built for this) can produce .d.ts files file by file, in parallel, without running the full type checker. (Your normal type-check still needs tsc; only the declaration output stops depending on it.) The price is annotations: exported functions need return types, and an exported [1, 2, 3] needs a type or as const. The fix is usually one annotation. Error codes differ slightly between versions: a function without a return type is TS9007 on the function name in 6.0 and TS9013 on the returned expression in 7.0 (shown in a real run below); TS9017 (only const arrays can be inferred) is the same in both.

  6. 6

    A quick checklist for a library's public types: (1) annotate the return type of every exported function, (2) make every inference position tied to a key or type parameter the caller already supplies, (3) test the types with // ^? queries and type-level tests (Topic 15.3), including the negative cases that must fail, (4) keep declaration files small, (5) do not export more than you document, and (6) mark replaced APIs @deprecated for at least one release before removal.

Explain it without notes

01

When would you use overloads instead of a generic with a conditional return type, and the reverse?

02

What makes an API inference-friendly? Show an example.

03

How can you shape the error message a caller sees?

04

What does @deprecated do, and what does it not do?

05

What does isolatedDeclarations require, and why would a library turn it on?

Practice

01

Design get(obj, key) so that get({ a: 1 }, "a") is number and get({ a: 1 }, "b") is an error, with no annotations by the caller.

02

Make first return the element type for arrays and undefined for an empty-literal tuple type, using overloads. Then say why you might prefer a generic here.

Trade-offs

  • ↔

    Smart signatures make calls pleasant and the implementation ugly. The right place for that ugliness is inside the library, behind one well-tested cast, never in callers' code.

  • ↔

    isolatedDeclarations adds annotations that inference would have written for you. In an application, skip it; in a library that publishes types or builds in a big monorepo, it pays for itself.

Done when you can

  • I can choose between overloads and a generic with a conditional return, and explain why.

  • I can design a function whose callback parameter is inferred from another argument.

  • I can write an error message for the caller with a conditional parameter type.

  • I can mark an API @deprecated and say what enforces it.

  • I can turn on isolatedDeclarations and fix the errors it reports.