Volume 4

Q204: Union Types, Narrowing and Exhaustiveness

Difficulty: SeniorFrequency: HighAnswer time: 8-12 minutes

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.
  • never is 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.