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:
- The ability for a function to describe what kinds of exceptions it throws, with commensurate effects on
catch
clause variables, AKA typed exceptions - 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 throw
s 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:
- Pervasive explicit and imperative resource management, wherein every function needs critical cleanup code to ensure correct long-run operation of the program (
free
,delete
, closing native handles, etc.). Modern languages use constructs more likeusing
, which is coming to JS, or ownership models like Rust. - The inability to return disparate values from a function
- Lackluster support for first-class functions (especially in their formative years)
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:
- Avoidable: Those related to logical errors in the calling code, i.e. calling [].find(32). These kinds of exceptions "should" never occur in production code and can always be avoided by calling the function correctly. In other words, "you did it wrong".
- Unavoidable: Those related to errors outside the programmer's control, i.e. a network socket being closed during transmission. These errors should be considered "always possible" and programmers should always be aware that they might happen. In other words, "something went wrong".
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:
e.message
is strongly-typed, and property access one
is correctly refined (even ife
isany
)e.toUpperCase()
is strongly-typed as well- More cases can be added, e.g. detecting
e instanceof RangeError
If we accept as broadly-true principles that...
- Most JS code does not have documented exception behavior, nor strong versioning guarantees around it
- Most JS code has at least some indirection, thus can always call code with undocumented exceptions
- Safe handling of exceptions requires taking both of these into account
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:
- An unannotated function will not throw any exception
- An unannotated function might throw any exception
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
:
- What exceptions can
fn
throw assuming thatarg
matches its declared type? - What exceptions can
fn
throw if not? - Are there other unavoidable conditions
fn
might throw under? - If
arg
is a function, doesfn
invoke it?- If so, does it wrap that invocation in a
try/catch
?- If so, does it conditionally re-throw some inclusionary or exclusionary subset of those exceptions?
- If so, does it wrap that invocation in a
- If
arg
isn't a function, but has function-valued properties, doesfn
invoke any of those?- If so, which? Are those invocations wrapped in a
try/catch
? etc.
- If so, which? Are those invocations wrapped in a
- Is any of this actually documented by the library author?
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:
- Widespread adoption and documentation of strong exception hierarchies in JS libraries in the wild
- TC39 proposals to implement some sort of pattern-matching criteria to catch exceptions (arguably this would just work "by itself" the same way instanceof works today)
TL;DR
- Any feature here implies a huge amount of new information in .d.ts files that isn't documented in the first place
- "Good" exception introspection (
catch (e) { if (e instanceof X ) {
) already works today - Anything more inferential than that is unlikely to be sound in practice