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.