Chapter 12

Type Design Philosophy: Legal vs Illegal States, 12 Principles

The Core Proposition

The value of a type system is not labeling variables โ€” it is letting the compiler eliminate entire classes of errors. Good type design makes illegal states literally unrepresentable in code. Bad type design makes correct code and buggy code look identical.

These 12 principles are drawn from real production lessons. Each shows an antipattern and its fix.


Principle 1: Make Illegal States Unrepresentable (The Core Principle)

If your type can represent a state that should never exist, your code must compensate with runtime checks forever.

// Antipattern: two fields that are semantically coupled, type cannot enforce consistency
interface FetchState {
  isLoading: boolean;
  data: string | null;
  error: Error | null;
}

// Structurally valid, semantically nonsense: loading=true with both data and error
const broken: FetchState = {
  isLoading: true,
  data: "some data",
  error: new Error("also an error"),
};
// Fix: discriminated union โ€” each state is self-contained
type FetchState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: Error };

function render(state: FetchState): string {
  switch (state.status) {
    case "idle":    return "Click to load";
    case "loading": return "Loading...";
    case "success": return state.data;            // compiler knows data exists
    case "error":   return state.error.message;   // compiler knows error exists
  }
}

Principle 2: Prefer Union of Interfaces Over Optional Fields

Optional fields (?) say "this might exist" but cannot say "under what conditions." A union of interfaces makes those conditions explicit.

// Antipattern: optional fields cannot constrain inter-field relationships
interface Payment {
  method: "card" | "paypal" | "bank";
  cardNumber?: string;
  cardExpiry?: string;
  paypalEmail?: string;
  bankAccount?: string;
  bankRoutingNumber?: string;
}

// Type allows this absurdity:
const p: Payment = {
  method: "card",
  paypalEmail: "[email protected]", // card method with PayPal fields โ€” no error
};
// Fix: each payment method has its own interface
interface CardPayment {
  method: "card";
  cardNumber: string;
  cardExpiry: string;
}

interface PaypalPayment {
  method: "paypal";
  paypalEmail: string;
}

interface BankPayment {
  method: "bank";
  bankAccount: string;
  bankRoutingNumber: string;
}

type Payment = CardPayment | PaypalPayment | BankPayment;

function processPayment(payment: Payment) {
  if (payment.method === "card") {
    // cardNumber and cardExpiry are guaranteed present โ€” no null check needed
    charge(payment.cardNumber, payment.cardExpiry);
  }
}

Principle 3: Push Null to the Perimeter

null should be dealt with at the point data enters the system. Inner functions should receive clean, non-null values. Passing nullable through multiple layers infects every callsite.

// Antipattern: null propagates through business logic
function getDisplayName(user: User | null): string | null {
  if (!user) return null;
  const profile = user.profile ?? null;
  if (!profile) return null;
  return profile.displayName ?? null; // caller still has to handle null
}
// Fix: boundary handles null, inner functions receive guaranteed values
function getUser(id: string): User | null {
  return db.find(id) ?? null; // null lives only here
}

// Inner function: assumes valid input
function getDisplayName(user: User): string {
  return user.profile?.displayName ?? user.email;
}

// Call site: handle null once, then everything downstream is clean
const user = getUser(id);
if (user) {
  const name = getDisplayName(user); // string, not string | null
  render(name);
}

Principle 4: Use Distinct Types for Distinct Concepts

UserId and PostId are both strings at runtime, but they are different concepts. Using the same string type for both means the compiler cannot stop you from passing a post ID where a user ID is expected.

// Antipattern: all IDs are string โ€” compiler cannot distinguish them
function getPost(userId: string, postId: string): Post {
  return db.posts.findOne({ author: userId, id: postId });
}

const userId = "user_123";
const postId = "post_456";

getPost(postId, userId); // arguments reversed โ€” no compiler error
// Fix: distinct branded types (see Chapter 13 for full treatment)
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };

const makeUserId = (s: string): UserId => s as UserId;
const makePostId = (s: string): PostId => s as PostId;

function getPost(userId: UserId, postId: PostId): Post {
  return db.posts.findOne({ author: userId, id: postId });
}

const userId = makeUserId("user_123");
const postId = makePostId("post_456");

getPost(postId, userId); // Compile error: PostId is not assignable to UserId

Principle 5: Be Liberal in What You Accept, Strict in What You Return

Function parameters should accept broad types โ€” union types, optional fields, multiple representations. Return values should be narrow and precise, giving callers the maximum information.

// Accept: broad types (union + optional)
interface RenderOptions {
  width: number | string;    // accepts 300 or "300px"
  height?: number;           // optional โ€” has a sensible default
  theme?: "light" | "dark";  // optional โ€” defaults to light
}

// Return: narrow types โ€” callers get exact information
interface RenderedOutput {
  html: string;
  width: number;             // normalized to number internally
  height: number;            // default filled in โ€” no longer undefined
  theme: "light" | "dark";   // resolved value โ€” no longer undefined
}

function render(options: RenderOptions): RenderedOutput {
  const width = typeof options.width === "string"
    ? parseInt(options.width, 10)
    : options.width;

  return {
    html: buildHtml(options),
    width,
    height: options.height ?? 200,
    theme: options.theme ?? "light",
  };
}

Principle 6: Don't Put Type Information in Variable Names

Names like userString or parsedUserObject embed type information in the identifier because the type itself is unclear. Good type design means the name describes what, the type describes the form.

// Antipattern: variable names carrying type information
const userJsonString = '{"name":"Alice"}';
const userParsedObject = JSON.parse(userJsonString);
const userValidatedObject = validate(userParsedObject);
// Fix: types carry the information, names carry the concept
const rawJson: string = '{"name":"Alice"}';
const parsed: unknown = JSON.parse(rawJson);
const user: User = parseUser(parsed); // parseUser validates internally

function parseUser(raw: unknown): User {
  if (typeof raw !== "object" || raw === null) {
    throw new Error("Invalid user data");
  }
  const obj = raw as Record<string, unknown>;
  if (typeof obj.name !== "string") {
    throw new Error("User must have a string name");
  }
  return { name: obj.name };
}

Principle 7: Prefer optional never Over Boolean for Exclusive-Or Fields

When two fields are mutually exclusive, a boolean flag cannot encode the constraint. optional never + discriminated union enforces it at compile time.

// Antipattern: two booleans for mutually exclusive states
interface ButtonProps {
  primary?: boolean;
  secondary?: boolean; // should be exclusive with primary โ€” type doesn't say so
}

const btn: ButtonProps = { primary: true, secondary: true }; // absurd but valid
// Fix: discriminated union with optional never
interface PrimaryButton {
  primary: true;
  secondary?: never;
}

interface SecondaryButton {
  secondary: true;
  primary?: never;
}

type ButtonProps = PrimaryButton | SecondaryButton;

const btn1: ButtonProps = { primary: true };                 // OK
const btn2: ButtonProps = { secondary: true };               // OK
const btn3: ButtonProps = { primary: true, secondary: true }; // Compile error

Principle 8: Names Should Reflect the Problem Domain

Type names should come from the business language, not from technical implementation. UserMap and PostArray leak implementation details. UserDirectory and Feed describe business concepts.

// Antipattern: technical terms pollute business types
type UserMap = Map<string, User>;
type PostArray = Post[];
function processUserMap(users: UserMap): PostArray { ... }
// Fix: domain language for naming
type UserDirectory = Map<UserId, User>; // "directory", not "map"
type Feed = Post[];                      // "feed", not "array"

function buildFeed(directory: UserDirectory): Feed { ... }

Principle 9: Avoid Types Based on Anecdotal Data

Seeing { tags: ["ts", "react"] } in one API response and typing tags as string[] is dangerous. The actual API might return objects, null, or omit the field entirely.

// Antipattern: infer type from one observed sample
interface Article {
  title: string;
  tags: string[]; // guessed from a single example โ€” probably wrong
}
// Fix: consult documentation or schema; use conservative types
// If docs say tags can be null:
interface Article {
  title: string;
  tags: string[] | null;
}

// If the shape is genuinely unknown, say so:
interface Article {
  title: string;
  tags: unknown; // forces callers to validate before use
}

// Best: use a schema validator that doubles as the type source
import { z } from "zod";
const ArticleSchema = z.object({
  title: z.string(),
  tags: z.array(z.string()).nullable(),
});
type Article = z.infer<typeof ArticleSchema>;

Principle 10: Imprecise Types Are Better Than Inaccurate Types

unknown is safer than a specific type that lies. string | number is safer than the wrong string. Prefer a wider honest type over a narrower dishonest one.

// Antipattern: the return type lies โ€” fetch can return anything
function fetchUser(id: string): Promise<User> {
  return fetch(`/api/users/${id}`).then(r => r.json()); // silent type assertion
}
// Runtime crash if server returns error shape โ€” compiler said nothing
// Fix: admit uncertainty at the boundary, validate before narrowing
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const raw: unknown = await res.json(); // honest: JSON deserialization yields unknown
  return parseUser(raw);                  // validate then return User
}

// With zod โ€” schema validates and infers type simultaneously
import { z } from "zod";
const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
  const raw = await fetch(`/api/users/${id}`).then(r => r.json());
  return UserSchema.parse(raw); // throws descriptive error on invalid shape
}

Principle 11: Limit Optional Properties โ€” Prefer Required with Defaults

Every optional field forces every callsite to handle the undefined case. If a property always has a value (even a default), make it required and handle the default in one place.

// Antipattern: many optional fields โ€” every callsite pays the ?? tax
interface TableConfig {
  pageSize?: number;
  sortField?: string;
  sortOrder?: "asc" | "desc";
  showHeader?: boolean;
  striped?: boolean;
}

function renderTable(data: Row[], config: TableConfig) {
  const size  = config.pageSize   ?? 10;
  const field = config.sortField  ?? "id";
  const order = config.sortOrder  ?? "asc";
  const show  = config.showHeader ?? true;
  const strip = config.striped    ?? false;
  // five ?? fallbacks in every function that touches config
}
// Fix: factory function applies defaults once; internal type is fully required
interface TableConfig {
  pageSize: number;
  sortField: string;
  sortOrder: "asc" | "desc";
  showHeader: boolean;
  striped: boolean;
}

function createTableConfig(overrides: Partial<TableConfig>): TableConfig {
  return {
    pageSize:   10,
    sortField:  "id",
    sortOrder:  "asc",
    showHeader: true,
    striped:    false,
    ...overrides,
  };
}

function renderTable(data: Row[], config: TableConfig) {
  // every field is guaranteed โ€” no ?? needed
  paginate(data, config.pageSize);
  sort(data, config.sortField, config.sortOrder);
}

Principle 12: Use readonly to Document Intent and Prevent Mutation Bugs

readonly is a declaration to readers: "this value should not change after creation." It makes accidental mutation a compile-time error instead of a subtle runtime bug.

// Antipattern: config object can be mutated anywhere
interface AppConfig {
  apiUrl: string;
  timeout: number;
  retries: number;
}

const config: AppConfig = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
};

// A function silently mutates the global config
function makeRequest(url: string, cfg: AppConfig) {
  cfg.timeout = 100; // modifies the caller's object โ€” no warning
}
// Fix: readonly at the field level
interface AppConfig {
  readonly apiUrl: string;
  readonly timeout: number;
  readonly retries: number;
}

// Or with the Readonly<T> utility type
const config: Readonly<AppConfig> = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
};

function makeRequest(url: string, cfg: Readonly<AppConfig>) {
  cfg.timeout = 100; // Compile error: Cannot assign to 'timeout' (read-only property)
}

// Deep readonly for nested objects
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface NestedConfig {
  db: { host: string; port: number };
}

const cfg: DeepReadonly<NestedConfig> = {
  db: { host: "localhost", port: 5432 },
};

cfg.db.host = "remote"; // Compile error โ€” nested mutation blocked

Summary Table

# Principle Primary Tool Cost of Ignoring
1 Make illegal states unrepresentable Discriminated union Contradictory runtime states
2 Union of interfaces over optional fields Union types Unconstrained field relationships
3 Push null to the perimeter Boundary validation Null propagates through all layers
4 Distinct types for distinct concepts Branded types Wrong argument, no error
5 Liberal in, strict out Postel's Law Callers lack information
6 No type info in variable names Proper naming Name rot
7 optional never for exclusive fields never + union Illegal combinations constructable
8 Names from problem domain Business vocabulary Semantic gap
9 No types from anecdotal data Schema/docs Type lies silently
10 Imprecise beats inaccurate unknown + validation Silent runtime crashes
11 Limit optional, prefer required Partial + factory Repeated ?? at every callsite
12 readonly for immutability intent readonly / DeepReadonly Accidental mutation

What's Next

Chapter 13 dives into Branded Types and Phantom Types โ€” the complete implementation of Principle 4. We cover factory functions for safe branded value creation, phantom types for compile-time state machines, and a real currency-amount type that prevents mixing USD and EUR at the type level.

Rate this chapter
4.6  / 5  (26 ratings)

๐Ÿ’ฌ Comments