A Philosophy of Software DesignData-First DesignLesson 6 of 13

Data Dominates Design

Fred Brooks said it: "Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I will not usually need your flowcharts."

Linus Torvalds said it differently: "Bad programmers worry about the code. Good programmers worry about data structures and their relationships."

The principle is the same. Data comes first. Code follows. Get the data model right and the code almost writes itself. Get it wrong and no amount of clever algorithms will save you.

Choosing Representations

Every domain concept can be represented multiple ways. The choice of representation determines what operations are easy, what operations are hard, and what bugs are possible.

Strings vs Enums

Strings are the laziest representation. They work for prototyping but create bugs at scale because they have no constraints.

// Strings: infinite possible values, most of them wrong
function setStatus(order: { status: string }, newStatus: string) {
  order.status = newStatus;
}
setStatus(order, "pendng");   // Typo. No error. Silent bug.
setStatus(order, "PENDING");  // Wrong case. No error. Silent bug.
setStatus(order, "on_hold");  // Not a valid status. No error. Silent bug.

// Closed enum: only valid values exist
type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";

function setStatus(order: { status: OrderStatus }, newStatus: OrderStatus) {
  order.status = newStatus;
}
setStatus(order, "pendng");   // Compile error. Caught immediately.
setStatus(order, "on_hold");  // Compile error. Caught immediately.

The string version has infinite possible values. The enum version has exactly five. TypeScript's type system enforces this at compile time. Every typo, every invalid value, every case mismatch is caught before the code runs.

Use closed enums for any value that has a fixed set of options. Statuses, roles, categories, directions, modes, priorities. If you can list all valid values, use a union type.

Magic Numbers and Magic Strings

A magic number is a literal value whose meaning is not obvious from context. Magic strings are the same problem with text.

// Magic everything: what do these values mean?
if (user.role === 2) { /* ... */ }
if (response.code === "E_AUTH_FAIL") { /* ... */ }
if (timeout > 30000) { /* ... */ }
setTimeout(cleanup, 864e5);

// Named constants: meaning is explicit
const ROLE_ADMIN = 2;          // Better: use a union type instead
const AUTH_FAILURE = "E_AUTH_FAIL";
const MAX_TIMEOUT_MS = 30_000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;

But named constants are the minimum fix. The real fix is making the type system carry the meaning:

type Role = "admin" | "editor" | "viewer";
// No numbers. No magic strings. The type IS the documentation.

Discriminated Unions: The Power Pattern

Discriminated unions are TypeScript's most powerful feature for data modeling. A discriminated union is a type where each variant has a literal kind field that identifies it.

// The kind field discriminates between variants
type Shape =
  | { readonly kind: "circle"; readonly radius: number }
  | { readonly kind: "rectangle"; readonly width: number; readonly height: number }
  | { readonly kind: "triangle"; readonly base: number; readonly height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":    return Math.PI * shape.radius ** 2;
    case "rectangle": return shape.width * shape.height;
    case "triangle":  return 0.5 * shape.base * shape.height;
  }
  // TypeScript knows this is exhaustive. No default needed.
  // Add a new shape variant → compile error here. You cannot forget.
}

Inside each case, TypeScript narrows the type. In the "circle" case, shape.radius is available but shape.width is not. The type system enforces that you only access fields that exist for that variant.

Why Not Class Hierarchies?

The traditional OOP approach uses inheritance:

// OOP: class hierarchy
abstract class Shape {
  abstract area(): number;
}

class Circle extends Shape {
  constructor(readonly radius: number) { super(); }
  area() { return Math.PI * this.radius ** 2; }
}

class Rectangle extends Shape {
  constructor(readonly width: number, readonly height: number) { super(); }
  area() { return this.width * this.height; }
}

This works for area(). But what about perimeter()? contains(point)? serialize()? Each new operation requires modifying every class. With discriminated unions, you add a new function. With class hierarchies, you add a new method to every class.

Discriminated unions scale with operations. Class hierarchies scale with types. Choose based on what changes more often. In most applications, operations change more than types.

Boolean Flags Are a Code Smell

Boolean flags create ambiguity. Two booleans create four states. Three booleans create eight. Most combinations are invalid, but the type system allows all of them.

// Boolean soup: 8 possible states, most invalid
interface Notification {
  sent: boolean;
  failed: boolean;
  retrying: boolean;
}
// Can a notification be sent AND failed AND retrying?
// Can it be not-sent AND not-failed AND not-retrying?
// The type allows all 8 combinations. Only 4 make sense.

// Discriminated union: only valid states exist
type Notification =
  | { readonly kind: "pending" }
  | { readonly kind: "sending" }
  | { readonly kind: "sent"; readonly sentAt: Date }
  | { readonly kind: "failed"; readonly error: string; readonly attempts: number }
  | { readonly kind: "retrying"; readonly nextAttempt: Date; readonly attempts: number };

// Five states. Each has exactly the fields that make sense for it.
// "sent but also retrying" is unrepresentable.

Notice how each variant carries its own specific data. A "sent" notification has a sentAt timestamp. A "failed" notification has an error message. A "pending" notification has neither. The data model enforces that you cannot access sentAt on a failed notification, because that field does not exist in that variant.

Exhaustive Switches

When you switch on a discriminated union's kind field, TypeScript verifies that you handle every case. This means adding a new variant to the union is a compile error everywhere the union is used.

type PaymentMethod =
  | { readonly kind: "credit_card"; readonly last4: string; readonly brand: string }
  | { readonly kind: "bank_transfer"; readonly accountLast4: string }
  | { readonly kind: "crypto"; readonly wallet: string };

function displayPaymentMethod(method: PaymentMethod): string {
  switch (method.kind) {
    case "credit_card":    return `${method.brand} ending in ${method.last4}`;
    case "bank_transfer":  return `Bank account ending in ${method.accountLast4}`;
    case "crypto":         return `Wallet ${method.wallet.slice(0, 8)}...`;
  }
}

// Add { kind: "paypal"; email: string } to the union:
// → TypeScript ERROR in displayPaymentMethod: not all cases handled.
// You cannot forget to handle the new variant.

To enforce exhaustiveness at the type level, you can use a helper:

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

function displayPaymentMethod(method: PaymentMethod): string {
  switch (method.kind) {
    case "credit_card":    return `${method.brand} ending in ${method.last4}`;
    case "bank_transfer":  return `Bank account ending in ${method.accountLast4}`;
    case "crypto":         return `Wallet ${method.wallet.slice(0, 8)}...`;
    default:               return assertNever(method);
  }
}
// If you miss a case, `method` is not `never`, and TypeScript errors.

Check Your Understanding

Why are strings a poor representation for fixed sets of values?

Not quite. The correct answer is highlighted.
A union type where each variant has a literal field that identifies it is called a union.
Not quite.Expected: discriminated

What advantage do discriminated unions have over class hierarchies?

Not quite. The correct answer is highlighted.

Why are boolean flags a code smell?

Not quite. The correct answer is highlighted.

Practice

Replace boolean flags and string statuses with discriminated unions:

Summary

  • Data comes first, code follows. The data model determines what bugs are possible.
  • Closed enums over strings for any fixed set of values. TypeScript catches typos at compile time.
  • Discriminated unions are TypeScript's power pattern: each variant carries exactly the data it needs, and exhaustive switches ensure every case is handled.
  • Boolean flags create exponential state space. Replace them with explicit state variants.
  • Exhaustive switches on discriminated unions turn runtime errors into compile errors.
  • as const satisfies gives you const narrowing with type checking.

Next, we take this further: making illegal states unrepresentable with Option and Result types.