Error handling in Typescript
Summary
- Define and use custom error types by extending the built-in
Error
type. - Always handle errors within the functions where they occur, and return errors as values to let the Typescript compiler handle them.
- Do not introduce
Result
type as its benefit is not worth the cost.
Error Handling in JavaScript
Error handling in Typescript starts with the tools provided in Javascript.
/*
* Custom error types. Extending built-in Error class is great for
* interoperability, but works only after ES5.
*/
class CustomError extends Error {
constructor(errorCode, message) {
super(message);
this.name = this.constructor.name;
this.errorCode = errorCode
}
}
class InsufficientBalanceError extends CustomError {
constructor() {
super(13, "The account has insufficient balance to execute the transaction.")
}
}
class SuspendedAccountError extends CustomError {
constructor() {
super(21, "The account is suspended.")
}
}
class OffBusinessHoursError extends CustomError {
constructor() {
super(53, "The branch is off business hours and cannot execute the transaction.")
}
}
function checkBalance(account) {
if (!account.active) {
throw new SuspendedAccountError()
else {
} return account.balance
}
}
// Try... catch statement for failable operation and handling errors
function failableFunction(account) {
try {
// Do something that may throw error
const balance = checkBalance(account)
return balance
catch(e) {
} // Handle thrown error
console.error(e)
if (e instanceof InsufficientBalanceError) {
return e
else if (e instanceof SuspendedAccountError) {
} return e
else if (e instanceof OffBusinessHoursError) {
} return e
else {
} return undefined
}finally {
} // Do this regardless of whether there's an error or not
console.log("Hello, world!")
} }
Three key improvements by Typescript
Return type can be written down for better readibility. This also encourages programmers to pay more attention to error handling, especially if return types are inconsistent or convoluted like in the following example.
function failableFunction(account: string): number | InsufficientBalanceError | SuspendedAccountError | OffBusinessHoursError | undefined { ... }
Union types can be used to group errors free of prototype chain.
type AccountError = InsufficientBalanceError | SuspendedAccountError function failableFunction(account: string): number | AccountError | OffBusinessHoursError | undefined { ... }
Custom type guards allow more precise handling of error types than Javascript’s type guards of
typeof
andinstanceof
typeof
checks for only the most basic types -boolean
,string
,bigint
,symbol
,undefined
,function
,number
,object
- and is not suited for handling error typesA instanceof B
simply checks ifB.prototype
exists anywhere in the prototype chain ofA
, which can result in unexpected behavior.class AccountError extends CustomError { constructor(message) { super(10, message); } } const e = new AccountError("test") if (e instanceof CustomError) { console.log("CustomError") else if (e instanceof AccountError) { } console.log("AccountError") } /* * This outputs "CustomError". * When using instanceof typeguard, you should keep track of * the prototype chain and handle more specific errors first. */
Custom type guard can specify the exact error type you want to handle.
function isInsufficientBalanceError(o: unknown): o is InsufficientBalanceError { return typeof o === "object" && o !== null && "name" in o && o.name === "InsufficientBalanceError" }
Introducing Result
type
Typescript also enables adopting Result
type to handle errors. Result
, also often called Either
, is not built into Typescript. Defining the type itself is easy, but defining the API around it is quite a lot of work so I recommend using libraries. There are several, ranging from simple ones such as vultix/ts-result or badrap/result to full suites such as mobily/ts-belt or gcanti/fp-ts.
A basic definition and usage of Return
type is as following:
class Ok<T> {
constructor(private value: T) {}
}
class Err<E> {
constructor(private value: E) {}
}
type Result<T, E> = Ok<T> | Err<E>
class ParseError extends CustomError {
constructor(input: any) {
const message = `Could not parse the given input: ${input}`
super(message)
}
}
const SEASONS = ["spring", "summer", "fall", "winter"] as const
type Season = typeof SEASONS[number]
function isSeason(o: unknown): o is Season {
return typeof o === "string" && !!SEASONS.find((season) => o === season);
}
function parseSeason(s: string): Result<Season, ParseError> {
if (isSeason(s)) {
return new Ok(s as Season)
else {
} return new Err(new ParseError(s))
} }
Benefits of Result
type
This pattern requires all errors to be caught within the functions where they can occur. Otherwise the return types for both successful and failed operations cannot be correctly specified.
This pattern also leads to type-safe errors. Javascript can
throw
anything, not justError
type - this is why caught errors haveunknown
type in Typescript.
All failable operations can be represented as a single unified abstraction, improving code readability and composability. A chain of failable functions can quickly grow out of hand.
function stepOne(): string | undefined { ... } function stepTwo(s: string): number | StepTwoError { ... } function stepThree(n: number): string | StepThreeError { ... } function operation() { const stepOneResult = stepOne() if (stepOneResult !== undefined) { const stepTwoResult = stepTwo(stepOneResult) if (typeof stepTwoResult === "number") { const stepThreeResult = stepThree(stepTwoResult) return stepThreeResult else { } ... } else { } ... } }
Result
type has an established pattern of API that makes such operation much easier. Specific implementation may differ among libraries, but it generally looks like this.function stepOne(): Result<string | undefined> { ... } function stepTwo(s: string): Result<number | StepTwoError> { ... } function stepThree(n: number): Result<string | StepThreeError> { ... } function operation() { const result = stepOne().andThen(stepTwo).andThen(stepThree) return result }
Downsides of Result
type
- Introducing an unwieldy functional programming pattern and complexity just for error handling is often hard to justify, unless robustness is particularly important for the business domain. And if that is the case, then maybe the first question should be if Typescript is the right language for it.
- Wrapping and unwrapping values into and out of
Result
type is tiring, especially when the pattern is almost never supported by the broader Javascript ecosystem. - This pattern encourages explicitly handling all possible exceptions at compile time, which quickly becomes extremely tedious. For years, there has been a lot of skepticism in the practicality of this approach.
- Using
Result
type still cannot guarantee the absence of runtime error at compile time. If you forget to handle a potential error, Typescript won’t remind you of it sincethrow
is not represented in Typescript’s type system. For example,JSON.parse
canthrow
aSyntaxError
, but its type signature is justJSON.parse(text: string, reviver?: ((this: any, key: string, value: any) => any)
. Unless you remember to wrap it inResult
, the program will still crash at runtime.
Conclusion
Error handling in Typescript is better than Javascript’s but still is not really great. Here’s my conclusion as of now.
Define and use custom error types by extending the built-in
Error
type.This is simply a standard practice. Typescript’s union type allows a very flexible definition of error types which is pleasant to use.
Always handle errors within the functions where they occur, and return errors as values to let the Typescript compiler handle them.
All Javascript errors are runtime errors, which are difficult to catch and reason about. Typescript allows programmers to manually turn them into compile time errors, which should be taken advantage of as much as possible. Unfortunately, this means that the programmer’s skill and understanding of the domain will remain the deciding factor of the program’s robustness.
Do not introduce
Result
type as its benefit is not worth the cost.I loved using
Result
in Elm and Haskell, and missed it when working with Typescript. Trying it out in Typescript, however, was an unpleasant experience. The Typescript ecosystem is not compatible with it, and you have to constantly fight against it to makeResult
work. And I believe that if you’re fighting against the environment, it’s a losing game. Unless the language itself starts natively supporting theResult
type, I won’t be using it.