162: Unions, Intersections, Narrowing, Type Predicates, and Exhaustiveness
Learning outcomes
By the end of this lesson, you can:
- explain and apply union types in a realistic implementation;
- explain and apply discriminated unions in a realistic implementation;
- explain and apply built-in narrowing in a realistic implementation;
- explain and apply user-defined predicates in a realistic implementation;
- explain and apply assertion functions in a realistic implementation.
Prerequisites and retrieval
This lesson assumes the 01-06 foundation and the earlier lessons in this module. Before you begin, retrieve one concrete example from a previous project where this kind of modeling problem appeared. Perhaps a response could be loading, successful, or failed, or perhaps an input had more than one valid shape. The point is not to memorize a list of TypeScript terms. The point is to make a defensible design decision in a strict codebase, using the compiler to describe domain invariants while remembering that static types do not validate runtime data.
Terminology
- Union types: A union describes a value that may be one of several alternatives. Code can use only the operations that are safe for all alternatives until it has narrowed the value.
- Discriminated unions: A discriminated union gives each state a shared literal property, such as
statusorkind, so control flow can identify the specific state. - Built-in narrowing: TypeScript can narrow a type from checks such as
typeof,instanceof,in, equality comparisons, and control-flow analysis. Truthiness checks also narrow, but they need care. - User-defined predicates: A function whose return type is
value is Ttells the compiler that the function's runtime checks establish thatvalueis aT. - Assertion functions: An assertion return type such as
asserts value is Tis appropriate when failure throws and successful return establishes an invariant. - Intersection types: An intersection combines requirements. If incompatible primitives or discriminants are intersected, the result can be
never, because no value can satisfy both sides.
Mental model
Treat Unions, Intersections, Narrowing, Type Predicates, and Exhaustiveness as a design problem, not just a collection of syntax features. Start with observable inputs and outputs, state the invariants that must hold, and identify the failure modes. Finite alternatives are among TypeScript's strongest modeling tools, but they help only when runtime evidence can narrow the alternatives safely and impossible combinations have been excluded.
A good implementation makes its assumptions visible. It narrows uncertainty at system boundaries and leaves enough evidence, such as tests, types, constraints, metrics, or diagrams, to explain why the design is safe. The useful sequence in both an interview and a production change is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
Do not leap from a requirement to a library call. First write down what must remain true. Then select the TypeScript mechanism, runtime check, or external tool that enforces or verifies that condition.
Deep dive
1. Union types
The problem a union solves is straightforward: one value needs to represent several legitimate alternatives. A union means that the value may be one of those alternatives. Until control flow proves which one it is, TypeScript permits only operations that are safe for every member of the union.
Decision rule: Use a union type when it makes the contract or invariant easier to prove. If the union merely saves typing while hiding an assumption about the data, choose a more explicit design instead.
2. Discriminated unions
Several optional booleans and properties often describe a workflow badly. They can allow combinations that do not make sense, such as a state that is both loading and failed. A discriminated union gives each state a shared literal discriminator, such as status or kind. That makes workflows, asynchronous UI state, commands, and results easier to inspect and safer to handle.
Decision rule: Use a discriminated union when the explicit states make the contract or invariant easier to prove. If the discriminator only hides a broader, unmodeled assumption, make that assumption explicit rather than adding a field mechanically.
3. Built-in narrowing
Built-in narrowing is how TypeScript connects an observable runtime check to a more specific compile-time type. Common checks include typeof, instanceof, in, equality comparisons, and the control-flow analysis that follows those checks. Truthiness can narrow as well, but it is not a universal presence test: a check such as if (value) can exclude valid values like 0 and an empty string.
Decision rule: Use built-in narrowing when the check corresponds to evidence you can actually observe at runtime and makes the invariant easier to prove. If it only suppresses uncertainty without checking the relevant property, use a more precise condition.
4. User-defined predicates
When the same runtime shape check is needed in several places, a user-defined predicate can give that check a reusable name. A function returning value is T tells the compiler that the function's result establishes value as T on the true branch. That return type is a promise, not a proof supplied by the compiler. The implementation must really perform the necessary checks; otherwise every caller receives a false guarantee.
Decision rule: Use a user-defined predicate when its runtime checks genuinely establish the narrower type and the named check is useful to the domain. If the implementation cannot prove the predicate, do not encode the claim as a type predicate.
5. Assertion functions
An assertion function is useful at a boundary where invalid data must stop execution. asserts value is T tells TypeScript that a successful return establishes the invariant, while failure is expected to throw. Keep assertion functions small, test them directly, and place them close to the boundary they validate. An assertion is not a replacement for handling an expected failure when the caller should recover from that failure.
Decision rule: Use an assertion function when failure should throw and successful return establishes a clear invariant. If invalid input is a normal result that the caller needs to report or recover from, return a result or error instead.
6. Intersection types
An intersection says that one value must satisfy all of the requirements in the intersected types. This is useful for composing compatible domain capabilities. It is not a safe way to glue unrelated API types together. Intersecting incompatible primitives or discriminants can produce never, which means the requirements describe no possible value.
Decision rule: Use an intersection when domain composition makes the combined invariant easier to prove. If the members conflict or come from unrelated external contracts, model the relationship explicitly instead of mechanically intersecting them.
Worked example
Consider a strict TypeScript codebase in which the compiler helps describe domain invariants, but runtime validation still owns untrusted data. Begin by stating the requirement in one sentence. Then list the input and output contracts and decide which concept owns each failure mode. The key design move is separation: parsing and validation belong at the boundary; domain rules belong in the domain or service layer; persistence rules belong in the database or repository; and presentation rules belong in the client. Combining those responsibilities can make a happy-path demo shorter, but it makes edge cases much harder to reason about.
Here is a small result union. The ok property is the discriminator, so checking it narrows the result before the code reads either value or error:
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function unwrap<T>(result: Result<T>): T {
if (result.ok) return result.value;
throw new Error(result.error);
}
The type models a choice between success and failure. unwrap can read result.value only after the success check; on the other branch, TypeScript knows that error is available. The function intentionally turns the failure alternative into an exception, so its callers must use it only where throwing is the desired failure behavior. A caller that needs to display an error or retry should usually keep the Result instead of unwrapping it.
Walk through at least four cases: the normal path; an empty or missing value; a duplicate, retry, or concurrent path where that distinction applies; and a dependency failure. For each case, identify the layer that detects the problem and describe what the caller observes. That explanation is more valuable than merely showing that the code compiles, and it is the level of reasoning expected in a senior code review or technical interview.
Production perspective
Production correctness is broader than “the code works on my machine.” Consider deploys, retries, partial failures, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality inputs. Prefer explicit contracts, bounded resource usage, structured errors, and behavior that can be measured. Optimize after you can identify the bottleneck or risk with evidence, not because a mechanism sounds scalable.
When an external dependency is involved, define both timeout and cancellation behavior. When persistence is involved, define transaction and consistency expectations. When the feature exposes user-visible state, model loading, empty, error, stale, and success states rather than leaving those states implicit. When security is involved, assume the client can be modified and all network input is untrusted. TypeScript can describe the data you expect, but it cannot make an untrusted payload conform to that description by itself.
Guided lab
Refactor an asynchronous UI state that is currently represented by isLoading, error, and optional data into a discriminated union. Add a never exhaustiveness check so a new state cannot be silently ignored, and add a runtime parser for one external payload.
Complete the lab with this discipline:
- Write the requirement and two non-requirements.
- List input, output, and error contracts before implementation.
- Implement the smallest correct vertical slice.
- Add at least one invalid-input test and one edge-case test.
- Instrument or inspect the behavior instead of guessing.
- Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
- Explain one alternative design and why you did not choose it.
- Record a short “what would break at 10× scale?” note.
The parser is the runtime boundary in this exercise. The discriminated union should represent the states your UI can actually handle, while the never check makes exhaustiveness a compile-time obligation. Keep those responsibilities distinct: a type annotation is not a parser, and a parser does not automatically encode every domain rule.
Edge cases and failure modes
- Union types: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Discriminated unions: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Built-in narrowing: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- User-defined predicates: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Assertion functions: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
These cases are not all relevant to every value, so interpret the list against the domain. The habit to keep is to test the boundary conditions and the behavior that is easy to assume incorrectly, rather than proving only the ordinary success path.
Common mistakes and debugging
- Solving the example instead of the requirement: a copied pattern may be syntactically correct while being architecturally wrong for the real data.
- Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary”
anyvalues. - Testing only the happy path, which means the actual contracts are discovered only after integration.
- Optimizing before measuring, or choosing a scalable mechanism without a demonstrated scale requirement.
- Treating client-side behavior as a substitute for server-side authorization, validation, or persistence guarantees.
When debugging, reproduce the smallest failing case first. Inspect the actual value or execution plan, then trace the boundary where the invariant first became false. Fix the layer that owns the invariant rather than adding a downstream patch that merely hides the symptom. In TypeScript, check both the static model and the runtime value: a type predicate or assertion can make the compiler trust an incorrect claim.
Interview questions
- What problem do union types solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do discriminated unions solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does built-in narrowing solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do user-defined predicates solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do assertion functions solve, and what trade-off or failure mode would make you choose a different approach?
Checkpoint
Without notes, explain Unions, Intersections, Narrowing, Type Predicates, and Exhaustiveness to another developer in five minutes. Include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson's code. As a final check, be able to say which evidence narrows the value at runtime and which guarantee exists only during compilation.
Mastery checklist
- I can define the core terms precisely.
- I can choose a design from requirements instead of from habit.
- I can implement and test the normal path and edge cases.
- I can explain the runtime, storage, or complexity cost.
- I can identify which layer owns validation, errors, and recovery.
- I can compare at least two reasonable alternatives.
- I can explain how the design changes at larger scale or stricter reliability.
