Now we get to the good stuff. You’ve seen literal types and union types on their own, but when you combine them, you unlock one of TypeScript’s most powerful patterns: the discriminated union. The name sounds fancy, but the concept is beautifully simple. It’s how we tell TypeScript, “Look, this object could be one of several shapes, and here’s the literal value you can check to know exactly which one it is.”

Think of it like a multi-tool. From the outside, it’s one object. But you need to know if you’re holding the screwdriver, the knife, or the bottle opener before you use it. The discriminant property—a literal type on a shared field—is the latch you flip to select the right tool.

The Anatomy of a Discriminated Union

Let’s build one from the ground up. We’ll model API responses because they are the classic, “please-don’t-screw-this-up” use case.

// First, we define our distinct states using literal types for a 'status' field.
type Success = {
  status: 'success'; // This is the literal type discriminant
  data: {
    id: string;
    name: string;
  };
};

type Loading = {
  status: 'loading';
};

type Error = {
  status: 'error'; // Note: all members use the same field name ('status')
  error: {
    message: string;
    code: number;
  };
};

// Now we combine them into a union type.
type ApiResponse = Success | Loading | Error;

The magic here is that every member of the union has a common property (status) whose type is a literal. TypeScript’s type narrowing sees this and goes, “Oh, I know this trick!” When you check response.status === 'success', it can immediately eliminate Loading and Error from the possible types of response within that code block. The type narrows exclusively to Success.

Why This is a Game-Changer

Before discriminated unions, you’d have to use a bunch of awkward, brittle checks like if ('data' in response) or if (response.hasOwnProperty('error')). Yuck. Those are implementation details that could change. The discriminant property (status) is a formal contract. It’s the official flag this object flies to declare its type. This makes your code incredibly robust and self-documenting.

Here’s how you use it in practice. Notice how the autocompletion and type safety are flawless.

function handleResponse(response: ApiResponse) {
  // At this point, TypeScript has no idea which type it is.
  // It's the entire union: Success | Loading | Error.

  switch (response.status) {
    case 'loading':
      // Now, TypeScript knows it's a Loading. `response.data` is an error here.
      return 'Show a spinner...';

    case 'success':
      // The type is narrowed to Success. We can safely access `response.data`.
      console.log(`Hello, ${response.data.name}`);
      break;

    case 'error':
      // Type is narrowed to Error. We can access `response.error`.
      console.error(response.error.message);
      break;

    default:
      // This is the killer feature: exhaustiveness checking.
      // If you add a new type to ApiResponse (e.g., 'cancelled') but forget
      // to handle it in this switch, TypeScript will warn you that the new type
      // is not assignable to the 'never' type here. It's a free unit test.
      const _exhaustiveCheck: never = response;
      return _exhaustiveCheck;
  }
}

Pitfalls and the ‘const’ Assertion Lifesaver

The most common way to break this is by being sloppy with the discriminant value. You might try to create a Success object like this:

const mySuccess = {
  status: 'success', // TypeScript infers this as string, not the literal 'success'
  data: { id: '1', name: 'Alice' }
};

// ERROR: Type '{ status: string; data: { id: string; name: string; }; }'
// is not assignable to type 'ApiResponse'.
const myResponse: ApiResponse = mySuccess;

Whoops. TypeScript saw 'success' and, because it can be changed, inferred its type as string, not the literal 'success'. The object doesn’t satisfy the ApiResponse contract anymore.

This is where const assertions earn their keep. They tell TypeScript to infer the narrowest possible types.

// The fix: use a const assertion to lock the literal type in place.
const mySuccess = {
  status: 'success' as const, // Now it's type 'success', not string
  data: { id: '1', name: 'Alice' }
};

// Alternatively, you can assert the whole object.
const myOtherSuccess = {
  status: 'success',
  data: { id: '1', name: 'Alice' }
} as const;

// Both work perfectly. No more assignment errors.
const myResponse: ApiResponse = mySuccess;

Choosing Your Discriminant Wisely

Don’t just use kind or type for every union. The property name should make sense in your domain. A network request has a status, a geometric shape has a kind, a UI event has a type. Picking a meaningful name makes the code read like plain English. And for the love of all that is holy, make the literal values clear and distinct. 'y' and 'n' are a maintenance nightmare; 'yes' and 'no' are forever. This pattern is your best friend for state management, Redux actions, configuration objects, and anywhere else you need to handle multiple, well-defined shapes without losing your mind. Use it liberally.