CODE [INPUT]

Rust-like Error Handling in TypeScript

An overview of how to replicate Rust Result type ergonomics in TypeScript using neverthrow, covering custom error types, and result chaining with safeTry.

I've been working on Code Input front-end for close to a year now. Coming from years of Rust, its toolchain and type system set a pretty high bar and jumping into TypeScript made me both appreciate what Rust gets right and wanting to bring those same ideas over.

TypeScript typing is genuinely hard. It's easy to end up in a codebase where half your types are any and unknown, which defeats the point. But that wasn't the most I miss from Rust.

My real pet peeve was losing the ergonomics that come with Rust's Result type. Once you define your own error types in Rust, your functions end up looking like this:

fn calc_operation(var_1: Type1, var_2: Type2) -> Result<i32> {
let a1 = sub_operation_1(var_1)?;
let a2 = sub_operation_2(var_2)?;
a1.checked_add(a2).ok_or(CustomError::new("calc_operation failed at a1 + a2))
}

There are a lot of things I like about Rust in this function. At a glance, I can tell a1 and a2 are both i32. I can tell that sub_operation_1 and sub_operation_2 both return my custom Result type. And I know this function is extremely unlikely to panic or fail in some unpredictable way. It can fail, but when it does, it bubbles that responsibility up to the caller in a structured, traceable way. You know what failed and where.

TypeScript doesn't give you any of that out of the box, so I was left looking for the next best option.

Settling with neverthrow

neverthrow is the closest TypeScript gets to Rust's Result ergonomics. It's not a 1-to-1 port, the API has its own shape and you'll need to spend some time getting familiar with it and that's fine, as TypeScript has different constraints anyway

Custom Error/Result Types

The first thing I wanted was my own custom error types. Having a centralized error type means I can do things like report errors to external services, show popups, and enforce consistent error handling across the app. By re-exporting Result with my own error type baked in, every new function is forced to handle errors in a way that fits my app's conventions.

export type Result<T, E = APXError> = NTResult<T, E>;

However, this is a bit more involved than Rust. You also need to re-export and wrap a handful of neverthrow types to make everything work with your custom error. Luckily, it's something that LLMs can help you do.

Usage ends up looking something like this:

import { Result, ok, err } from '@/apx/Error/result';
function validateAge(age: number): Result<number> {
if (age < 0) {
return err(new APXError('Invalid age', 'Age cannot be negative', 'Validation'));
}
return ok(age);
}

Chaining issue with TypeScript

In Rust, you can chain functions that return Result as long as your function returns the same Result type. The ? operator handles the rest. TypeScript has no equivalent, so you can't just write validateAge(29)?.

The next best option is generator functions. In JavaScript, generator functions return an iterable object. The yield keyword lets you short-circuit execution: if an error is returned, it halts immediately and bubbles the error up. neverthrow takes advantage of this with a wrapper function called safeTry.

import { Result, ok, err, safeTry } from '@/apx/Error/result';
import { APXError } from '@/apx/Error/apxError';
function validateAge(age: number): Result<number> {
return safeTry(function* () {
if (age < 0) {
return err(new APXError('Invalid age', 'Age cannot be negative', 'Validation'));
}

It's not the most elegant ergonomics, but it gets the job done. Now, you can safely unwrap the result like this.

function processUser(age: number, name: string): ResultAsync<string> {
return safeTry(async function* () {
const validAge = yield* validateAge(age);
return okAsync(`User ${name} is ${validAge} years old`);
});
}

Which is about as close to Rust's Result chaining as TypeScript can get.

Effect

Can the experience get better? Maybe. Effect is a TypeScript library that goes much further than neverthrow. Typed errors, recovery APIs, tracing, and a lot more. The tradeoff is a steep learning curve and it reshapes how you structure your entire codebase. For now, neverthrow is enough for me. I've learned to be skeptical when a framework bundles a lot of "free/batteries-included" stuff. There's usually a cost somewhere, and Effect being runtime evaluated is worth keeping in mind.