Mirror: JavaScript, TypeScript and Typed Exceptions

The following is my mirror of TypeScript/issues/13219#issuecomment-1515037604.

Ryan's thorough analysis and explanation of error handling in JavaScript is simply excellent. Is goes really deep — well beyond just JavaScript and TypeScript, exploring exception/error handling in software, in general; and it does so thoroughly but succinctly.

It would make me really sad if it ever got lost, for whatever reason, so here I am hosting a copy of it, for posterity.


by Ryan Cavanaugh on Apr 19, 2023

After reviewing all the comments here over the years and much discussion internally, we don't think that the JavaScript runtime or overall ecosystem provide a platform on which to build this feature in a way that would meet user expectations. Per popular request to either add the feature or close this issue for clarity, we're opting for the latter. As with Minification (#8), we're implementing a two-week cool-down period on further comments (ends 5/3).

There are a few different facets that have been implied by the proposal and it's worth sort of breaking them apart individually:

  1. The ability for a function to describe what kinds of exceptions it throws, with commensurate effects on catch clause variables, AKA typed exceptions
  2. The ability to enforce that certain exceptions are explicitly handled (or declared as re-thrown), AKA checked exceptions

Overall Observations on Exceptions in JavaScript

We first need to examine how exceptions are used in JavaScript today to see how this fits into our goal of typing idiomatic JavaScript.

Exception Introspection

There are definitely some places in JavaScript where probing the thrown exception is useful, e.g. you might have code that is likely to throw a few kinds of known exceptions. TypeScript supports these well today with existing patterns:

try {
    // ...
} catch (e) {
    if (e instanceof TypeError) {
        console.log(e.message);
    } else if (typeof e === "string") {
        console.log(e.toUpperCase())
    } else {
        throw e;
    }
}

Since there are usually extremely few static guarantees on what kind of values e might actually have, the existing dynamic type test patterns used in TypeScript are appropriate for writing safe code. More on that later.

A proposed TC39 feature, pattern matching in catch clauses, would make these sorts of checks more ergonomic while at the same time providing useful runtime guarantees. If added to the language, TS would naturally support these. Examples in the future might look something like this:

try {
    // ...
}
catch match ({ code: "E_NOENT" }) {
    // Syntax TBD, of course
}
catch match ({ code: "E_EXIST" }) {

}

Ecosystem Survey

Looking at the landscape of JS libraries, the sort of rich inheritance hierarchies of various Error/Exception classes seen in languages like C# and Java are not widely adopted in the JavaScript ecosystem.

For example, the lodash documentation is 200 pages, of which there is zero description of what kinds of exceptions are thrown, even though the source code reveals that a handful of functions are capable of throwing exceptions. The one apparent user-surfable throw in jQuery is not mentioned in the documentation. React mentions some of the exceptions it can throw, but not all of them, and only uses language like "throws an error", opting not to include specific information about what type of exception. An 850-page book on Material-UI never mentions exceptions, and only talks about throws from user code. There are no documented exceptions in xstate. The Svelte documentation, over the course of 100 pages, simply says "throws an error" in one occurrence. You cannot read the NodeJS documentation and accurately predict which properties will be present in a failing call like fs.open("doesnotexist", "r", err => { console.log(Object.keys(err)); }).

In reality, passing invalid inputs to most JS libraries typically leads to exceptions only tangentially related to the error being made, e.g. passing a primitive where an object is expected in xstate produced uncaught TypeError: Cannot use 'in' operator to search for 'context' in 32. JS programmers are generally expected to realize they made a mistake earlier in the call stack and fix their own problems, rather than to look for very specific errors like you would get in C#. This situation doesn't seem likely to change anytime soon.

Overall, there isn't a culture of strongly-typed exceptions in JS, and trying to apply that culture after the fact is unlikely to produce satisfactory results. But why is that culture absent in the first place? It has to do with language capabilities, in both directions.

Language Capabilities

This culture is a predictable consequence of the way JavaScript exceptions work. Without a first-class filtering mechanism to provide the ability to only catch certain exceptions in the first place, it doesn't make much sense to invest in formalizing error types that can't be usefully leveraged by developers.

The other reason that strongly-typed exception hierarchies are rarely used is that these sorts of exceptions are not needed in the same way as they are in other languages.

A key observation is that languages with strong cultures of exception throwing and exception catching have critical constraints which aren't present in JS:

Let's discuss the last two in further detail.

Inability to return disparate values from a function

In many older languages, functions might be overloaded, but those overloads were statically resolved at compile-time, and the result types of those calls had to be statically known. In other words, in C, there isn't the same notion of a function that returns an int | char* the way that you might talk about a number | string in JavaScript. Especially in languages with checked exceptions, this results in a very typical pattern: Functions which return a value representing the most common case (for example, a file handle) and throw an exception in the uncommon cases (for example, a FileNotFoundException).

Effectively, exceptions (and checked exceptions doubly so) are a workaround for a lack of union return types in a language. They force a caller to reason about the non-golden-path results of an invocation the exact same way that a JS function returning Buffer | undefined does. Exceptions do allow a sort of transparent pass-through of these edge cases, but this capability is not widely leveraged in JS programs -- it's very uncommon to see programs written in a way that they meaningfully catch a specific exception from inner stack frames. You might do this once or twice, for example, to issue a retry on certain HTTP error codes, but this logic isn't pervasive throughout your code the way it is in Java.

JavaScript doesn't have this problem of lacking union return types, and in fact has multiple good solutions. In addition to simply returning various values and requiring the caller to type-test them, we see other emergent solutions.

First-class functions

Another way to handle this situation is to pass a function value that receives two parameters:

openSomeFile((err, fileHandle) => { /* ... */ })

This approach is widely adopted in the NodeJS API and is specifically reliant on how JS makes it much easier to write function values than languages like C, C++, C#, or Java did in their early incarnations. It's also convenient because, real talk, you can just pretend like err doesn't happen if you're writing code that doesn't need to be resilient.

Or, use two separate callbacks:

fetchSomething(err => { /* handle the error*/ }, data => { /* handle the data */ });

This monadic approach has gained wide adoption in libraries like fp-ts, and for good reason. Advantageously, it allows clear separation of "good" and "bad" paths, and forces upfront reasoning about what to do in failure cases.

An interesting observation to make here is that it could be entirely idiomatic to specify multiple error parameters or callbacks in either pattern:

openSomeFile((notFoundPath, accessError, diskRemoved, fileHandle) => { /* decide what to do */ });
// or
fetchSomething(
    socketClosed => { /*do something */ },
    diskFull => { /*do Something else */ },
    data => { /*yay*/ }
);

... but it isn't. I think if you suggested this to a library developer, you'd get some very reasonable pushback: Adding new errors to fetchSomething shouldn't be a breaking API change, most callers do not care which kind of error happened, and we might not even know what kind of errors the underlying calls involved throw because that information isn't well-documented. That pushback applies equally to trying to document exception behavior in the type system.

Generally speaking, JS code goes into exactly two paths: the happy case, or the "something went wrong" case, in which meaningful introspection as exactly what went wrong is quite rare.

Avoidable and Unavoidable Exceptions

For terminology's sake, generally we can think of two kinds of exceptions:

Typed Exceptions

Even setting aside the lack of exception typing in the wild, typed exceptions are difficult to describe in a way that provides value in the type system.

A typical use case for typed exceptions looks like this

try {
    someCode();
} catch (e) {
    // Primary suggestions on the table:
    //  - Allow type annotations on 'e' if they
    //    supertype what we think 'someCode' can throw
    //  - Automatically type 'e' based on what
    //    errors we think 'someCode' can throw
}

Current State of Support

As a baseline, we need to look at how TypeScript handles these cases today.

Consider some basic exception-inspecting code:

try {
    // ...
} catch (e) {
    if (e instanceof TypeError) {
        console.log(e.message);
    } else if (typeof e === "string") {
        console.log(e.toUpperCase())
    } else {
        throw e;
    }
}

This code already works:

If we accept as broadly-true principles that...

then the code that works correctly today in TypeScript is the code that you should be writing in the first place.

Setting that aside, let's look at some problems associated with trying to make this better.

The Default Throw State of Unannotated Functions

100% of function declarations today don't have throws clauses. Given this, we'd have to make one of two assumptions:

If we assume all unannotated functions don't throw, the feature largely does not work until every type definition in the program has accurate throw clauses:

// From a library that's annotated
declare function doSomething1(): void throws NeatException;
// From a library that's not annotated.
// In reality, it can throw AwesomeException
declare function doSomething2(): void;

function fn() {
    try {
        doSomething1();
        doSomething2();
    } catch (e) {
        // e incorrectly claimed to be NeatException
    }
}

If we assume all unannotated functions do throw, the feature largely does not work until every type definition in the program has accurate throw clauses:

// From a library that's annotated
declare function doSomething1(): void throws NeatException;
// From a library that's not annotated.
// In reality, it does not throw
declare function doSomething2(): void;

function fn() {
    try {
        doSomething1();
        doSomething2();
    } catch (e) {
        // e claimed to be 'unknown'
    }
}

Assignability

To keep exception information accurate, assignability would need to take into account throw information. For example:

const justThrow: () => void = () => {
    throw new TypeError("don't call me yet");
}

function foo(callback: () => void) {
    try {
        callback();
        throwRangeError();
    } catch (e) {
        // e: ?
    }
}
foo(justThrow);

Depending on the meaning of unannotated functions, this program is either unsound (e marked as RangeError when it's actually TypeError), or rejected (the justThrow initializer is illegal). Neither option is particularly appealing.

Having the program be accepted as unsound means the feature simply isn't working. That's not good, and to make matters worse, this would be the state of the world until every possible downstream call from the try body is accurately documented. Given the constraints of how well JS exceptions are documented in the first place, this is likely to never happen.

Needing to reject this program is also unfortunate. The function justThrow is legal according to our current definitions, and the assignment doesn't seem to violate any particular rule. Creating additional hoops to jump through to make this program typecheck seems very difficult to justify. A potential fix would be to say that justThrow can throw any error marked as "avoidable" (in Java terms, using RuntimeException), thus making the assignment legal, but the problem is that existing JS programs don't make this distinction on the basis of the error object itself. It's more a property of the throw itself that is avoidable or unavoidable, information which is not inferrable from the way the function is written but rather is a part of the human-facing semantics of it.

Getters / Setters

Getters and setters in JavaScript can throw too, so the problem of non-annotation is also present in property declarations. A default of "does not throw" is obviously more palatable here, but leads to questions of how throw types would interact with assignability. For example, if we assume property get/sets don't throw, this program appears to have a type error because Array#length throws a RangeError if the assigned value is negative or too large:

function clearArray(obj: { length: number }) throws never {
    obj.length = 0;
}
clearArray(someArray, n);

But in reality this program is entirely fine, and it's not obvious what kind of type annotation you should have to write in order to have TypeScript accept it.

Propagation of Exception Information

We'd also have to be able to reason about a huge number of interacting criteria, and start demanding information from type definitions that people have never had to think about before. Let's consider some extremely simple code.

try {
    fn(arg);
} catch (e) {
    // e: ???
}

Questions that need answering in order to make meaningful determinations about e:

For example, the two lines of code at the bottom of this example are the same in terms of syntax, but have different semantics:

function justThrow() throws NeatError {
    throw new NeatError();
}
const someArray = [0];

// Should propagate NeatError to its control flow
someArray.forEach(justThrow);
// Should not propagate NeatError to its control flow
window.setTimeout(justThrow);

Many of these questions require answers from the programmer. Every function declaration needs to change signature to represent this information. Instead of

function callMe(f: () => void): void;

You might need to write something like

function callMe(f: () => void): void rethrows exceptions from fn except RangeError;

or

function callMe(f: () => void, g: () => void): void does not rethrow from f but does rethrow from g;

or

function callMe<T extends { func: () => void }>(f: T): void rethrows exceptions from T["func"] if they are TypeError;

or dozens of other variants that might exist.

But to the last point, as we started with, there is not a strong culture of documentation or adoption of strong exception types in the JS world, nor describing the behavior of what code does when an exception occurs in the first place. This is all not even getting into problems like how to reason about exceptions that occur during future event loop ticks (e.g. Promise).

Indeed, if JS had a culture of not describing input and output types at all, it'd be very difficult for TypeScript to have bootstrapped. Thankfully it's quite difficult to program without that sort of basic information, so input and output types are generally well-documented. But this isn't true for exceptions.

Checked Exceptions

A related feature request is the ability to have checked exceptions, as in Java or Swift. This feature require functions to either catch specific exceptions, or declare that they re-throw them. Certain exceptions are subject to this checking, and certain ones aren't.

Beyond Java and Swift, though, no other mainstream programming language has adopted this feature. The common opinion among language designers, including ourselves, is that this is largely an anti-feature in most cases. Checked exceptions aren't seen in any of the widely used-and-liked languages today, with most new languages opting toward something closer to the Result<T, E> pattern of Rust or a simpler unchecked exception model.

Porting this feature to the JS ecosystem brings along a huge host of questions, namely around which errors would be subject to checking and which wouldn't. The ES spec itself defines over 400 places where an exception is thrown, and the spec clearly doesn't make a hard distinction between avoidable and unavoidable exceptions because it wasn't written with this concept in mind. The distinction is also fuzzy in some cases.

For example, TypeError is thrown by JSON.stringify when encountering a circular data structure. In some sense, this is avoidable because many calls to JSON.stringify, by construction, cannot produce circularities. But other calls can. It's not really clear if you should have to try/catch a TypeError on every call here.

Similarly, SyntaxError is thrown by JSON.parse if the input is invalid. This might be impossible in your application, or might not be. Erring on the conservative side, we might say that this exception is unavoidable since in at least some scenarios, you might be getting arbitrary data from the wire and trying to parse it. But SyntaxError is also the error thrown by the RegExp constructor on invalid regex syntax. Constructing a RegExp from non-hardcoded input is so vanishingly rare that it seems obnoxious to force a try/catch around new RegExp("[A-Z]") since that technically "can" throw an unavoidable exception, even though by inspection it clearly doesn't.

Reconsideration Points

What would change our evaluation here? Two primary things come to mind:

TL;DR

A newsletter for programmers

Yo! This is Taro. I've been doing JavaScript for years and TypeScript for years. I have experience with many programming languages, libraries, frameworks; both backend and frontend, and in a few company roles/positions.

I learned a few things over the years. Some took more effort than I wish they had. My goal with this blog and newsletter is to help frontend and backend developers by sharing what I learned in a friendlier, more accessible and thorough manner.

I write about cool and new JavaScript, TypeScript and CSS features, architecture, the human side of working in IT, my experience and software-related things I enjoy in general.

Subscribe to my newsletter to receive notifications when I publish new articles, as well as some newsletter-exclusive content.

No spam. Unsubscribe at any time. I'll never share your details with anyone. 1 email a week at most.

Success!
You have subscribed to Taro's newsletter
Shoot!
The server blew up. I'll go get my fire extinguisher — please check back in 5.