Q204: Union Types, Narrowing and Exhaustiveness
What Interviewers Want To Evaluate
Interviewers want to see whether you can model changing application states safely.
They look for union types, discriminated unions, control-flow narrowing, type guards, exhaustive checks, impossible states, and clean React rendering based on state.
Short Interview Answer
Union types let a value be one of several known shapes. TypeScript narrows unions through checks like typeof, in, equality comparisons, custom type guards, and discriminant fields. For complex UI states, I prefer discriminated unions because they make impossible states unrepresentable and allow exhaustive rendering with a never check.
Detailed Interview Answer
Frontend applications constantly deal with values that can be in different states.
Examples:
loading or loaded
success or error
authenticated or anonymous
draft or published
desktop or mobile
valid or invalid
Union types model this directly.
Basic Union
type Status = "idle" | "loading" | "success" | "error";
function getLabel(status: Status) {
if (status === "loading") {
return "Loading";
}
return "Ready";
}
TypeScript knows status can only be one of the listed strings.
Object Unions
Object unions are more powerful.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
Each state carries only the data that belongs to that state.
This avoids weak modeling like:
type BadRequestState = {
loading: boolean;
data?: string[];
error?: string;
};
The weak model allows impossible combinations.
loading true with data
loading true with error
data and error together
not loading with no data and no error
Discriminated unions remove those invalid combinations.
Narrowing By Discriminant
The status field is the discriminant.
function renderState(state: RequestState) {
switch (state.status) {
case "idle":
return "Start";
case "loading":
return "Loading";
case "success":
return state.data.join(", ");
case "error":
return state.message;
}
}
Inside each branch, TypeScript knows the exact shape.
Exhaustiveness
Exhaustiveness means every union member is handled.
A helper can enforce it:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${value}`);
}
Use it in the default branch.
function renderState(state: RequestState) {
switch (state.status) {
case "idle":
return "Start";
case "loading":
return "Loading";
case "success":
return state.data.join(", ");
case "error":
return state.message;
default:
return assertNever(state);
}
}
If a new state is added, TypeScript can flag the missing branch.
Narrowing With typeof
For primitive unions:
function format(value: string | number) {
if (typeof value === "number") {
return value.toFixed(2);
}
return value.toUpperCase();
}
TypeScript narrows value based on the runtime check.
Narrowing With in
For object unions:
type LinkItem = { href: string; label: string };
type ButtonItem = { onClick: () => void; label: string };
function renderAction(item: LinkItem | ButtonItem) {
if ("href" in item) {
return item.href;
}
return item.onClick;
}
The in operator helps TypeScript identify the available property.
Custom Type Guards
When checks repeat, create a guard.
type ApiError = {
code: string;
message: string;
};
function isApiError(value: unknown): value is ApiError {
return (
typeof value === "object" &&
value !== null &&
"code" in value &&
"message" in value
);
}
Now callers get narrowing.
function getErrorMessage(error: unknown) {
if (isApiError(error)) {
return error.message;
}
return "Something went wrong";
}
React Rendering Example
Discriminated unions fit React UI well.
type ProfileState =
| { status: "loading" }
| { status: "ready"; user: { name: string } }
| { status: "error"; retry: () => void };
function ProfilePanel({ state }: { state: ProfileState }) {
switch (state.status) {
case "loading":
return <Spinner />;
case "ready":
return <h2>{state.user.name}</h2>;
case "error":
return <button onClick={state.retry}>Try again</button>;
default:
return assertNever(state);
}
}
The component cannot accidentally render user data while still loading.
State Machines
Unions can model transitions too.
type EditorState =
| { mode: "view"; content: string }
| { mode: "edit"; draft: string }
| { mode: "saving"; draft: string }
| { mode: "failed"; draft: string; error: string };
This is often clearer than many booleans.
isEditing
isSaving
hasError
isDirty
Booleans multiply possible states.
Unions name the valid states.
Common Mistakes
Common mistakes include:
using optional properties instead of explicit states
forgetting exhaustive checks
using string instead of literal unions
writing type guards that are too weak
casting instead of narrowing
mixing UI state and server data without modeling transitions
The biggest mistake is allowing impossible states and then debugging them later.
Senior Trade-Offs
Unions add structure.
They are worth it when:
states are mutually exclusive
each state has different data
missing a case would be dangerous
UI rendering depends on state
actions differ by mode
For simple values, a basic union or boolean may be enough.
For workflows, use discriminated unions.
Interview Framing
In an interview, say:
I use discriminated unions to model UI and request states because they encode which data exists in which state. Then I use narrowing and exhaustive switches so adding a new state forces every rendering path to update.
That answer shows correctness and maintainability.
Revision Notes
- Union types represent known alternatives.
- Narrowing refines a union based on runtime checks.
- Discriminated unions use a shared literal field.
- Exhaustiveness protects future changes.
neveris useful for unreachable cases.- Good unions make impossible states unrepresentable.
Follow-Up Questions
- What is a discriminated union?
- How does TypeScript narrow a union?
- Why are many booleans risky for UI state?
- What is an exhaustive switch?
- When should you write a custom type guard?
How I Would Answer This In A Real Interview
I would explain that unions are how I model known alternatives in TypeScript. For serious UI state, I use discriminated unions because each state carries only the data that belongs to it. TypeScript can then narrow inside switch or if branches, and an exhaustive never check makes sure future states are handled. This improves React rendering because loading, success, error, and empty states become explicit instead of being inferred from optional properties.