Discriminated Unions
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/ifblocks 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
instanceofnarrowing 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/elsechecks or Enums for complex state?” - “How does TypeScript’s type narrowing work when you check a discriminant property?”
Answers by Technology
+ Add VariantExpected Answer (Java 26)
In modern Java, Discriminated Unions are implemented using Sealed Classes (or Interfaces) combined with Records and Pattern Matching.
- Sealed Classes: Restrict which classes can extend or implement them.
- Records: Provide a concise way to define immutable data carriers.
- 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
defaultcase, 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
sealedprevents the compiler from performing exhaustiveness checks.
Follow-up Questions
- Sealed vs Final? (Answer:
finalallows ZERO subclasses;sealedallows 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.
- Enums: Introduced in PHP 8.1. They can have methods and implement interfaces.
- Match Expression: A strictly typed, exhaustive version of
switch. - DTOs with Type Hinting: Using
readonlyproperties 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
defaultarm in amatchexpression 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
stringornumberinstead of a literal type (e.g.,'success'). TypeScript cannot narrow the type unless the tag is a specific literal value. - Forgetting the
nevercheck: 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).