cswork / libraryOpen practice workspace

Articles

TypeScript Discriminated Union Interview Questions: Narrowing and Exhaustive State Machines

Practice TypeScript discriminated unions with narrowing, exhaustive switches, state transitions, stale events, and runtime validation boundaries.

A TypeScript interface can look tidy while allowing a successful upload with no file URL, a failed upload with no error, and a progress counter attached to an idle screen. If every field is optional, the compiler cannot recover relationships that the model never expressed.

TypeScript discriminated union interview questions test whether you can encode those relationships and explain their limits. A union describes which shapes are valid. Narrowing selects a shape inside a branch. An exhaustive check helps expose missing cases when the model changes. None of those steps automatically validates incoming JSON or decides which state transitions are legal.

Evidence boundary: Language facts below are grounded in the official TypeScript handbook. The upload workflow, transition ledger, and code-review exercises are original practice material, not candidate reports or employer-specific questions. Apply them to Review and Improve Four AI-Assisted TypeScript Pull Requests.

Why does one interface with optional fields fail?

Consider this apparently convenient model:

type LooseUpload = {
  status: "idle" | "uploading" | "done" | "failed";
  sentBytes?: number;
  fileUrl?: string;
  error?: string;
};

It permits { status: "done" }. A renderer that checks status === "done" still cannot assume fileUrl exists. The annotation says the field is optional for every status, so a non-null assertion would hide a modeling problem rather than resolve it.

Write one variant for each meaningful state instead:

type Upload =
  | { status: "idle" }
  | { status: "uploading"; requestId: string; sentBytes: number }
  | { status: "done"; fileUrl: string }
  | { status: "failed"; error: string };

Now the success variant requires its URL. The uploading variant requires both progress and a request identity. That identity becomes useful when a late completion belongs to an earlier attempt.

Do not describe this as a universal ban on extra runtime properties. TypeScript is structurally typed, and assertions, external data, and assignment through other values can complicate excess-property checks. The useful claim is narrower: ordinary checked construction must satisfy a compatible variant, and consumers can rely on that variant's required fields within the static model.

Choose the discriminant from the domain. A literal status communicates more than a boolean whose meaning changes across functions. Reusing a tag for two variants with incompatible payloads weakens what checking that tag can establish.

What exactly does the discriminant let TypeScript narrow?

Official language behavior: the TypeScript narrowing handbook explains that a shared property with literal types can distinguish union members. Checking that property refines which member is possible. A never value can then represent the branch where no member remains.

For the upload model, the branch state.status === "done" provides evidence for state.fileUrl. Checking an unrelated boolean such as hasFinished does not necessarily connect that boolean to the object. The relationship must be visible to the compiler's control-flow analysis.

Keep the tag and payload together while you explain the first version. Read state.fileUrl inside the done branch. This is easier to reason about than distributing the discriminant and payload among independently reassigned variables and hoping their correlation survives.

Avoid an outdated blanket claim that destructuring always prevents narrowing. Supported analysis depends on the declaration, mutation, and compiler version. If a particular destructured form matters, compile the exact snippet. In an interview, preserving the object until after the switch is a clear starting point that avoids making the answer hinge on subtle inference behavior.

Also distinguish narrowing from mutation. Assigning a new value can change the compiler's view at a later point. Capture the specific validated value needed by a callback instead of assuming a mutable container will still hold the same variant when asynchronous work runs.

How do you make a renderer exhaustive?

Give the renderer a concrete return type and route the impossible branch through a helper that accepts never:

function unreachable(value: never): never {
  throw new Error(`Unexpected upload state: ${JSON.stringify(value)}`);
}

function describeUpload(state: Upload): string {
  switch (state.status) {
    case "idle":
      return "Choose a file";
    case "uploading":
      return `Sent ${state.sentBytes} bytes`;
    case "done":
      return state.fileUrl;
    case "failed":
      return state.error;
    default:
      return unreachable(state);
  }
}

The important check is the argument at unreachable(state). When all variants have been handled, no valid Upload remains there. If you add { status: "cancelled" } without updating the switch, the new variant cannot be passed to a parameter of type never.

A fallback such as return "Unknown" would silently absorb that new case. It may be appropriate at a deliberately tolerant external boundary, but it removes the compile-time reminder from this internal renderer.

Do not write unreachable(state as never). That assertion tells the compiler to accept the very claim the check was intended to prove. Likewise, passing an any through your state model undermines the protection.

The helper throws at runtime because runtime values can violate static assumptions. Logging raw state in a production exception may expose sensitive payloads; the demonstration uses a small synthetic model. A real application can report only the unexpected tag and a safe correlation identifier.

Does exhaustive handling create a correct state machine?

No. The renderer above accepts every valid shape, but it says nothing about how the program moves between shapes. An application could jump directly from idle to done using a fabricated URL and still construct a valid Upload object.

Separate shape validity from transition validity. For this exercise, a start event begins an attempt, progress updates apply only to that attempt, and completion or failure finishes it. Reset returns to idle. A late event with a different request ID leaves the current state unchanged.

Current stateEventExercise policy
IdleStart with request IDEnter uploading with zero bytes sent
UploadingProgress for current IDUpdate progress after validating its value
UploadingComplete for current IDEnter done with the validated URL
UploadingFail for current IDEnter failed with an error message
UploadingEvent for an older IDPreserve the current state
Done or failedProgressIgnore or report a protocol violation
Any stateResetEnter idle

The last two policies are design choices. Explain them rather than presenting them as TypeScript requirements. A product might allow retry directly from failed, keep historical progress, or prohibit reset while cancellation is pending.

An event union can ensure that a completion event has a URL and a progress event has a byte count. A reducer still needs to check the current state, event identity, and numerical constraints. Neither number nor a discriminated union proves that bytes are nonnegative, finite, or monotonically increasing.

If the interviewer asks for stronger compile-time transition restrictions, you can discuss state-specific functions or a typed transition API. That adds complexity and still cannot establish the truth of a remote event. Start with a readable reducer and make the additional guarantee explicit before introducing more types.

How should you handle stale asynchronous results?

Imagine attempt A starts, the user resets, and attempt B starts. A then completes after B. Both completion events can be perfectly well typed. Applying A's URL to B's screen is still a behavioral bug.

Use a request identity that is unique for the relevant operation lifetime. When handling a completion, first establish that the current state is uploading, then compare the event ID with the active request ID. If they differ, retain the current state. Test the out-of-order sequence explicitly.

Cancellation and identity checks solve different problems. Cancellation can reduce wasted work, while the identity check protects against a completion that already escaped cancellation or came through another delivery path. Do not claim that adding an AbortController makes all stale events impossible.

A useful interview trace is A-start, reset, B-start, A-complete, B-progress, B-complete. Write the expected state after each event before implementing the reducer. At A-complete, B must still be uploading. At B-complete, only B's URL should appear.

This is where the union becomes useful without pretending to solve concurrency by itself: it tells you which payloads are present, while the transition logic checks whether the event belongs to the current operation.

What changes when success data should remain during refresh?

Do not force every product into idle, loading, success, and error just because that pattern is familiar. An application may intentionally show the previous file while refreshing metadata. Calling that combination “impossible” would be a product-modeling mistake.

Add an explicit refreshing variant with the old data and a new request identity. Decide what a refresh failure means: should the old file remain visible with a warning, or should the screen become a full failure state? Those choices determine the variants.

The design question is which combinations are meaningful. A separate refreshing variant might require fileUrl and requestId; a refreshFailed variant might require fileUrl and error. Every consumer must then decide how to render those combinations.

Run the missing-case experiment again after changing the union. A compile error in a renderer is useful evidence that the model change reached that consumer. It does not prove every downstream effect or analytics event has been updated. Search for other consumers and test the changed behavior.

Keep the vocabulary consistent across the API boundary, reducer, and UI. Translating a remote status into a different internal state is often sensible, but the mapping should be deliberate and tested.

Why does a type assertion not validate incoming JSON?

Official language fact: the Everyday Types handbook explains that type assertions are removed during compilation and do not perform runtime checking. Writing raw as Upload cannot create a missing URL or reject an unknown status.

Treat an external payload as unknown. Validate that it is an object, inspect the tag, and check the required fields for the selected variant. For a progress value, also apply the domain checks that plain number cannot express. Only then construct or return a trusted internal value.

A user-defined predicate can lie if its implementation only checks the tag and ignores the payload. Review its body with the same care as ordinary validation code. A schema library can reduce repetition, but its schema still needs to match the intended contract.

Versioning adds a policy decision. If a server introduces a new status before an older client deploys, that client needs an explicit boundary behavior: reject, map to a safe unsupported state, or use a documented compatibility path. An internal exhaustive switch is not a substitute for a wire-protocol rollout strategy.

How can you demonstrate the guarantees during practice?

Use both positive and negative checks. Compile the valid renderer under strict checking. Then remove the URL from a done value and confirm that construction fails. Add a cancelled variant and confirm that the unchanged renderer fails at the never argument. Restore the complete implementation afterward.

For runtime behavior, test idle rendering, uploading progress, success output, and failure output. Separately test the reducer's stale-ID and invalid-transition policies. A compile-only test cannot tell whether your switch returns the wrong message for an otherwise valid branch.

Record the compiler version and flags with the exercise. An editor appearing happy is weaker evidence than an explicit check of the exact example, particularly when the interviewer asks about a version-sensitive inference detail.

Sources and Further Reading