Skip to Content
Course content

141: The never Type and Exhaustiveness Checking

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was working on a payment processing engine that used a discriminated union to track transaction statuses: Pending, Completed, and Failed. We had a giant switch statement that handled the UI logic for each state. Everything was working perfectly until we decided to add a Refunded state. I updated the type definition, but I completely missed one specific switch block in a legacy notification component. Because that block had a generic default: return null; case, TypeScript didn't complain. The code compiled, we deployed, and users whose payments were refunded simply saw a blank screen with no explanation. It was a classic "silent failure" that could have been prevented if the compiler had just screamed at me the moment I added Refunded to the union.

The Logic of the Impossible

To understand how to prevent that mistake, we have to talk about the never type. You've likely encountered void, which is used for functions that return nothing. never is different; it represents the type of values that cannot exist. It's not just "nothing"—it's an impossibility.

You'll most commonly see never in two places. First, in functions that never return—like a function that always throws an error or an infinite loop. Second, and more importantly for us today, as the result of "narrowing" a union type until there are no possibilities left. If you have a union of string | number and you've already checked if the value is a string and then checked if it's a number, whatever is left in the final else block is, by definition, never.

Forcing the Compiler to Complain

This is where "exhaustiveness checking" comes in. Instead of using a default case that silently swallows errors, we can use the never type to turn a logic error into a compile-time error. The trick is to assign the remaining value to a variable explicitly typed as never.

type PaymentStatus = 
  | { status: 'pending' }
  | { status: 'completed' }
  | { status: 'failed' };

function handleStatus(payment: PaymentStatus) {
  switch (payment.status) {
    case 'pending':
      return 'Your payment is processing...';
    case 'completed':
      return 'Thank you for your purchase!';
    case 'failed':
      return 'There was an issue with your card.';
    default:
      // This is the magic part. 
      // If we've handled every case, 'payment' is now the 'never' type.
      const _exhaustiveCheck: never = payment;
      return _exhaustiveCheck;
  }
}

Right now, this code is happy. But imagine you add | { status: 'refunded' } to the PaymentStatus union. Suddenly, the default block is no longer empty. In that block, payment is now of type { status: 'refunded' }. Since a refunded payment cannot be assigned to the never type, TypeScript will throw a red squiggly line right there: Type '{ status: "refunded" }' is not assignable to type 'never'.

I personally prefer this over default: throw new Error("Unknown status") because the error happens while you're writing the code, not while the user is using the app. It turns your type system into a checklist that ensures you've handled every single edge case you've defined.




📋 Practical Task

Fixing the Broken Notification Dispatcher

You are maintaining a notification system. The NotificationType union was recently expanded to include a 'critical' alert, but the getNotificationColor function wasn't updated. As a result, critical alerts are currently falling through to a default "gray" color, which is a major bug.

Your Task: Modify the getNotificationColor function to implement exhaustiveness checking using the never type. The goal is to make the TypeScript compiler throw an error in the default block, alerting you that the 'critical' case is missing.

type NotificationType = 
  | { type: 'info'; message: string }
  | { type: 'success'; message: string }
  | { type: 'warning'; message: string }
  | { type: 'critical'; message: string };

function getNotificationColor(notification: NotificationType): string {
  switch (notification.type) {
    case 'info':
      return 'blue';
    case 'success':
      return 'green';
    case 'warning':
      return 'yellow';
    default:
      // TODO: Implement exhaustiveness checking here 
      // so that this block errors until 'critical' is handled.
      return 'gray'; 
  }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.