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.