HomeArticles

The `never` type and error handling in TypeScript

Stefan Baumgartner

Stefan on Mastodon

More on 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.

I’ve written my second book on TypeScript. Check out The TypeScript Cookbook on Amazon!

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);
}

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

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 never return 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!");
}

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.

I write at length about this in my book, TypeScript Cookbook, so if you want to learn more about this, you can check it out.

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.

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.

First, through the use of discriminated unions and exhaustiveness checks. I’ve spoken about this feature in my blog and also in my book, and here’s a quick rundown.

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;

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
}
}

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);
}
}

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'.
}
}

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.

This is different from using Error classes, which are a topic on their own. This here gives you better error states through the use of discriminated unions and exhaustiveness checks.

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 };
}

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);
}

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);
}

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);

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));

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. I have more articles like this on this very blog, but you find 100+ recipes in my book TypeScript Cookbook.

More articles on TypeScript

Stay up to date!

3-4 updates per month, no tracking, spam-free, hand-crafted. Our newsletter gives you links, updates on oida.dev, conference talks, coding soundtracks, and much more.