DEV Community

Cover image for The `never` type and `error` handling in TypeScript
Chinnureddy
Chinnureddy

Posted on • Updated on

The `never` type and `error` handling in TypeScript

One thing that I see more often recently is that folks find out about the never type, and start using it more often, especially trying to model error handling. But more often than not, they don’t use it properly or overlook some fundamental features of never. This can lead to faulty code that might act up in production, so I want to clear doubts and misconceptions, and show you what you can really do with never.

Image description

"never" and "errors".

First of all, don’t blame developers for misunderstandings. The docs promote an example of never and error handling that is true if looked at in isolation, but it’s not the whole story. The example is this:

// Function returning never must not have a reachable endpoint
function error(message: string): never {
  throw new Error(message);
}
Enter fullscreen mode Exit fullscreen mode

This comes from the old docs, which are deprecated. The new docs do a much better job, yet this example sticks around in lots of places and is referenced in many blog posts out there.

It’s Schrödinger’s example: It’s both correct and wrong until you open the box and use it in situations that are not as simple as the one in the example.

Let’s look at the correct version. The example states that a function returning never must not have a reachable endpoint. Cool, so if I call this function, the binding I create will be unusable, right?

function error(message: string): never {
  throw new Error(message);
}

const a = error("What is happening?");
//    ^? const a: never
Enter fullscreen mode Exit fullscreen mode

Yes! The type of a is never, and I can’t do anything with it. What TypeScript checks for us is that this function won’t ever return a valid value. So it correctly approves that the neverreturn type matches the error thrown.

But you rarely just break your code in a single function without some extra stuff going on. Usually, you have either a correct value or you throw something.

What I see people do is this:

function divide(a: number, b: number): number | never {
  if (b == 0) {
    throw new Error("Division by Zero!");
  }
  return a / b;
}

const result = divide(2, 0);

if (typeof result === "number") {
  console.log("We have a value!");
} else {
  console.log("We have an error!");
}
Enter fullscreen mode Exit fullscreen mode

You want to model your function in a way that in the “good” case, you return a value of type number, and you want to indicate that this might return an error. So it’s number | never.

And this example is 100% bogus, wrong, and doesn’t express the truth at all! If you look at the type of result, you see that the type is only number. Where has never gone?

Again, I don’t blame the developers. If you look at the original example describing the never type, you might draw your conclusion that this is how you want to handle the error case.

But I do blame bloggers for creating cheap Medium articles that reach the top hit on Google with wrong information that they didn’t even bother to test. I won’t link the culprit to not give them any link juice, but it’s easy to find with the right keywords. Kids, don’t do this. All your LLMs will learn the wrong things. And your readers, too.

What happened to "never"?

Alright, where did the never type go? It’s easy to understand if you know what never actually represents, and how it works in the type system.

The TypeScript type system represents types as sets of values. The type checker’s purpose is to make sure that a certain known value is part of a certain set of values. If you have a variable with the value 2, it will be part of the set of number. The type boolean allows for the values true and false. You can fine-grain your types and create unions making the set bigger, or intersections, making the set smaller.

The never type also represents a set of values, the empty set. No value is compatible with never. It indicates a situation that should never happen. This is also known as a bottom type.

Image description

The reason why never disappeared is simple set theory. If you create a union of a set number and the empty set, well all that remains is number. If you add nothing to something, something remains, after all.

never gets swallowed up by reality, and you won’t be able to indicate that this function might return an error. The type system will just ignore it.

Take away the following: Don’t use never as a representation for a thrown Error.

How to correctly use never for error handling

This doesn’t render never useless, though. There are situations where you can model impossible states with this type.

Think of expressing your models as a union type.

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };

type Shape = Circle | Square | Rectangle;
Enter fullscreen mode Exit fullscreen mode

Note that I set the kind property to a literal type. This is a discriminated union. Usually, when creating a union type, TypeScript will allow elements that fall into the overlapping areas of the sets, meaning that an object with { radius: 3, side: 4, width: 5 } would be accepted as a Shape.

But by using a literal type, TypeScript can distinguish between the different types and will only allow the correct properties for each type. This is because ** "circle" | "square" | "rectangle"** don’t have any overlap.

Also, note that we use a literal string here as a type. This is not a value. "circle" is a type that only accepts a single value, the literal string called "circle".

With this discriminated union, we can now use exhaustiveness checks to make sure that we handle all cases.

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ** 2;
    case "square":
      return s.side ** 2;
    case "rectangle":
      return s.width * s.height;
    default:
      // tbd
  }
}
Enter fullscreen mode Exit fullscreen mode

You even get nice autocomplete in your editor and TypeScript will tell you which cases to handle.

We haven’t handled the default case yet, but we can use never to indicate that this case should never happen.

function assertNever(x: never): never {
  throw new Error("Unexpected object: " + x);
}

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ** 2;
    case "square":
      return s.side ** 2;
    case "rectangle":
      return s.width * s.height;
    default:
      return assertNever(s);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is interesting. We have a default case that should never happen because our types won’t allow it, and we use never as a parameter type. Meaning that we pass a value, even though the never set doesn’t have any values. Something is going incredibly wrong if we reach this stage!

And we can use this to let TypeScript help us in the case that our code should change. Let’s introduce a new variant of Shape without changing the area function.

type Triangle = { kind: "triangle"; a: number; b: number; c: number };

type Shape = Circle | Square | Rectangle | Triangle;

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ** 2;
    case "square":
      return s.side ** 2;
    case "rectangle":
      return s.width * s.height;
    default:
      return assertNever(s);
    //                   ~
    // Argument of type 'Triangle' is not assignable
    // to parameter of type 'never'.
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at that! TypeScript understands that we didn’t check all variants, and our code will throw red squigglies at us. Time to check if we did everything right!

This is the good stuff about never. It helps you make sure that all your values are handled, and if not, it will tell you through red squigglies.

Error types

You now know how never actually works, but you still want to have a way to correctly express errors.

There is a way that is inspired by functional programming languages and made popular by Rust. You can use a result type to express that a function might fail.
We do the following:

  • We define a type Error that carries the error message and has a kind property set to "error".
  • We define a generic type Success that carries the value and has a kind property set to "success".
  • Both types are combined into a Result type, which is a union of Error and Success.
  • We define two functions error and success to create the respective types.

Like this:

type ErrorT = { kind: "error"; error: string };
type Success<T> = { kind: "success"; value: T };

type Result<T> = ErrorT | Success<T>;

function error(msg: string): ErrorT {
  return { kind: "error", error: msg };
}

function success<T>(value: T): Success<T> {
  return { kind: "success", value };
}
Enter fullscreen mode Exit fullscreen mode

Let’s refactor the divide function from above to use this Result type.

function divide(a: number, b: number): Result<number> {
  if (b === 0) {
    return error("Division by zero");
  }
  return success(a / b);
}
Enter fullscreen mode Exit fullscreen mode

If we want to use the result, we need to check for the kind property and handle the respective case.

const result = divide(10, 0);

if (result.kind === "error") {
  // result is of type Error
  console.error(result.error);
} else {
  // result is of type Success<number>
  console.log(result.value);
}
Enter fullscreen mode Exit fullscreen mode

The important thing is that the types are correct, and the type system knows about all the possible states.

And you can play around with that. Maybe you have functions that throw errors. Create a safe function that takes the original function and its arguments, and wraps everything into your newly created error handling system.

function safe<Args extends unknown[], R>(
  fn: (...args: Args) => R,
  ...args: Args
): Result<R> {
  try {
    return success(fn(...args));
  } catch (e: any) {
    return error("Error: " + e?.message ?? "unknown");
  }
}

function unsafeDivide(a: number, b: number): number {
  if (b == 0) {
    throw new Error("Division by Zero!");
  }
  return a / b;
}

const result = safe(unsafeDivide, 10, 0);
Enter fullscreen mode Exit fullscreen mode

Or if you have a Result, and you want to fail at some point, well then do so:

function fail<T>(fn: () => Result<T>): T {
  const result = fn();
  if (result.kind === "success") {
    return result.value;
  }
  throw new Error(result.error);
}

const a = fail(divide(10, 0));
Enter fullscreen mode Exit fullscreen mode

It’s not perfect, but you have clear states, clear types, know about what your sets can contain, and when you really have no possible value left.

Conclusion

I found some code piece expressing thrown Errors with never a while ago and thought “Oh, the docs messed something up”. I got into a rabbit hole seeing that folks on Medium are suggesting this as a good practice. If there’s something that annoys me, it’s folks teaching things wrong. So I wrote this article to clear things up. I hope it does.


@Article by chinnanj

Top comments (0)