Medium15 minType Systems
UpdatedAug 1, 2026
Edit

Discriminated Unions

CONCEPTS:Structural vs Nominal Typing

Question Variations

  • "What is a 'discriminated union' (or 'tagged union') in TypeScript?"
  • "How do you ensure exhaustive type checking when using a switch statement with a union type?"
  • "Why are discriminated unions often preferred over simple `if/else` checks or Enums for complex state?"
  • "How does TypeScript's type narrowing work when you check a discriminant property?"

Why This Is Asked

Discriminated unions are one of TypeScript’s most powerful patterns. Interviewers want to see if you can model domain state safely using union types and ensure compile-time exhaustiveness — a key skill for building maintainable, type-safe applications.

Key Concepts

  • A discriminated union is a union of types sharing a common literal property (the “discriminant” or “tag”)
  • TypeScript narrows the type in switch/if blocks based on the discriminant value
  • Exhaustiveness checking via never: if a new variant is added, unhandled cases cause a compile error
  • Preferred over enums for modeling state machines (e.g., { status: "loading" } | { status: "error", message: string })
  • Works with instanceof narrowing for class hierarchies, but literal discriminants are more common in TS

Question Variations

  • “What is a ‘discriminated union’ (or ‘tagged union’) in TypeScript?”
  • “How do you ensure exhaustive type checking when using a switch statement with a union type?”
  • “Why are discriminated unions often preferred over simple if/else checks or Enums for complex state?”
  • “How does TypeScript’s type narrowing work when you check a discriminant property?”

Answers by Technology

+ Add Variant

Expected Answer (Java 26)

In modern Java, Discriminated Unions are implemented using Sealed Classes (or Interfaces) combined with Records and Pattern Matching.

  1. Sealed Classes: Restrict which classes can extend or implement them.
  2. Records: Provide a concise way to define immutable data carriers.
  3. Pattern Matching for switch: Allows the compiler to check for exhaustiveness.

Why It Matters

Before Java 17, modeling “One of these several things” required complex Visitor patterns or instance-of chains. Sealed classes allow the JVM and compiler to understand the complete set of possible subtypes, enabling safer and more expressive domain modeling.

Code Example

public sealed interface Shape permits Circle, Square, Rectangle {}

public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Rectangle(double w, double h) implements Shape {}

public double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Square s -> s.side() * s.side();
        case Rectangle r -> r.w() * r.h();
        // No 'default' needed if all permitted types are covered!
    };
}

Common Mistakes

  • Using ‘default’ in switch: If you include a default case, you lose the exhaustiveness check. If a new permitted subclass is added later, the compiler won’t warn you that it’s unhandled.
  • Open hierarchies: Using standard interfaces instead of sealed prevents the compiler from performing exhaustiveness checks.

Follow-up Questions

  • Sealed vs Final? (Answer: final allows ZERO subclasses; sealed allows a SPECIFIC list of subclasses).
  • Permits Clause: When can it be omitted? (Answer: When the subclasses are defined in the same file as the sealed class).

Expected Answer (PHP 8.5)

PHP does not have native Discriminated Unions like TypeScript, but it uses Enums and Match Expressions to achieve similar type-safe results.

  1. Enums: Introduced in PHP 8.1. They can have methods and implement interfaces.
  2. Match Expression: A strictly typed, exhaustive version of switch.
  3. DTOs with Type Hinting: Using readonly properties and Union Types (string|int) to model complex data.

Why It Matters

Before PHP 8, developers relied on strings or integers (constants) to represent states, which were prone to typos and lacked type safety. PHP 8.1 Enums combined with match provide compile-time (or rather, runtime-check) safety for state machines.

Code Example

enum Status {
    case Pending;
    case Active;
    case Deleted;
}

function getLabel(Status $status): string {
    return match($status) {
        Status::Pending => 'Waiting...',
        Status::Active  => 'Live',
        Status::Deleted => 'Archived',
        // 'match' is exhaustive; if Status::Deleted was missing, 
        // it would throw an UnhandledMatchError.
    };
}

Common Mistakes

  • Using ‘default’ in match: Similar to Java/TypeScript, adding a default arm in a match expression bypasses the exhaustiveness check, making it harder to find missing cases when the Enum grows.
  • Backed Enums vs Pure Enums: Forgetting that Pure Enums cannot be directly serialized to a string or integer without using a Backed Enum (enum Status: string).

Follow-up Questions

  • Can PHP Enums have properties? (Answer: No, but they can have methods and constants).
  • How do you simulate a union of types in a parameter? (Answer: Using Union Types, e.g., string|int|null).

Expected Answer

A Discriminated Union (also known as a Tagged Union) is a pattern where several types share a common property with a literal value (the “discriminant” or “tag”). TypeScript’s type narrowing logic uses this property to differentiate between members of the union with 100% type safety.

To ensure Exhaustiveness Checking, you can use the never type in a default block. If the union is ever expanded but the handling logic isn’t, the code will fail to compile.

Why It Matters

This is the gold standard for modeling state in TypeScript (e.g., API responses, Redux actions). It eliminates the need for manual type casting (as) and prevents bugs where you accidentally access a property that only exists on a different member of the union. It forces the developer to handle every possible state of the data.

Example Code

interface Success { kind: 'success'; data: string; }
interface Error { kind: 'error'; message: string; }
interface Loading { kind: 'loading'; }

type ApiResponse = Success | Error | Loading;

function handleResponse(res: ApiResponse) {
  switch (res.kind) {
    case 'success':
      return res.data; // narrowed to Success
    case 'error':
      return res.message; // narrowed to Error
    case 'loading':
      return 'Loading...';
    default:
      // Exhaustiveness check
      const _exhaustive: never = res;
      throw new Error(`Unhandled case: ${_exhaustive}`);
  }
}

Common Mistakes

  • Using non-literal discriminants: Trying to use a string or number instead of a literal type (e.g., 'success'). TypeScript cannot narrow the type unless the tag is a specific literal value.
  • Forgetting the never check: Losing the compile-time safety that ensures new members of the union are handled when they are added later.

Follow-up Questions

  • Can you use a boolean as a discriminant? (Answer: Yes, it works for unions of two types, but you lose the descriptive “tag” name).
  • How does this relate to Pattern Matching? (Answer: It is the foundation for pattern matching in functional languages, and while TS doesn’t have native pattern matching syntax yet, discriminated unions provide the same safety).

References