Imagine a box that can contain one of several things: a toy, a snack, or a note. The easiest way to know what’s inside is to put a label on the outside, like toy, snack, or note. Then you don’t need to guess based on the contents; you just read the label and handle it correctly.
That’s the core idea behind unions and narrowing in TypeScript. A union says “this value could be one of several shapes,” and narrowing is how TypeScript helps you figure out which one you have right now. This matters a lot for state, API responses, and any place where values can legitimately change form.
Unions, Intersections, and Why Narrowing Exists
A union type uses | to mean “either this or that.” For example, a value might be string | number, or a network result might be Loading | Success | Error. A union of object types is especially useful when each variant represents a distinct state.
An intersection type uses & to mean “both at once.” It’s how you combine capabilities, like an object that is both User and WithPermissions.
Narrowing exists because code often starts with a broad union, but inside a branch you know more. TypeScript’s control-flow analysis tracks checks like if, switch, typeof, in, instanceof, and custom guards, then refines the type so you can safely access the right properties. That’s why unions are powerful: they model reality, and narrowing lets you work with reality without unsafe casts.
Step 1: Model States as Literal Discriminated Unions
The safest way to model multiple states is with a discriminated union: each variant has a shared field whose value is a literal type. That shared field is often called the discriminant or tag.
For example, a request state can be:
{ status: 'loading' }
{ status: 'success'; data: string }
{ status: 'error'; message: string }
The status field is the label on the box. Because it’s a literal type like 'loading', TypeScript can tell the variants apart exactly. This is much safer than “optional fields everywhere,” because the type system can connect the label to the correct payload.
type Loading = { status: 'loading' };
type Success = { status: 'success'; data: string };
type Failure = { status: 'error'; message: string };
type Result = Loading | Success | Failure;
Step 2: Narrow with Control Flow
Once you have a union, TypeScript narrows it when you test the discriminant. Inside if (result.status === 'success'), the type becomes Success, so result.data is safe.
This is control-flow narrowing: TypeScript remembers what you proved in each branch. The same idea works with switch, which is often the cleanest choice for discriminated unions.
Narrowing matters because without it, you would be forced into unsafe property checks or type assertions. With it, the compiler becomes a partner: it lets you access only what’s valid for the current branch, and it rejects impossible access early.
function render(result: Result) {
if (result.status === 'success') {
return result.data.toUpperCase();
}
if (result.status === 'error') {
return result.message;
}
return 'Loading...';
}
Step 3: Use Built-in Guards and Custom Type Predicates
TypeScript narrows not only on tags, but also on type guards. Built-in guards include typeof for primitives, instanceof for class instances, and in for property presence.
Sometimes you need a reusable check. A custom type guard is a function that returns a predicate like value is Success. That tells TypeScript, “if this function returns true, treat the value as this specific type.”
This is especially useful when data comes from outside your code, like parsing API responses. The guard lets you isolate validation logic and still get precise types in the calling code. It is a better option than assertions because it preserves type safety instead of pretending.
function isSuccess(r: Result): r is Success {
return r.status === 'success';
}
function handle(r: Result) {
if (isSuccess(r)) {
return r.data.length;
}
return 0;
}
Step 4: Combine Variants with Intersections When Needed
An intersection is useful when every union member should also carry shared capabilities. For example, you might have Result & { requestId: string } if every state also needs tracing metadata.
This means the object must satisfy both sides at once: the union member’s shape and the shared fields from the intersection. Intersections don’t replace unions; they complement them.
In interviews, a common pattern is asking whether you would duplicate requestId in each variant or factor it out. An intersection can keep the model DRY while preserving the discriminated union’s narrowing power. The key is to keep the discriminant intact, because that’s what enables safe branching.
type WithRequestId = { requestId: string };
type TrackedResult = (Loading | Success | Failure) & WithRequestId;
function log(r: TrackedResult) {
console.log(r.requestId, r.status);
}
Worked Example: Async Fetch State Machine
Suppose a UI fetches a profile and needs to represent exactly one of three states. A discriminated union models this cleanly, and switch makes rendering straightforward.
The important part is that each branch gets the right payload without optional chaining everywhere. If you add a new state later, TypeScript can force you to update all the places that handle it.
This is why unions are a superpower for state: they make invalid states unrepresentable, and they make valid transitions easier to reason about.
type State =
| { status: 'loading' }
| { status: 'success'; profile: { name: string } }
| { status: 'error'; error: string };
function view(state: State) {
switch (state.status) {
case 'loading': return 'Please wait...';
case 'success': return state.profile.name;
case 'error': return state.error;
}
}
Exhaustiveness Check with `never`
A strong interview answer includes exhaustiveness checking. You can add a default branch that assigns to never; if a new union member is introduced, the assignment fails and you catch the missing case.
This pattern proves you handled every variant. It is especially useful in reducers, UI render functions, and protocol handlers.
The point is not the default itself, but the guarantee that no case slips through silently. TypeScript uses never to represent code paths that should be impossible.
function assertNever(x: never): never {
throw new Error('Unexpected value: ' + x);
}
function view(state: State) {
switch (state.status) {
case 'loading': return 'Loading';
case 'success': return state.profile.name;
case 'error': return state.error;
default: return assertNever(state);
}
}
Common Traps Interviewers Set
Watch for these common mistakes:
Using string instead of a literal type for the discriminant, which destroys narrowing.
Making fields optional across one big type instead of using a union of precise states.
Forgetting that in checks property presence, not exact shape; it can narrow, but only when the property is truly discriminative.
Overusing as instead of a type guard; assertions silence the compiler and hide bugs.
Missing exhaustiveness in switch, which means a new variant can compile but break logic later.
Interviewers often ask what happens when the union grows. The best answer is that discriminated unions plus never make the compiler tell you exactly where code must be updated.