A boolean named loading looks harmless until it meets data, error, and hasLoadedOnce. Which combinations are meaningful? Can data and an error coexist? Does loading: false mean idle, successful, or failed? A handful of independent fields can describe far more states than the application actually supports, and TypeScript will faithfully allow every one of them.
Algebraic data types offer a better vocabulary. Product types combine values that exist together; sum types choose exactly one alternative. TypeScript does not use the formal terms in its syntax, but object types, unions, literal types, and never are enough to model them effectively. Once alternatives carry a discriminant, ordinary control flow becomes a practical pattern-matching system, and the compiler can prove that every case has been handled.
The goal is not to make application code look academic. It is to move domain rules into the shape of the data, so invalid combinations become hard or impossible to construct and future changes produce useful compiler errors.
Products and Sums: The Algebra Behind the Types
A product type contains all of its fields at once. If a Point contains an x and a y, a value is chosen from the Cartesian product of their possible values:
type Point = {
x: number;
y: number;
};
type User = {
id: string;
displayName: string;
};
The number of possible values multiplies. If type A has inhabitants and type B has , then a pair has
A sum type contains one alternative or another. TypeScript spells it with a union:
type ContactMethod =
| { kind: 'email'; address: string }
| { kind: 'sms'; phoneNumber: string };
Its alternatives add rather than multiply: . The kind field is the discriminant. It gives each alternative a stable name and lets TypeScript narrow the remaining fields. An email contact cannot accidentally contain only a phone number because that object satisfies neither member.
The distinction gives a useful design test:
| Question | Product type | Sum type |
|---|---|---|
| Relationship | Values exist together | Exactly one alternative exists |
| TypeScript form | Object, tuple | Union, usually discriminated |
| Typical words | “and”, “has” | “or”, “one of” |
| Example | User has an ID and name | Contact is email or SMS |
| Common mistake | Making required fields optional | Replacing alternatives with booleans |
Optional properties mix both ideas. { value?: T } roughly says that the object has a value or does not, but it often lacks a name for the absent case. When absence has domain meaning, make the sum explicit:
type Option<T> =
| { kind: 'some'; value: T }
| { kind: 'none' };
const findUser = (id: string): Option<User> =>
id === 'u_123'
? { kind: 'some', value: { id, displayName: 'Ada' } }
: { kind: 'none' };
Option<T> is more verbose than T | undefined, but it pays for itself when “not found” must travel through several layers without being confused with an omitted argument, an uninitialized variable, or a programming error.
Make Illegal States Unrepresentable
Consider a request represented by independent fields:
type FragileRequest<T> = {
loading: boolean;
data?: T;
error?: string;
};
This admits loading with stale data and an error, neither loading nor having any outcome, and every other combination. Some may be intentional, but the type does not say which. A discriminated union encodes the actual state machine:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading'; requestId: string }
| { status: 'success'; data: T; receivedAt: number }
| { status: 'failure'; error: AppError; retryable: boolean };
type AppError =
| { kind: 'network'; message: string }
| { kind: 'unauthorized' }
| { kind: 'invalid-response'; issues: string[] };
Each state owns exactly the data that is valid there. A successful request must have data and cannot have an error. A failure must explain what went wrong. The richer error union also prevents callers from parsing message strings to decide whether login or retry UI should appear.
Events should be a sum too. The reducer then documents legal transitions in executable form:
type RequestEvent<T> =
| { type: 'load'; requestId: string }
| { type: 'resolve'; requestId: string; data: T; receivedAt: number }
| { type: 'reject'; requestId: string; error: AppError };
function transition<T>(
state: RequestState<T>,
event: RequestEvent<T>,
): RequestState<T> {
switch (event.type) {
case 'load':
return { status: 'loading', requestId: event.requestId };
case 'resolve':
if (state.status !== 'loading' || state.requestId !== event.requestId) {
return state;
}
return {
status: 'success',
data: event.data,
receivedAt: event.receivedAt,
};
case 'reject':
if (state.status !== 'loading' || state.requestId !== event.requestId) {
return state;
}
return {
status: 'failure',
error: event.error,
retryable: event.error.kind === 'network',
};
}
}
The requestId guards against a slow earlier response overwriting a newer request. Types cannot prevent every temporal bug, but they can ensure the information needed to prevent one is present.
Note
“Impossible” means impossible through type-checked construction. Type assertions, any, malformed network data, and mutated JavaScript can still violate the model. Static guarantees must be paired with runtime validation at untrusted boundaries.
Exhaustive Matching Turns Change into Feedback
TypeScript narrows a discriminated union inside switch, if, and conditional expressions. A small assertNever helper converts that narrowing into an exhaustiveness check:
function assertNever(value: never): never {
throw new Error(`Unexpected variant: ${JSON.stringify(value)}`);
}
function requestLabel<T>(state: RequestState<T>): string {
switch (state.status) {
case 'idle':
return 'Not started';
case 'loading':
return 'Loading';
case 'success':
return 'Ready';
case 'failure':
return state.retryable ? 'Try again' : 'Failed';
default:
return assertNever(state);
}
}
If a cancelled member is later added to RequestState, state in the default branch is no longer never. Compilation fails at every exhaustive matcher that needs a policy for cancellation. That is valuable change amplification: instead of searching for conditionals that might care, the type checker produces a work list.
A reusable matcher can make value-producing branches concise while preserving the same guarantee:
type Result<T, E> =
| { kind: 'ok'; value: T }
| { kind: 'err'; error: E };
function matchResult<T, E, R>(
result: Result<T, E>,
cases: { ok: (value: T) => R; err: (error: E) => R },
): R {
switch (result.kind) {
case 'ok':
return cases.ok(result.value);
case 'err':
return cases.err(result.error);
default:
return assertNever(result);
}
}
const message = matchResult(saveUser(), {
ok: (user) => `Saved ${user.displayName}`,
err: (error) => `Could not save: ${formatError(error)}`,
});
Libraries such as ts-pattern add nested patterns, guards, selections, and fluent .exhaustive() checks. They are useful when matching deep structures. Native switch remains an excellent default: it has no dependency, is familiar to every TypeScript reader, and its control-flow narrowing is transparent.
Warning
Avoid a broad default that returns a fallback. It silences the compiler when a new variant appears. Reserve the default branch for assertNever, or omit it and assign the result to a declared return type when the compiler can prove all paths return.
Result Makes Expected Failure Part of the Contract
Exceptions are appropriate for broken invariants and failures a layer cannot reasonably recover from. They are weaker for expected outcomes such as validation failure, duplicate email, or optimistic-lock conflict because the function signature hides them.
Result<T, E> makes those outcomes explicit and composable:
type RegistrationError =
| { kind: 'invalid-email'; input: string }
| { kind: 'duplicate-email'; email: string }
| { kind: 'storage-unavailable'; cause: unknown };
function parseEmail(input: string): Result<string, RegistrationError> {
const email = input.trim().toLowerCase();
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)
? { kind: 'ok', value: email }
: { kind: 'err', error: { kind: 'invalid-email', input } };
}
function mapResult<T, E, U>(
result: Result<T, E>,
transform: (value: T) => U,
): Result<U, E> {
return result.kind === 'ok'
? { kind: 'ok', value: transform(result.value) }
: result;
}
The caller must inspect kind before using the value. Error variants can carry structured context without exposing transport details to the domain. At an HTTP boundary, one exhaustive matcher can translate invalid-email to 400, duplicate-email to 409, and storage-unavailable to 503. At a UI boundary, another matcher supplies localized messages.
This is not a demand to replace every throw. A filesystem disappearing during startup may be exceptional; a user submitting an occupied username is not. The useful question is whether callers are expected to make a decision based on the failure. If yes, put it in the return type.
Serialization Is a Boundary, Not a Type Assertion
Discriminated unions serialize naturally because the tag survives JSON. That does not make parsed JSON trustworthy. JSON.parse returns any, and as RequestState<User> checks nothing at runtime. Decode unknown data before it enters the typed core:
type UserPayload = { id: string; displayName: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function decodeUser(value: unknown): Result<UserPayload, string[]> {
if (!isRecord(value)) {
return { kind: 'err', error: ['Expected an object'] };
}
const issues: string[] = [];
if (typeof value.id !== 'string') issues.push('id must be a string');
if (typeof value.displayName !== 'string') {
issues.push('displayName must be a string');
}
return issues.length > 0
? { kind: 'err', error: issues }
: {
kind: 'ok',
value: {
id: value.id as string,
displayName: value.displayName as string,
},
};
}
For real schemas, a runtime validation library can remove repetitive checks and infer the static type from the schema. The architectural rule is more important than the library: accept unknown, validate once at the edge, then pass a trusted domain value inward.
Tags are also wire-format decisions. Renaming 'failure' to 'error' breaks persisted records, queued messages, URLs, and older clients even if local TypeScript compiles. Use stable, boring tag strings and treat them as part of the protocol.
Version persisted unions when their shape must evolve:
type StoredSettings =
| { version: 1; theme: 'light' | 'dark' }
| { version: 2; colorScheme: 'light' | 'dark' | 'system' };
type CurrentSettings = Extract<StoredSettings, { version: 2 }>;
function migrateSettings(value: StoredSettings): CurrentSettings {
switch (value.version) {
case 1:
return { version: 2, colorScheme: value.theme };
case 2:
return value;
default:
return assertNever(value);
}
}
Validate the historical shape first, migrate it, and let the rest of the application see only the current model. Migration tests should keep fixtures for every supported version; otherwise a refactor can accidentally erase the only path from old data.
Evolution, Tradeoffs, and Practical Limits
ADTs make evolution visible, but they do not make it free. Adding a field to one product requires every constructor to supply it. Adding a member to a sum requires every exhaustive consumer to choose behavior. Those compile failures are the cost accounting for a genuine domain change.
There are cases where open extension matters more than closed-world exhaustiveness. A plugin registry cannot know every future plugin at compile time; a plain map from string keys to handlers may be more honest than a fixed union. Large unions can also produce noisy error messages, and deeply generic matcher helpers can slow the type checker or obscure straightforward control flow.
Keep domain unions focused. A single ApplicationEvent containing hundreds of unrelated variants creates coupling because every consumer appears to depend on the whole system. Prefer bounded unions such as BillingEvent and EditorEvent, then translate between boundaries explicitly.
Structural typing has another limit: two concepts with the same shape are assignable. If mixing UserId and OrderId would be dangerous, add branded types or constructors that validate them. ADTs model alternatives and combinations; they do not automatically provide nominal identity, units, authorization, or runtime immutability.
Tip
Start where invalid combinations already cause branches, bugs, or defensive checks. Replacing three correlated booleans with one discriminated union usually delivers more value than introducing a generic functional toolkit across an entire codebase.
Tests still matter. Exercise each variant, transition, decoder failure, and migration fixture. Exhaustiveness proves that code addresses every named alternative; it does not prove that the behavior chosen for each alternative is correct.
Takeaways
- Product types model values that exist together; sum types model mutually exclusive alternatives.
- A stable discriminant turns TypeScript unions into precise domain models and enables control-flow narrowing.
Optionnames meaningful absence, whileResultexposes expected failure in a function’s contract.- State and event unions remove contradictory combinations and make transitions reviewable.
assertNeverand exhaustive matching turn a new variant into compiler-guided migration work.- Validate
unknownat serialization boundaries, keep tags stable, and version persisted shapes deliberately. - Use ADTs where the domain is closed enough to enumerate; prefer open registries for genuinely extensible systems.
Good types do not eliminate change or failure. They put both where engineers can see them: in constructors, return values, transition functions, decoders, and compiler errors. That visibility is the practical advantage of algebraic data types.