Type Design
Type Design
Great TypeScript code starts with great type design. This chapter focuses on how to model data and APIs so the compiler helps you, not fights you.
Item 28: Use Types to Model Valid States Only
A powerful principle: Make invalid states unrepresentable.
// BAD: invalid state possible
interface RequestState {
status: "idle" | "loading" | "success" | "error";
data?: string[];
error?: Error;
}
// This allows impossible states:
const bad: RequestState = { status: "success", error: new Error("Oops") };Use discriminated unions instead:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; error: Error };
// Now invalid combinations are impossibleCheckout State
type CheckoutState =
| { step: 'cart'; items: CartItem[] }
| { step: 'shipping'; items: CartItem[]; address: Address }
| { step: 'payment'; items: CartItem[]; address: Address; paymentMethod: Payment }
| { step: 'complete'; orderId: string };Item 29: Be Liberal in What You Accept, Strict in What You Produce
Accept flexible input shapes, but produce strict, well-defined output.
// Input can be flexible
type DateInput = string | number | Date;
// Output should be consistent
function parseDate(input: DateInput): Date {
if (input instanceof Date) return input;
return new Date(input);
}Note
This principle leads to APIs that are easy to call but safe to consume.
Item 30: Don't Repeat Type Information in Documentation
When types are clear, documentation should explain why not what.
// BAD doc: repeats types
/**
* @param user - User object with id, name, email
*/
function sendWelcomeEmail(user: User) {}
// GOOD doc: explains behavior and constraints
/**
* Sends a welcome email and schedules the onboarding sequence.
* Throws if the user has already been onboarded.
*/
function sendWelcomeEmail(user: User) {}Item 31: Prefer Unions of Interfaces to Interfaces of Unions
This improves narrowing and makes invalid states impossible.
// BAD: interface with union properties
interface Layer {
type: "text" | "image";
text?: string;
url?: string;
}
// GOOD: union of interfaces
interface TextLayer {
type: "text";
text: string;
}
interface ImageLayer {
type: "image";
url: string;
}
type Layer = TextLayer | ImageLayer;Item 32: Prefer string literal types to string
Use literal unions to constrain values:
type Theme = "light" | "dark" | "system";
function setTheme(theme: Theme) {
// Only valid values allowed
}This improves autocomplete and prevents typos.
Item 33: Use unknown for Values with Unknown Types
unknown forces you to check before use, making it safer than any:
function parseJson(json: string): unknown {
return JSON.parse(json);
}
const data = parseJson('{"name":"Alice"}');
// data.name // Error!
if (typeof data === "object" && data && "name" in data) {
console.log((data as { name: string }).name);
}Item 34: Prefer never for Impossible States
never helps you check exhaustiveness:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
default:
return assertNever(shape);
}
}
function assertNever(x: never): never {
throw new Error("Unexpected value");
}Item 35: Use Pick, Omit, and Mapped Types to Stay DRY
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
// Create smaller views
type UserPreview = Pick<User, "id" | "name">;
type UserWithoutEmail = Omit<User, "email">;
// Create read-only view
type ReadonlyUser = { readonly [K in keyof User]: User[K] };Item 36: Use satisfies to Validate Without Widening
const routes = {
home: "/",
about: "/about",
contact: "/contact",
} satisfies Record<string, string>;
// routes.home is still '/'Item 37: Avoid Optional Properties When You Can
Optional properties require extra checks everywhere.
// Prefer explicit states
type Result = { ok: true; value: string } | { ok: false; error: string };Item 38: Group Related Options Into Objects
It makes APIs easier to extend without breaking changes.
// BAD: long parameter list
function createUser(name: string, email: string, isAdmin: boolean) {}
// GOOD: options object
function createUser(opts: { name: string; email: string; isAdmin?: boolean }) {}Item 39: Use Type Aliases for Complex Unions and Intersections
type APIResponse<T> =
| { status: "success"; data: T }
| { status: "error"; error: string };Key Takeaways
- Model only valid states using discriminated unions.
- Be liberal in inputs, strict in outputs.
- Avoid documentation that repeats types.
- Prefer unions of interfaces for better narrowing.
- Use literal unions for constrained values.
- Use
unknowninstead ofanyfor untrusted input. - Use
neverto enforce exhaustiveness. - Use
Pick,Omit, and mapped types to avoid duplication. - Use
satisfiesto validate without widening. - Prefer options objects for extensible APIs.
Next: working safely with any, unknown, and boundary types.