Authoring a Typed Library
Designing a public type surface, exports maps, shipping declarations for multiple module formats, and treating a type change as a breaking change when it is one.
Designing a public type surface, exports maps, shipping declarations for multiple module formats, and treating a type change as a breaking change when it is one.
You widen a return type from Photo to Photo | undefined
because a lookup can now miss. Tests pass, the runtime behaviour
is strictly better, and you ship it as a patch release. Within
an hour there are issues from teams whose builds now fail on
code they did not change.
A published type surface is a contract, and it can break independently of your runtime behaviour. By the end of this lesson you will know which type changes are breaking, how to design a surface you can evolve, and how to verify what you are actually shipping.
Semantic versioning says a major release means "your code may break". For a typed library that includes your consumers' builds, not only their runtime.
BREAKING
removing or renaming an exported type
widening a return type Photo -> Photo | undefined
narrowing a parameter type string -> "a" | "b"
adding a required parameter
adding a required property to an input type
making an optional property required
changing what a generic infers
raising the minimum TypeScript version
SAFE
narrowing a return type unknown -> Photo
widening a parameter type "a" -> string
adding an optional parameter
adding a property to an OUTPUT type
adding a new exportTwo entries surprise people every time.
Widening a return is breaking, because callers wrote code
for the narrower one. Returning Photo | undefined where you
returned Photo breaks everyone who did not check.
Adding a property to an input type is breaking if required, and safe on an output type. The asymmetry follows from variance: your consumers construct inputs and consume outputs.
Bad — positional parameters and a tuple return.
export function upload(
file: Buffer,
name: string,
retries: number,
): [string, number] { ... }Good — an options object and a named result.
export type UploadOptions = {
file: Buffer;
name: string;
retries?: number;
};
export type UploadResult = {
id: string;
bytesWritten: number;
};
export function upload(options: UploadOptions): UploadResult { ... }Every change is a breaking change.
A fourth parameter, a reorder, a rename — all breaking. And
the tuple cannot grow at all, because
const [id, bytes] = upload(...) fixes its length forever.
Both directions grow additively.
A new optional option breaks nobody. A new result field breaks nobody.
The highest-return decision in library design, and it costs one type alias.
Three more that pay:
Keyword-only in effect. An options object means parameter names are the API and their order is not.
Export every type in a public signature. If upload returns
UploadResult, consumers need to name it — to store it, wrap
it, or write their own function returning it.
Return interfaces, accept structural shapes. Accept the minimum you use; return something specific and named.
export function parse<T>(schema: Schema<T>, input: unknown): T { ... }Changing where T is inferred from, adding a second type
parameter without a default, or altering a constraint all break
callers — often with an error that does not mention your change.
Two rules keep them evolvable.
Give new type parameters a default:
export function parse<T, E = ValidationError>(
schema: Schema<T>,
): Result<T, E> { ... }Existing callers are unaffected; new ones can specify.
Infer from arguments, never require explicit type
arguments. A signature where T appears only in the return
forces every caller to write parse<Photo>(...), and that
becomes part of your API — you can no longer change how it is
inferred.
And be deliberate about const parameters, which are
themselves a contract:
export function defineRoutes<const T extends readonly string[]>(
routes: T,
): Router<T[number]> { ... }That gives callers literal types without as const. Removing it
later widens everyone's inferred types, which is breaking.
/**
* @deprecated Use {@link upload} instead. Removed in 3.0.
*/
export function uploadFile(file: Buffer, name: string): string {
return upload({ file, name }).id;
}The JSDoc tag makes editors strike the name through and
@typescript-eslint/no-deprecated can fail a build. That is
notice a consumer actually receives, unlike a changelog entry.
The sequence: deprecate in a minor with a pointer to the
replacement and the removal version, keep it working, remove in
the next major. Removing in a minor breaks people who followed
your own contract by depending on ^2.0.0.
For a type rather than a value, the same tag works, and a type alias keeps old code compiling:
/** @deprecated Renamed to {@link UploadOptions}. */
export type UploaderOptions = UploadOptions;The generated declarations can differ from what you intended, and there are three specific ways.
A leaked internal type:
error TS4053: Return type of exported function has or is using
private name 'InternalCache'.Your public API is larger than you thought. Export the type, or change the signature.
An accidentally-wide inferred type. Without an explicit return annotation the compiler infers, and the inference may be broader — or may reference something you consider internal. Annotate the return type of every exported function; it is documentation and it pins the contract.
Declarations that do not resolve. The exports map ordering
from the modules lesson, dual-format mistakes, a types field
pointing at a file that does not exist.
Two tools check the last one from outside:
npx @arethetypeswrong/cli --pack
npx publintBoth should run in CI before publish. They catch what is invisible locally and obvious to a consumer.
Four things that cost little and matter.
Ship one format if you can. Dual ESM and CommonJS doubles the build and creates the dual-package hazard from the modules lesson. Publish ESM only when your consumers can take it.
Do not bundle dependencies, and do not minify. Consumers need to debug through your code.
Set sideEffects: false if importing your modules does
nothing on its own — it enables tree-shaking. And do not set it
if that is untrue, because the consequence is silently missing
code.
State the TypeScript versions you support, and test against
the oldest. typesVersions exists for shipping different
declarations per version, and avoiding the need for it is
better.
TYPES ARE THE API - a type change can break a consumer's BUILD
BREAKING remove/rename an export
WIDEN a return Photo -> Photo | undefined
NARROW a parameter
add a required parameter or input property
make an optional property required
change what a generic infers
raise the minimum TypeScript version
SAFE narrow a return; widen a parameter
add an optional parameter
add a property to an OUTPUT type
add a new export
DESIGN
an options object, not positional parameters
a named result type, never a tuple - tuples cannot grow
export every type in a public signature
accept the minimum; return something specific
GENERICS
new type parameters need a DEFAULT
infer from arguments; never require explicit type arguments,
or the call syntax becomes part of your API
a `const` parameter is a contract - removing it widens everyone
DEPRECATING
@deprecated with {@link replacement} and the removal version
editors strike it through; a lint rule can fail the build
deprecate in a minor, remove in the next MAJOR
a type alias keeps old names compiling
WHAT YOU ACTUALLY SHIP
TS4053 private name -> your API leaks an unexported type
annotate every exported return type - inference may be wider
npx @arethetypeswrong/cli --pack
npx publint
a test asserting Object.keys(api) catches accidental exports
CONSUMERS
one format if possible; dual ESM+CJS is a hazard
do not bundle deps, do not minify
sideEffects: false only if TRUE
test against the oldest TypeScript you claimYou can now evolve a published type surface without breaking people, and verify what you are shipping rather than assuming. The options object is the change to make first — it converts most future additions from breaking to additive, for the cost of one type alias.
Next is Generics That Scale, which returns to type parameters with everything this course has established. Typing higher-order functions, preserving inference through wrappers, and the builder patterns that stay inferable as they chain.
Before you move on, take a published or shared function of yours with positional parameters and convert it to an options object. Then look at your last three releases and ask whether any of them widened a return type. If one did, it was a breaking change that went out as a minor.