Learning Guides
Menu

Type Design

5 min readEffective TypeScript

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.

TYPESCRIPT
// 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:

TYPESCRIPT
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: Error };
 
// Now invalid combinations are impossible

Checkout State

TYPESCRIPT
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.

TYPESCRIPT
// 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.

TYPESCRIPT
// 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.

TYPESCRIPT
// 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:

TYPESCRIPT
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:

TYPESCRIPT
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:

TYPESCRIPT
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

TYPESCRIPT
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

TYPESCRIPT
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.

TYPESCRIPT
// Prefer explicit states
 
type Result = { ok: true; value: string } | { ok: false; error: string };

It makes APIs easier to extend without breaking changes.

TYPESCRIPT
// 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

TYPESCRIPT
type APIResponse<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: string };

Key Takeaways

  1. Model only valid states using discriminated unions.
  2. Be liberal in inputs, strict in outputs.
  3. Avoid documentation that repeats types.
  4. Prefer unions of interfaces for better narrowing.
  5. Use literal unions for constrained values.
  6. Use unknown instead of any for untrusted input.
  7. Use never to enforce exhaustiveness.
  8. Use Pick, Omit, and mapped types to avoid duplication.
  9. Use satisfies to validate without widening.
  10. Prefer options objects for extensible APIs.

Next: working safely with any, unknown, and boundary types.