10.1 Discriminated Unions: A Common Discriminant Field Pattern
Right, let’s talk about one of the most satisfying patterns you’ll use in TypeScript: the discriminated union. You’ve probably felt the pain of having a function that accepts a few different, but related, types. You check for one property to see if it’s type A, then another for type B, and your code becomes a fragile mess of if statements and type assertions. It feels clunky because it is clunky. Discriminated unions are how TypeScript and you formally agree on a system to tell these types apart.
The core idea is stupidly simple, which is why it’s so brilliant. You have a union of types, and each type in that union has a common field—the discriminant—with a unique literal value. It’s like every object in this group is holding up a specific, unique placard. TypeScript’s type checker can see that placard and immediately narrow the type down for you. No more guesswork.
The Anatomy of a Discriminated Union
Let’s start with a classic example. Imagine you’re handling events from a network request. You might get a UserUpdatedEvent, a UserDeletedEvent, or a PostCreatedEvent. Without a discriminant, this is a nightmare.
// The messy, non-discriminated way (DON'T DO THIS)
interface UserUpdatedEvent {
eventType: string; // Is it "UPDATE"? "UPDATED"? who knows!
userId: number;
newEmail: string;
}
interface UserDeletedEvent {
type: string; // Different field name! Madness!
id: number;
reason: string;
}
type AppEvent = UserUpdatedEvent | UserDeletedEvent;
function handleEvent(event: AppEvent) {
// How do you tell them apart? Check both? Ugh.
if ('eventType' in event) {
// TypeScript still thinks `event` could be either type here
console.log(event.userId); // Error: Property 'userId' does not exist on type 'AppEvent'
}
}
See? A complete mess. Let’s fix it by establishing a common discriminant.
// The correct, discriminated union way (DO THIS)
interface UserUpdatedEvent {
kind: 'USER_UPDATED'; // Literal type, not just string
userId: number;
newEmail: string;
}
interface UserDeletedEvent {
kind: 'USER_DELETED'; // Same field name, different literal value
userId: number;
reason: string;
}
// Behold, a properly discriminated union
type AppEvent = UserUpdatedEvent | UserDeletedEvent;
function handleEvent(event: AppEvent) {
// Now we check the common discriminant field
switch (event.kind) {
case 'USER_UPDATED':
// TypeScript knows it's a UserUpdatedEvent in this block
console.log(`Updating email for user ${event.userId} to ${event.newEmail}`);
break;
case 'USER_DELETED':
// And it knows it's a UserDeletedEvent here
console.log(`Deleting user ${event.userId} because: ${event.reason}`);
break;
}
// No default case needed; TypeScript knows we've exhausted all possibilities.
}
This is lightyears better. The kind field is our discriminant. It’s a common field across every member of the union, and its value is a literal type ('USER_UPDATED', 'USER_DELETED') that is unique to each member.
Why This Works So Well
TypeScript’s type narrowing isn’t magic; it’s based on straightforward logic. When you write event.kind === 'USER_UPDATED', the type checker can prove that within that branch of code, the type of event cannot be anything that doesn’t have a kind property with that exact value. Since we defined our union so that only UserUpdatedEvent has that value, it can confidently narrow the type. It’s a formal, logical deduction, and it’s rock solid.
Best Practices and Pitfalls
First, choose a discriminant name that makes sense. kind, type, and action are all common choices. Just be consistent. If you’re working with a third-party API that uses event_type, use that! The pattern works with any field name.
Second, use literal types. This is non-negotiable. A discriminant of string is useless. It must be a literal type like 'click', 'UPDATE', or even a literal number like 404.
Third, make the discriminant a readonly property. This isn’t enforced by TypeScript, but it’s a critical practice. The whole system falls apart if something can change the discriminant at runtime. For objects, make the field readonly.
interface UserUpdatedEvent {
readonly kind: 'USER_UPDATED'; // This should never change
userId: number;
newEmail: string;
}
A huge pitfall is forgetting to check every possible value. The beauty of a switch statement on a discriminant is that TypeScript can check for exhaustiveness. If you add a new event type to the AppEvent union but forget to handle it in your switch, the compiler can warn you. You can enforce this with a never type check in the default case.
function handleEvent(event: AppEvent) {
switch (event.kind) {
case 'USER_UPDATED':
// ... handle update
return;
case 'USER_DELETED':
// ... handle delete
return;
default:
// This is your exhaustiveness check!
// If `event` has any type other than 'never' here, you missed a case.
const _exhaustiveCheck: never = event;
return _exhaustiveCheck;
}
}
// Now, if you add a `PaymentProcessedEvent` to the union, the `default` case
// will have a type error because you're trying to assign it to `never`.
This pattern is your best friend. It turns a whole class of potential runtime errors into compile-time errors, which is exactly where we want them. It’s the type system working with you, not against you.