1. The Problem: The “Optional Property Blob” Anti-Pattern#
When junior developers model dynamic states (like an HTTP request or a payment transaction), they frequently create a single monolithic interface filled with optional properties:
// ❌ ANTI-PATTERN: The Monolithic Optional Blob
interface BadAsyncState {
status: "idle" | "loading" | "success" | "error";
data?: string[];
error?: Error;
loadingTimeMs?: number;
}Why this design fails catastrophically:#
- Allows Impossible States: Nothing prevents a developer from constructing an object with
status: "loading"ANDdata: ["items"]ANDerror: new Error(). What does this object actually represent? - Requires Non-Null Assertions: Even inside
if (state.status === "success"), TypeScript viewsstate.dataasstring[] | undefined, forcing you to use unsafe non-null assertions (state.data!) or redundant optional chaining (state.data?.length).
function processBadState(state: BadAsyncState) {
if (state.status === "success") {
// ❌ Error: 'state.data' is possibly 'undefined'.
// console.log(state.data.length);
// Developer is forced to write redundant, unsafe checks:
if (state.data) {
console.log(state.data.length);
}
}
}2. The Solution: Discriminated Unions (Tagged Unions)#
In Type Theory, a Discriminated Union is an implementation of a Sum Type (an Algebraic Data Type).
Instead of one interface with optional properties, we create a Union of distinct, discrete interfaces, where each member interface possesses a common property with a unique literal type.
The 3 Requirements for a Discriminated Union#
- The Discriminant (Tag): A shared property name present in every member interface (e.g.,
kind,type,status). - Literal Types: The discriminant property in each member interface must have a distinct literal type (
"loading","success","error"). - The Union: A type alias uniting the discrete interfaces (
type State = Idle | Loading | Success | Error).
// 🟢 GOOD PATTERN: Discriminated Union
interface IdleState {
status: "idle";
}
interface LoadingState {
status: "loading";
startTime: number;
}
interface SuccessState {
status: "success";
data: string[];
fetchedAt: Date;
}
interface ErrorState {
status: "error";
error: Error;
}
// The Discriminated Union Type
type AsyncState = IdleState | LoadingState | SuccessState | ErrorState;3. How Control Flow Analysis Narrows Discriminants#
When you inspect the discriminant property using an if or switch statement, TypeScript’s Control Flow Analyzer automatically narrows the entire union variable to the specific member matching that literal tag!
function renderUI(state: AsyncState) {
switch (state.status) {
case "idle":
// Inferred: IdleState
console.log("Press start to load data.");
break;
case "loading":
// Inferred: LoadingState
// 'startTime' is guaranteed to exist! 'data' and 'error' CANNOT exist!
console.log(`Loading started at: ${state.startTime}`);
break;
case "success":
// Inferred: SuccessState
// 'data' is guaranteed to be string[] (NOT undefined)!
console.log(`Success! Loaded ${state.data.length} items.`);
break;
case "error":
// Inferred: ErrorState
// 'error' is guaranteed to be Error!
console.error(`Failed with message: ${state.error.message}`);
break;
}
}Why this changes everything:#
- Impossible States are Unrepresentable: You literally cannot create an
IdleStatethat accidentally containserror: Error. - Zero Optional Property Noise:
state.datainSuccessStateis typed asstring[], eliminating the need for?or!. - Intellisense Autocomplete: Inside
case "success", your IDE will only suggeststatus,data, andfetchedAt. It will hideerrorandstartTime.
4. Real-World Example: Redux Action Reducers#
The entire state management ecosystem of Redux, NgRx, and XState is built directly on Discriminated Unions:
interface AddTodoAction {
type: "ADD_TODO";
payload: { id: string; text: string };
}
interface ToggleTodoAction {
type: "TOGGLE_TODO";
payload: { id: string };
}
interface ClearCompletedAction {
type: "CLEAR_COMPLETED";
}
type TodoAction = AddTodoAction | ToggleTodoAction | ClearCompletedAction;
interface TodoState {
todos: Array<{ id: string; text: string; completed: boolean }>;
}
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "ADD_TODO":
// Action is narrowed to AddTodoAction
return {
...state,
todos: [...state.todos, { id: action.payload.id, text: action.payload.text, completed: false }],
};
case "TOGGLE_TODO":
// Action is narrowed to ToggleTodoAction
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo
),
};
case "CLEAR_COMPLETED":
// Action is narrowed to ClearCompletedAction
return {
...state,
todos: state.todos.filter((todo) => !todo.completed),
};
}
}5. Real-World Example: Payment Gateway Transactions#
interface CreditCardPayment {
method: "credit_card";
cardNumber: string;
cvv: string;
}
interface PayPalPayment {
method: "paypal";
payerEmail: string;
}
interface CryptoPayment {
method: "crypto";
walletAddress: string;
network: "ethereum" | "bitcoin";
}
type PaymentMethod = CreditCardPayment | PayPalPayment | CryptoPayment;
function processPayment(payment: PaymentMethod) {
if (payment.method === "credit_card") {
console.log(`Processing card ending in ${payment.cardNumber.slice(-4)}`);
} else if (payment.method === "paypal") {
console.log(`Redirecting to PayPal for email: ${payment.payerEmail}`);
} else {
console.log(`Waiting for ${payment.network} confirmation to ${payment.walletAddress}`);
}
}Summary & Next Steps#
In this episode:
- We demonstrated why the “Monolithic Optional Blob” anti-pattern allows impossible states and requires non-null noise.
- We constructed Discriminated Unions using the 3 core requirements (Discriminant, Literal Types, Union).
- We analyzed how
ifandswitchstatements refine discriminants effortlessly. - We explored production implementations in Redux Reducers and Payment Gateway workflows.
In Episode 12: Function Typing and the Void Type, we will master typing functions, arrow functions, parameter defaults, optional parameters, and the exact semantics of void!

