TL;DR — rethrowUnless
is a little
utility I copy into various TypeScript projects. It provides a declarative
Python-like except
for catching errors explicitly.
try {
await createUser(userData);
} catch (error: unknown) {
rethrowUnless(error, ValidationError, PermissionError);
error; // ValidationError | PermissionError
}
Errors are a fact of programming. Some languages with strong type systems (e.g., Rust, Go, Haskell) treat errors as values, making failures visible to the type system so the compiler can help ensure all outcomes are handled. I grown to appreciate how this style of error handling forces me to think about what could go wrong — and when it’s available, I prefer it.
That said, much of the code I write day-to-day is in Python and JavaScript (TypeScript), where errors are exception-based. Exceptions bubble up through the call stack, hiding from the type system. Types typically cover the “happy path,” so reasoning about failures takes extra care. Of course, one can mimic errors-as-values with libraries, but since it’s not a core feature, the are ad hoc, missing the je ne sais quoi of the underlying language.
Some exception-based languages, however, offer better type saftey and structure
around errors than others. In Python, for example, all errors must derive from
BaseException
. A modest
constraint, but interestingly, it’s enough for Python to support special syntax
for structured exception handling.
For instance, you can catch specific error types using except
clauses:
try:
await create_user(user_input)
except ValidationError as e:
print(f"Invalid input: {e}")
except PermissionError as e:
print(f"Permission denied: {e}")
except (TimeoutError, ConnectionError) as e:
print(f"Network issue: {e}")
Or, you can catch all standard exceptions generically:
try:
await create_user(user_input)
except Exception as e:
print(f"Something went wrong: {e}")
But catching “bare execptions” like this is generally discouraged. Catching specific errors makes it clear what failures the code is prepared to handle, rather than unintentionally suppressing unexpected issues.
JavaScript, by contrast, allows any value to be thrown — not just
Error
objects:
throw new Error("oops");
throw "boom";
throw 42;
Why is this a problem?
The try/catch
syntax in JavaScript is limited in its
expressiveness. There’s no way to specify which errors to
handle. It catches everything by default, much like Python’s discouraged
“bare except.” Python, by contrast, is more declarative: except
clauses make
the intended failure modes explicit, and unexpected errors bubble up
automatically.
Because of this lack of constraint, TypeScript catches errors as
unknown
— a type that correctly reflects the uncertainty of what was thrown. Unlike
any
,
an unknown
value cannot be used until the type system is convinced of certain
properties.
try {
await createUser(userInput);
} catch (error: unknown) {
// handle unknown
}
However, in practice, I’ve found that while typing errors as unknown
is
correct, it often leads TypeScript developers to give up on type safety inside
catch blocks. Many either reach for unsafe casts to satisfy the type checker or
operate on unchecked assumptions about what was thrown:
try {
await createUser(userInput);
} catch (error: unknown) {
console.log((error as Error).message); // not safe!!
}
Handling errors properly requires more ceremony, explicitly checking known cases and remembering to rethrow anything unfamiliar:
try {
await createUser(userInput);
} catch (error: unknown) {
if (error instanceof ValidationError) {
console.log(`Invalid field: ${error.field}`)
} else if (error instanceof PermissionError) {
console.log(`Invalid permissions for user: ${error.userId}`)
}
// Bubble up anything else
throw error
}
This pattern works, but it’s repetitive and easy to get wrong, especially if you forget to rethrow. I’ve often wanted something more structured, like Python.
Type narrowing
One (subtle) benefit of Python’s except clauses is how they automatically narrow the type of the error. Inside each block, the error is treated as a specific type, allowing safe access to relevant properties:
try:
create_user(user_input)
except ValidationError as e:
print(f"Invalid field: {e.field}")
except PermissionError as e:
print(f"Permission denied for user: {e.user_id}")
As an aside, I find this collaboration between runtime checks and static analysis both fascinating and useful, much like how assertions support the type checker.
TypeScript can do something similar, though it requires more explicit/imperative checks:
try {
await createUser(userInput);
} catch (error: unknown) {
if (error instanceof ValidationError) {
error; // ValidationError
}
throw error
}
Borrowing from Python
With just a few lines of code, we can add a bit more structure to error
handling in TypeScript, borrowing from Python’s except
clauses:
/**
* @param error - The error to check
* @param - Expected error type(s)
* @throws The original error if it doesn't match expected type(s)
*/
function rethrowUnless<E extends ReadonlyArray<new (...args: any[]) => Error>>(
error: unknown,
...ErrorClasses: E
): asserts error is E[number] extends new (...args: any[]) => infer R ? R
: never {
if (!ErrorClasses.some((ErrorClass) => error instanceof ErrorClass)) {
throw error;
}
}
rethrowUnless
checks whether an error
matches a specific set of types and rethrows anything that doesn’t. The runtime
logic is minimal, and most of the complexity (I know it’s a lot) is in the type
signature. The result is a utility that brings much more structure to exception
handling in TypeScript.
Here’s how the earlier example looks with it:
try {
await createUser(userInput);
} catch (error: unknown) {
rethrowUnless(error, ValidationError, PermissionError);
console.log(
"field" in error
? `Invalid field: ${error.field}`
: `Invalid permissions for user: ${error.userId}`,
);
}
Like in Python, the type of error is narrowed automatically based on the clause. Notice how we also don’t need to remember to rethrow the error if we don’t know how to handle it.
You can also chain catch
with async to mimic chained except
handlers:
await createUser(userInput)
.catch(error => {
rethrowUnless(error, ValidationError);
console.log(`Invalid field: ${error.field}`);
})
.catch(error => {
rethrowUnless(error, PermissionError);
console.log(`Invalid permissions for user: ${error.userId}`);
});
It doesn’t bring errors into the type system, but it makes try/catch
blocks
more declarative with minimal code. I’ve found it worth copying between
projects — maybe you will too.