TypeScript Errors (and Effect)
Typed errors
I was extremely lucky in my first couple of years programming to gain exposure to a pretty broad range of technologies. Ruby and JavaScript at the bootcamp I attended, then early in my first full time role I was assigned a one-off project to extract some data from a database which had been encrypted with a mechanism tightly coupled to a specific PHP codebase built by an agency. Following that I got the chance to write some Go and TypeScript, managed deployments in Kubernetes, worked on a React Native project, and wrote a little Rust code too.
In many ways I think the most influential of those technologies for me was Rust. I haven’t written any Rust code since, but that exposure definitely shaped the way I’ve thought about all the TypeScript code I’ve written since. The most fundamental lesson I learned was that strict type checking is really useful (for confidence in code correctness and for future refactors), but the more specific corner of type-safety that I’ve been slowly accumulating opinions around is error handling. I really liked the ability in Rust to see all of the possible error types that a function could return, and the way you could then group them to handle errors with a sensible level of granularity. For example, you might have a large number of different validation errors that could be ultimately grouped into a BadRequestError
. And Rust has language features to help you work with errors in an elegant way. You’ll see code that looks like this:
let contents = read_to_string(input_path)?;
let transformed = contents.to_uppercase();
let mut file = File::create(output_path)?;
file.write_all(transformed.as_bytes())
The question mark at the end of the file operation functions means ‘return early if this fails’ and allows you to write really readable ‘happy path’ code. It’s a bit like how you can rely on functions in JavaScript to throw, like this:
const contents = fs.readFileSync(inputPath, 'utf8');
const transformed = contents.toUpperCase();
fs.writeFileSync(outputPath, transformed);
If reading inputPath
fails (because the file doesn’t exist) then it will throw and contents.toUpperCase()
won’t run. Similar to the Rust code you’d handle the error in the parent function, but you won’t be forced to do so. And even if you’re diligent about remembering to handle thrown errors, it’s often really difficult to figure out what all of the different possible error values are.
TypeScript with all the right settings is a pretty massive upgrade to writing plain JavaScript, but doesn’t keep track of the type of thrown errors. There is an obvious solution to this though, which is returning error values rather than throwing them. You can make a Result
type that also includes an easy way to check whether your function call has returned the Ok
or the Err
variant. It could look something like this:
type Ok<T> = { ok: true; val: T }
type Err<E> = { ok: false; val: E }
type Result<T, E> = Ok<T> | Err<E>
const safeSqrt = (n: number): Result<number, Error> => {
if (n < 0) {
return { ok: false, val: new Error('input was less than zero') }
}
return { ok: true, val: n * 2 }
}
const input = 5
const rooted = safeSqrt(input)
if (rooted.ok) {
console.log(`Success! The square root of ${input} is ${rooted.val}`)
}
From here, you can add helper functions to return the Ok
and Err
variants, and methods on the Result
type to chain operations on Ok
values and what you end up with is a library like neverthrow. There are a handful of TypeScript libraries offering Rust-like Result
types (oxide.ts is one that calls out Rust explicitly, and also provides an Option
type), but neverthrow seemed the most popular (based on GitHub stars) last time I checked, and it’s the one we use at work.
These libraries all have a pretty significant shortcoming though: they’re not part of the language. This means they’re unfamiliar to many TypeScript developers, and they can be clunky to use. Neverthrow offers a chaining API allowing you to write code like const doubled = result.map(okValue ⇒ okValue * 2)
but this is pretty limited compared to the unrestrained ‘happy path’ code you can write when you’re relying on thrown errors. And this combination of unfamiliarity and clunkiness means you end up with inconsistent usage, which I think is better than nothing but certainly isn’t ideal.
As far as I can tell, the solution to the clunkiness and inconsistency must be to “go all in”. Write a guide on how the library should be used and enable some kind of linter rule to enforce it. The difficulty here is that you really need to be getting significant benefits from the library to justify the work of teaching and enforcing it, and even with totally consistent usage there’s some unavoidable clunkiness that you need to just live with. And if you’re going to go all in, you want to make sure you pick the right library to maximise the benefits since switching between these libraries would be a pain, especially if you’ve built custom tooling to enforce it and/or leverage it as much as possible in your codebase. I’m not convinced that neverthrow or any equivalent Result
library can offer sufficient benefit to go all in, given the drawbacks. But a library that offered more benefits in exchange for that unavoidable clunkiness might be a different story…
Effect
Effect is a typescript library providing a framework for type-safe error handling, structured concurrency, dependency injection, observability, and a range of common application requirements like data fetching, (de)serialisation, and validation. Adopting Effect is a much larger undertaking than adopting a Result
type for error handling, but the payoff is significantly bigger. They’ve done a lot of work to improve their docs over time and these days their home page does a pretty good job of explaining how complex (but relatively common) requirements can be achieved very succinctly with Effect. Here’s their example of how you might fetch a resource with error handling, retries, and a timeout:
const getTodo = (
id: number
): Effect.Effect<
unknown,
HttpClientError | TimeoutException
> =>
Http.request.get(`/todos/${id}`).pipe(
Http.client.fetchOk(),
Http.response.json,
Effect.timeout("1 seconds"),
Effect.retry(
Schedule.exponential(1000).pipe(
Schedule.compose(Schedule.recurs(3)),
),
),
)
It’s definitely neater than the code you’d probably write without Effect, but you absolutely face a learning curve when adopting the library and its footprint across your codebase is pretty significant.
I discovered Effect in late 2023 and used it for a web project where you could log in with Spotify to see all the album covers from your library sorted by the dominant colour of their artwork. The TL;DR of my experience is that it was very satisfying, but punctuated quite frequently with head scratching and lots of trial and error. The Effect website describes it this way: “Effect’s programming style might be different from what you’re used to” and “It’s similar to learning TypeScript. It’s worth it.”
The expressivity of Effect is enabled by some serious type-level programming under the hood. When it works, the experience is fantastic: properly reminiscent of my experiences writing Rust. When it doesn’t work though, the experience is quite different from writing Rust. The Rust compiler is amazing and quite often tells you exactly how to fix your code in its error messages. And if you can’t figure out how to fix your Rust code, there’s a vast number of resources online to help you. Contrastingly, my experience with Effect when things went wrong was quite often totally unspecific TypeScript errors pointing to generic type parameters in Effect library code, and searching through the Effect GitHub repo to find methods with names that might better fit what I’m trying to do. I am confident that this story will improve over time, however.
Which brings me back to the quote from their website: “It’s similar to learning TypeScript”. I think this is a really useful way to think about it. TypeScript offers significant benefits over writing plain JavaScript, but if you’ve never worked with a statically typed programming language then the experience can be quite frustrating. Similarly, with Effect you can write more expressive code which means a smaller codebase in which you can be more productive once you’ve familiarised yourself with it. I can really confidently recommend TypeScript to anybody currently writing plain JavaScript, but a big part of that confidence comes from the ubiquity of TypeScript nowadays — TypeScript isn’t going anywhere anytime soon. But it’s worth remembering CoffeeScript, “an attempt to expose the good parts of JavaScript in a simple way” first released in 2009 and one of the StackOverflow developer survey’s most dreaded technologies by 2015. And Flow, “a static type checker for JavaScript” with some interesting ideas like a component syntax for React code, but significantly lower adoption than TypeScript. Effect is just a library built on top of TypeScript rather than an alternative syntax, but as long as the experience of learning it is similar to learning TypeScript you need to consider the serious investment that it represents and on that basis I don’t currently feel confident recommending it.