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.
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.
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
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 becomesstring | string[] | number(and its body handles a number). - 2
Add an event
In the second example, add
save: { ok: boolean }toEvents. Without changingon, callon("save", (p) => console.log(p.ok))and hoverp. - 3
Read your own message
In the error-message example, change the message string in
shoutto something you would want to read at 5 pm on a Friday, and check the Types panel shows it.
Code & diagrams
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 ]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
{}
undefinedWhen 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
HIWith 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 []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[]'.// 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 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));.
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-parametersflags that). When two arguments should agree, give them the same parameter; when one should decide and the other only conform, useNoInfer(Topic 16.2). - ▸
Error messages are produced from your type names. Giving a type alias a clear name (
UserId, notstring & { __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
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 Eventslimits 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): numberandparse(string[]): number[]). A generic with a conditional return type is one signature that also accepts a union argument, so a caller who holds astring | 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 givesTS2769(No overload matches this call), and TypeScript 6.0 prints one block per overload while 7.0 prints only the last one. - 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 parameterK extends keyof Eventsis learned from the first argument ("open") and the callback's parameter becomesEvents[K]automatically: the editor showsp.filewith no annotation. A looser design (name: string,payload: any) accepts anything, so a typo likep.filgoes unnoticed and a misspelled event name is accepted. Constrain type parameters to the keys you have, not tostring. - 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 Eventserror saysArgument 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
**Retiring an API with
@deprecated.** A JSDoc comment with@deprecatedmarks 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 thetypescript-eslintruleno-deprecated(it exists in typescript-eslint 8). Write what to use instead in the comment: `@deprecated UsenewApiinstead.`. - 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.tsfiles file by file, in parallel, without running the full type checker. (Your normal type-check still needstsc; 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 oras const. The fix is usually one annotation. Error codes differ slightly between versions: a function without a return type isTS9007on the function name in 6.0 andTS9013on 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
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@deprecatedfor at least one release before removal.
Explain it without notes
When would you use overloads instead of a generic with a conditional return type, and the reverse?
What makes an API inference-friendly? Show an example.
How can you shape the error message a caller sees?
What does @deprecated do, and what does it not do?
What does isolatedDeclarations require, and why would a library turn it on?
Practice
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.
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.
- ↔
isolatedDeclarationsadds 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
@deprecatedand say what enforces it.I can turn on
isolatedDeclarationsand fix the errors it reports.