08 · Writing Robust Public APIs¶
Code you call from the same file behaves however you want; code
consumed by other people's codebases needs a stable, predictable
surface — clear error handling that doesn't force try/catch
everywhere, overloaded signatures for genuinely different call shapes,
and return types that can't be silently mutated by a caller. This
module covers three patterns common in well-designed TypeScript
libraries.
The Result type: errors as values, not exceptions¶
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
interface ParseError {
message: string;
position: number;
}
function parseCount(input: string): Result<number, ParseError> {
const trimmed = input.trim();
if (trimmed === "") {
return err({ message: "empty input", position: 0 });
}
const n = Number(trimmed);
if (Number.isNaN(n)) {
return err({ message: `"${trimmed}" is not a number`, position: 0 });
}
if (n < 0) {
return err({ message: "count cannot be negative", position: 0 });
}
return ok(n);
}
function handle(input: string): void {
const result = parseCount(input);
if (result.ok) {
console.log("parsed:", result.value);
} else {
console.log("failed:", result.error.message);
}
}
handle("42");
handle("-5");
handle("abc");
parseCount's signature itself documents every way it can fail —
a caller reading the type doesn't need to hunt through the
implementation or documentation to discover what exceptions might be
thrown. if (result.ok) narrows result to the success branch;
else narrows it to the error branch — TypeScript enforces you check
which one you have before accessing .value or .error, unlike a
thrown exception a caller can simply forget to catch.
Overloaded signatures for meaningfully different call shapes¶
function createLogger(name: string): { log(msg: string): void };
function createLogger(name: string, level: "debug" | "info" | "error"): { log(msg: string): void };
function createLogger(
name: string,
level: "debug" | "info" | "error" = "info"
): { log(msg: string): void } {
return {
log(msg: string) {
console.log(`[${level}] ${name}: ${msg}`);
},
};
}
const logger1 = createLogger("api");
logger1.log("started");
const logger2 = createLogger("db", "debug");
logger2.log("query executed");
The two signatures above the implementation are what callers and
autocomplete actually see; the implementation signature (with the
default parameter) is invisible from outside. Overloads are worth
reaching for specifically when the meaning of a call changes based on
arity — a single signature with level?: "debug" | "info" | "error"
would work identically here, so overloads are somewhat redundant in
this exact example, but become essential once, say, a two-argument
call and a three-argument call return genuinely different types
(which a single optional-parameter signature can't express).
Readonly return types prevent silent mutation¶
interface PublicApiOptions {
readonly timeout: number;
readonly retries: number;
}
function getDefaultOptions(): Readonly<PublicApiOptions> {
return { timeout: 5000, retries: 3 };
}
const opts = getDefaultOptions();
console.log(opts);
// opts.timeout = 1000; // compile error if uncommented
Without readonly, a caller doing
const opts = getDefaultOptions(); opts.timeout = 1000; compiles fine
and silently mutates what might be a shared/cached default-options
object — a classic "why did changing this in one place affect an
unrelated part of the app" bug. readonly at the type level catches
the mutation attempt at the call site, before it ever runs.
Traps¶
Return-type inference on ok()/err() widens if you drop the
explicit annotations. Removing : Result<T, never> from ok's
signature lets TypeScript infer a looser type from the function body,
and downstream discriminated-union narrowing (if (result.ok) {...}
else {...}) can stop working correctly — always annotate the return
type explicitly on small helper constructors like this in a public API,
even though TypeScript could technically infer something.
A Result<T, E> API doesn't stop a caller from ignoring the
.error case entirely if they just do result.value without
narrowing first — that's a compile error only when strict (specifically
strictNullChecks) is on. A public library targeting consumers who
might not use strict mode should document this clearly, since the
whole safety guarantee depends on the caller's own compiler settings.
Overloads are checked in declaration order, and the last matching overload's parameter types are what the implementation body sees — if two overload signatures could both match a given call, TypeScript picks the first one that fits, which can produce a confusingly "wrong" overload being selected if they're ordered loosely-to-strict instead of strictly-to-loose.
readonly is shallow. Readonly<PublicApiOptions> prevents
reassigning opts.timeout, but if PublicApiOptions had a nested
object property, that nested object's own fields would still be
mutable unless you also apply Readonly (or a recursive DeepReadonly
utility) to it.
How It Actually Works¶
A published library's public API surface is defined, as far as consumers' checkers are concerned, entirely by its emitted .d.ts files — not by the original .ts source, which typically isn't shipped at all. This means "what's a breaking change" for a TypeScript library is a question about the declaration output, and it can diverge sharply from what looks like a breaking change in the source: widening a function's parameter type is source-compatible but can still be a declaration-level breaking change for consumers relying on stricter inference from the old, narrower type; conversely, an internal implementation rewrite that leaves the exported .d.ts shape byte-identical is not a breaking change at all, regardless of how different the runtime logic underneath now is.
Function-type variance (contravariant parameters, covariant returns, from the classes-advanced lesson) directly governs what counts as a safe versus breaking signature change for a public function: widening a parameter's accepted type or narrowing a return type are both backward-compatible under structural assignability (existing calling code remains valid), while narrowing a parameter or widening a return type can silently break consumers who were relying on the old, more permissive or more specific contract — this is the same variance rule from ordinary structural comparison, just applied at the scale of "will every existing caller still type-check."
Generic API design interacts with declaration emit in a subtle way: a generic type parameter with no extends constraint that appears only in output positions (like a builder's fluent return type) can end up inferred as unknown for consumers who don't supply an explicit type argument, silently degrading the ergonomics of the public API — which is why well-designed generic libraries put real thought into default type parameters (<T = DefaultShape>) and constraint placement, since those choices directly shape what a consumer's checker infers with zero arguments supplied, not just what's technically type-safe.
Cheat sheet¶
| Pattern | Use for |
|---|---|
Result<T, E> |
Public functions where callers must handle failure explicitly |
| Function overloads | Different argument counts/types that mean genuinely different things |
Readonly<T> return type |
Preventing accidental mutation of shared/default objects |
| Explicit return-type annotations on helpers | Keeping discriminated unions narrow and correct |
DeepReadonly<T> (custom) |
Full immutability for nested return values |
Exercise¶
Add an unwrapOr(result: Result<T, E>, fallback: T): T helper function
(generic over both T and E) that returns result.value if ok,
otherwise the fallback. Use it to rewrite handle so a failed parse
falls back to 0 instead of just logging the error, and verify with
tsc --noEmit --strict that unwrapOr's generic parameters are
correctly inferred at each call site without any explicit type
arguments.