Type Design and Best Practices
|
This section documents the current TypeScript release line as published at the official TypeScript documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since TypeScript iterates quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
Types are a design medium, not just a safety net — the shapes you choose decide which bugs are
possible to write. The rules below pay off most: model only valid states, push null to the edges,
accept wide and return narrow, and reach for nominal typing when structural typing is too loose.
Make Illegal States Unrepresentable
Prefer a type that cannot describe a broken value over one that can but "shouldn’t".
A union of interfaces, not one interface full of optionals
BEFORE — every field optional, so the empty object and every nonsensical mix type-check:
interface RequestState {
loading?: boolean;
data?: string[];
error?: Error;
}
// All of these compile, none are meaningful:
const a: RequestState = {};
const b: RequestState = { loading: true, data: ["x"], error: new Error() };
AFTER — one shape per real state, combined with |. The compiler now rejects the impossible
combinations, and narrowing on status unlocks exactly the right fields:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; error: Error };
function render(state: RequestState): string {
switch (state.status) {
case "idle": return "";
case "loading": return "spinner";
case "success": return state.data.join(", "); // data guaranteed here
case "error": return state.error.message; // error guaranteed here
}
}
See Unions and Narrowing for the discriminated-union mechanics. Note that
interface declarations also support
declaration merging — handy
for augmenting library types, but a reason to keep union members as inline object types or type
aliases when you do not want a shape re-opened elsewhere.
A distinct type for a special value
BEFORE — a magic sentinel that lives inside the normal range:
// -1 means "not found"; nothing stops it reaching arithmetic
function indexOf(haystack: string, needle: string): number {
return haystack.indexOf(needle);
}
const i = indexOf("abc", "z");
const next = i + 1; // 0 -- silently wrong
AFTER — the "missing" case is its own type the caller is forced to handle:
function find(haystack: string, needle: string): number | undefined {
const i = haystack.indexOf(needle);
return i === -1 ? undefined : i;
}
const i = find("abc", "z");
const next = i + 1; // Error: 'i' is possibly 'undefined'
Keep null at the Perimeter
Validate at the API boundary, then work with a fully-populated type inside. Do not bake null /
undefined into shared type aliases or bury them deep in a structure — every downstream reader then
repeats the same guards.
BEFORE — optionality threaded through the whole model:
interface User {
id: string | null;
name?: string;
address?: { city?: string; zip?: string };
}
AFTER — a permissive input type at the edge, a strict domain type everywhere else:
interface UserInput { // only at the HTTP / form boundary
id?: string;
name?: string;
address?: { city?: string; zip?: string };
}
interface User { // used by the rest of the app
id: string;
name: string;
address: { city: string; zip: string };
}
function parseUser(input: UserInput): User {
const { id, name, address } = input;
if (!id || !name || !address?.city || !address.zip) throw new Error("invalid user");
return { id, name, address: { city: address.city, zip: address.zip } };
}
Utilities such as Partial<T>, Required<T> and NonNullable<T> for deriving the boundary type from
the domain type are in Utility Types.
Accept Wide, Return Narrow
"Be liberal in what you accept, strict in what you produce." Parameter types should ask for the least a caller can supply; return types should promise the most a caller can rely on.
BEFORE — the parameter demands a mutable number[], and the return type is vague:
function totals(nums: number[]): number[] | undefined {
if (nums.length === 0) return undefined;
return [nums.reduce((a, b) => a + b, 0)];
}
const scores: readonly number[] = [1, 2, 3];
// totals(scores); // Error: 'readonly number[]' is not assignable to 'number[]'
AFTER — accept readonly number[] (plain arrays, ReadonlyArray, and tuples all satisfy it); return
a concrete number[] the caller may freely mutate:
function totals(nums: readonly number[]): number[] {
return [nums.reduce((a, b) => a + b, 0)];
}
const scores: readonly number[] = [1, 2, 3];
const out = totals(scores); // out: number[] -- a fresh array the caller may mutate
The same rule favours Iterable<T> over T[] for a parameter, and a specific object type over
object or Record<string, unknown>.
Precision Without Overreach
More precise than bare string
string admits every sequence of characters. When the real domain is smaller, say so — with a
literal union, a template-literal type, or a branded type (below). The primitive types are covered in
the handbook’s Everyday Types.
// BEFORE
function setAlignment(value: string): void {}
// AFTER
type Alignment = "left" | "center" | "right";
function setAlignment(value: Alignment): void {}
type HexColor = `#${string}`; // template-literal type: at least the '#' is enforced
Prefer imprecise-but-correct to precise-but-wrong
An over-specified type that lies is worse than a loose one that holds. If you cannot model every field accurately, model the part you are sure of and leave the rest open rather than inventing a shape.
// precise-but-wrong: 'meta' is free-form; its keys vary by event
interface EventA { name: string; meta: { userId: string; ts: number } }
// imprecise-but-correct
interface EventB { name: string; meta: Record<string, unknown> }
Name types in the language of the domain
// BEFORE: the type describes its machine representation
type Data = { n: string; v: number; t: number };
// AFTER: the type describes the business
interface Payment {
payee: string;
amountCents: number;
createdAt: Date;
}
Also: do not restate type information in doc comments or in variable names — /** @param name the
name string */ and const nameStr: string add nothing the type does not already say, and drift when
the type changes. Let the type carry the type; use prose for intent and units.
Nominal Typing via Branding
TypeScript is structurally typed: any string is interchangeable with any other string. To stop a
UserId being passed where an OrderId is expected, intersect an unforgeable phantom field — type UserId = string & \{ readonly __brand: unique symbol } — and mint values through a single
checked constructor.
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function userId(raw: string): UserId {
if (!/^u_[0-9a-f]{16}$/.test(raw)) throw new Error("bad user id");
return raw as UserId; // the ONE sanctioned cast
}
function loadUser(id: UserId): void {}
const uid = userId("u_00000000deadbeef");
loadUser(uid); // OK
// loadUser("u_00000000deadbeef"); // Error: plain string is not UserId
// loadUser("order_123" as OrderId); // Error: OrderId is not UserId
The brand exists only in the type system — at runtime uid is just a string, with no wrapper cost.
never for Exhaustiveness
never is the empty type: nothing is assignable to it. Funnel a fully-narrowed union into a never
parameter and a missing case becomes a compile error — the handbook’s
exhaustiveness checking
pattern.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function assertNever(x: never): never {
throw new Error(`unhandled: ${JSON.stringify(x)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.r ** 2;
case "square": return shape.side ** 2;
default: return assertNever(shape); // add a 3rd Shape -> error here
}
}
Without the default branch a new union member fails silently at every call site; with it, the build
breaks in exactly one place.
Exclusive-Or with Optional never
Model "either a or b, never both" by adding the other key as an optional never on each side of
a union. In prose the two shapes are \{ a: string; b?: never } and \{ b: string; a?: never }.
BEFORE — both keys optional, so supplying both (or neither) type-checks:
interface LinkOrAction {
href?: string;
onClick?: () => void;
}
const bad: LinkOrAction = { href: "/x", onClick: () => {} }; // no complaint
AFTER — an XOR union:
type LinkOrAction =
| { href: string; onClick?: never }
| { onClick: () => void; href?: never };
const link: LinkOrAction = { href: "/x" }; // OK
const action: LinkOrAction = { onClick: () => {} }; // OK
// const both: LinkOrAction = { href: "/x", onClick: () => {} }; // Error
For a reusable XOR<A, B> helper built from mapped and conditional types, see
Type Manipulation.
See Also
-
Objects and Interfaces —
interfacevstype,readonly, and excess-property checks. -
Unions and Narrowing — discriminated unions and the narrowing constructs these designs rely on.
-
Utility Types —
Partial,Required,Readonly,NonNullablefor boundary-vs-domain types. -
Type Manipulation — mapped, conditional and template-literal types behind branding and XOR helpers.
-
Worked Example: Migrating a Module to TypeScript — these rules applied end to end in one small codebase.