Skip to main content

neverthrow - tutorial (with a bit of "byethrow")

· 33 min read

Picture There is a lack of beginner tutorials for neverthrow and sparse official documentation. Spent a few days to understand how to use neverthrow library.


This article is also published on dev.to

Credits:

Before we begin

This article assumes you:

  • already stumbled upon the fact that TypeScript doesn't infer throw types - meaning all your exceptions are essentially not tackled. This comment explains why.
  • acknowledged that to overcome this, you need to return your errors/exceptions alongside the data. E.g. as return { data, error }
  • need to do something with these returned errors (e.g. show to the User on your frontend).

One of such solutions is neverthrow (btw it has 0 dependencies). Another option (which I’ll compare at the end of the article) is byethrow

Image description link - npm download trends of some relevant libs

info

Before reading this article, I would highly advise to watch this talk from Scott Wlaschin, which explains the whole <Ok, Err> (railway-oriented programming) paradigm as if I'm 7 years old. Magnificent 🪄. It made my brain "click" to understand the whole concept.

TLDR: Problem ➡️ Solution

Let's say your codebase has 20 different functions with 50 different ways they can throw. Now you need to use 2 of these functions. How do you find out which throw errors they can give? You dive into each of them and find out that they can call 5 other functions. You dive into those. And then into child functions of those... You get it.

To overcome TS throw limitations, quick way is do this:

function sortStrings(data: string[]) {
if (!data) throw new Error("data is missing")
return data.sort()
} // ❌ returned type is: string[]

import { ok, err } from "neverthrow";
function sortStringsNeverthrow(data: string[]) {
if (!data) return err("data is missing")
return ok(data.sort())
} // ✅ returned type is: Err<never, "data is missing"> | Ok<string[], never>

Keep reading to find more.

Neverthrow

neverthrow is a TypeScript library that brings type-safe error handling. It helps you return a typed Result (Ok or Err) from your functions instead of using throw. Of course, it's a bit more than that, but let's start with the basics.

Neverthrow: basic primitives

The idea behind "typed errors" is to just return them instead of throw. That simple:

import { ok, err } from "neverthrow";
function divide(a: number, b: number) {
if (b === 0) return err("Division by zero"); // instead of `throw`
return ok(a / b); // instead of simple return value
}

I can argue that you only need to know about these 2:

  • ok() / okAsync()
  • err() / errAsync()

Both return an instance of Result/ResultAsync, which can be called & checked to retrieve:

  • value
  • error

Both are typed and can be checked by using .isOk() & .isErr():

import { ok, err } from "neverthrow";

function divide(a: number, b: number) {
if (b === 0) return err("Division by zero");
return ok(a / b);
}

const result = divide(10, 2);
// const result: Err<never, "Division by zero"> | Ok<number, never>

if (result.isOk()) {
console.log("Result:", result.value); // ✅ safe
// ?^ number
} else {
console.error("Error:", result.error); // ✅ safe
// ?^ "Division by zero"
}

Example: basic primitives

Now, whenever you call your functions that return Result<Ok, Err>, just check whether there was an error and decide what to do with it. Similar to Go or Rust.

const user = getUser(id)
if (user.isErr()) return json({error: "NO_USER_ID_PROVIDED"}, 400) // API response with error

const isAdmin = isUserAdmin(user.value)
if (isAdmin.isErr()) return json({error: "USER_IS_NOT_ADMIN"}, 400)

Since these responses are fully typed, you can use RPC to connect your backend to frontend & get full type safety there as well. There is a caveat of serialization, but we'll cover it later in the article.

Returning typed error codes provides benefits of easier i18n (internationalization) on the frontend too. Instead of generic error code, you can present translated user-friendly error on the UI.

That's it.

success

You’ve already gained about 80% of the library’s benefits (according to 80/20 rule).

Everything else is just helpers, handy utils etc. But you can definitely just use these 2 basic primitives to implement same stuff.

Again, EVERYTHING below this line can be replicated using only ok(), err(), isOk(), isErr() primitives.


Some neverthrow helpers

Once you start implementing this code, you will find yourself repeating same patterns in many places. That's where neverthrow helpers come in.

.unwrapOr()

Use .unwrapOr(fallback) helper function to get data & fallback from the Result. Provide value into .unwrapOr() to get it back in case of Result.isErr()===true

// ❌ Instead of:
const result = divide(10, 2)
let resultAfterFallback = 0
if (result.isOk()) {
resultAfterFallback = result.value
}

// ✅ Just .unwrapOr()
const result = divide(10, 2).unwrapOr(0); // returns 5
const result = divide(10, 0).unwrapOr(0); // returns 0, because divide() returned err()

.match()

Use .match() helper function to access err() and ok() in this way:

const result = divide(10, 0).match(
(value) => // do something with value ,
(error) => // do something with error
)

More helpers

There are more helpers, but let's keep our focus tight and proceed with deeper benefits.


Neverthrow: map/mapErr, andThen, and yield

You might have noticed that code syntax becomes quite verbose/tedious very quickly, especially when you want to just bubble the errors to the calling function (similar to throw). Here is an example of such verbosity:

function childFunction() {
const res1 = function1();
if (res1.isErr()) return err(res1.error); // ❌ Tedious

const res2 = function2(res1.value); // depends on res1
if (res2.isErr()) return err(res2.error); // ❌ Tedious

return ok(res2.value);
}

function main() {
const res = childFunction();
if (res.isErr()) {
switch (res.error) {
case "Error at function1":
break;
case "Error at function2":
break;
default:
break;
}
}
return res.value // or ok(res.value). Depending on your needs
}

That's where .andThen() & .map() come in handy.

.andThen()

Essentially, helps to "bubble up" the errors automatically, without the need to check for .isErr() after every function call. Function have to be executed in sequential order and dependent on each other to take benefit of this helper.

.andThen() takes the ok() value from the result and passes it into the next function's input. If there was an err() - it brings it forward.

Image description See the diagram above: result from function1() is passed as input to function2(). And if there was an error in function1(), then function2() never runs.

So, errors are propagated without the need to explicitly use return err(result.error) on every function call.

Let's use .andThen() to make childFunction() more concise:

// ✅ new approach
function childFunction() {
return function1().andThen((res1) => function2(res1));
}

// ❌ vs previous tedious approach
function childFunction() {
const res1 = function1();
if (res1.isErr()) return err(res1.error); // ❌ Tedious

const res2 = function2(res1.value);
if (res2.isErr()) return err(res2.error); // ❌ Tedious

return ok(res2.value);
}

Return types of both approaches are the same.

If you allow yourself more buy-in into the library - use .andThen(). If not - stick with err()/ok()- it's also perfectly fine.

If the output and input types of subsequent functions in .andThen() chain are of the same type - you can use it without callback args like this:

function childFunction() {
return function1().andThen(function2); // ✅ even more concise
}

It is important to understand here that if your functions are not dependent on each other in a linear way - .andThen() won't bring value (as far as I understood).

This is called "function composition". Function composition combines two functions by using the output of one function as the input for another, forming a new, composite function. (I didn’t know that before either — but now I do 🙂)

.map()

First of all, it's not the same Array.map() that you're used to. (as far as I understood)

The key distinction between .andThen() and .map() is that .map() automatically wraps your callback function result in ok():

  • .andThen(fn): The callback fn must return ok()/err() (Result<Ok, Err>). It is used for operations that might fail.
  • .map(fn): The callback fn returns a plain value. The .map() method then automatically wraps the returned value in a new ok() for you. It is used for operations that are guaranteed not to fail.

Simple example:

// Using .map() - transformation can't fail
const result1 = ok(5)
.map(x => x * 2) // Returns ok(10) (just a number)
.map(x => x + 1); // Returns ok(11) (just a number)
// result1 is Ok(11)

// Using .andThen() - transformation can fail
const result2 = ok(5)
.andThen(x => {
if (x > 0) return ok(x * 2); // Must return Result
return err("negative"); // Can fail!
});
// result2 is Ok(10)

Real-world example:

function getUser(id: number): Result<User, string> {
if (id === 1) return ok({ id: 1, name: "Alice" });
return err("User not found");
}

function getPostsByUser(user: User): Result<Post[], string> {
if (user.id === 1) return ok([{ userId: 1, title: "Hello" }]);
return err("No posts found");
}

// Chaining with .andThen() because each step returns a Result
const posts = ok(1)
.andThen(getUser) // Result<User, string>
.andThen(getPostsByUser) // Result<Post[], string>
.map(posts => posts.length); // Just transforming the success value

Here is a nice summary by Claude 4.5 Sonnet on when to use each:

Use .map() when:

  • Your transformation always succeeds
  • You're just reshaping/formatting data
  • Example: ok(user).map(u => u.name)

Use .andThen() when:

  • Your transformation might fail
  • You're calling another function that returns a Result
  • You need to chain multiple fallible operations
  • Example: ok(userId).andThen(getUser).andThen(validateUser)

If you're familiar with Promises, it's similar:

  • .map() is like .then(x => value)
  • .andThen() is like .then(x => Promise)

The key insight: .andThen() prevents nested Results like Ok(Ok(value)) by flattening them automatically!

.mapErr()

Same stuff as .map(), but for Err():

// .mapErr() - transform the Err value
err("not found")
.mapErr(e => e.toUpperCase())
.mapErr(e => `Error: ${e}`);
// Err("Error: NOT FOUND")

Async stuff

So far we've covered everything in simple sync code. The good news is that async code doesn’t require a new mental model & is almost the same. Check this out:

Convert async to sync
// You MUST await a ResultAsync to get a Result
const asyncResult: ResultAsync<number, string> = okAsync(42);
const syncResult: Result<number, string> = await asyncResult;
Mix async with sync
// fetchUser() is async function that returns ResultAsync<Ok, Err>
// validateEmail is sync function that returns Result<Ok, Err>
const result = fetchUser(1)
.map(user => user.email) // Sync map works on ResultAsync!
.andThen(email => validateEmail(email)) // Sync andThen works too!
.map(email => email.toLowerCase());
// result is ResultAsync<string, string>

await result; // Now you get Result<string, string>
Common mistakes
// ❌ WRONG: Creating nested ResultAsync
const nested = okAsync(42)
.map(x => okAsync(x * 2)); // ResultAsync<ResultAsync<number, never>, never>

// ✅ RIGHT: Use andThen for Result-returning functions
const flat = okAsync(42)
.andThen(x => okAsync(x * 2)); // ResultAsync<number, never>

// ❌ WRONG: Forgetting to await
function getUser(): ResultAsync<User, string> {
return fetchUser(1);
}
const user = getUser(); // This is a ResultAsync, not a User!

// ✅ RIGHT: Await the ResultAsync
const user = await getUser(); // Now it's Result<User, string>

Other async tips

The key insight: ResultAsync is "infectious" - once you go async, you stay async until you await. Sync operations (.map(), .andThen()) work on both Result and ResultAsync, making it easy to mix them!


Even more helpers

.orElse()

// .orElse() - recover from errors
err("failed")
.orElse(e => ok("default value"));
// Ok("default value")

._unsafeUnwrap()

// ._unsafeUnwrap() - throws if Err (avoid in production!)
const risky = ok(42)._unsafeUnwrap(); // 42
// err("oops")._unsafeUnwrap(); // throws!

.orTee() & .andTee()

.orTee() and .andTee(): For side effects or error and success tracks respectively

const result = (id: string) =>  
function1()
.andThen(function2)
.orTee((error)=> console.error(error))
.andTee((value)=> console.log(value))

"Side effect" means that ok() and err() values are not changed. We just use their values to do some stuff. But the return from .orTee() and .andTee() remains the same as input.

.combine(), .combineWithAllErrors(), .andThrough()

I didn't cover these because didn't find the exact use case or value for them. Happy to update this section if you share practical use cases in the comments.

Wrapping non-neverthrow code

Result.fromThrowable() vs basic primitives

If you want to continue working with the same list of basic primitives, you can convert sync function to Result like this:

import { ok, err, Result } from "neverthrow";

// ✅ basic primitives way
function safeJsonParse(str: string) {
try {
return ok(JSON.parse(str));
} catch (e) {
return err(`Invalid JSON: ${(e as Error).message}`);
}
}
// returned type is: Ok<any, never> | Err<never, `Invalid JSON: ${string}`>

// 🟠 is almost the same as:
const safeJsonParse = Result.fromThrowable(
JSON.parse,
(e) => `Invalid JSON: ${(e as Error).message}`
);
// returned type is: Result<any, string>
// notice "string" as error type here ^

// ✅ is almost the same as:
const safeJsonParse = Result.fromThrowable(
JSON.parse,
(e) => `Invalid JSON: ${(e as Error).message}` as const
);
// notice "as const" ^
// returned type is: Result<any, `Invalid JSON: ${string}`>


//////////////////////////////////////
// Results are the same:
console.log(safeJsonParse('{"valid": true}'));
// Ok({ valid: true })

console.log(safeJsonParse("oops"));
// Err("Invalid JSON: Unexpected token o in JSON at position 0")

ResultAsync.fromThrowable() vs basic primitives

note

This section in docs says that we don't ever need to use ResultAsync.fromPromise() because:

Note that this can be safer than using ResultAsync.fromPromise with the result of a function call, because not all functions that return a Promise are async, and thus they can throw errors synchronously rather than returning a rejected Promise.

But in my actual code testing, I found that they both tackle the throw just fine. Example with ResultAsync.fromPromise():

async function dummyFetch(path: string) {
const response = await fetch(path);
const data = await response.json();

throw new Error("Just a test error"); // explicitly throwing
return data;
}

const result = ResultAsync.fromPromise(
dummyFetch("https://jsonplaceholder.typicode.com/todos/1"),
() => "Failed to fetch"
);
const res = await result; // DOESN'T throw, despite the contrary info in the docs

Example with ResultAsync.fromThrowable():

async function dummyFetch(path: string) {
// throw new Error("Just a test error");
const response = await fetch(path);
const data = await response.json();
return data;
}

const result = ResultAsync.fromThrowable(
() => dummyFetch("https://jsonplaceholder.typicode.com/todos/1"),
() => "Failed to fetch" as const
);
const res = await result(); // Now you get Result<any, "Failed to fetch">

So,

  • ResultAsync.fromPromise(): Takes an ALREADY CREATED Promise
  • ResultAsync.fromThrowable(): Takes a FUNCTION that returns a Promise

Some more examples:

// Wrap fs.readFile
const safeReadFile = (path: string) =>
ResultAsync.fromThrowable(
() => fs.promises.readFile(path, "utf-8"),
(error) => {
if (error instanceof Error) {
if (error.message.includes("ENOENT")) {
return { type: "NOT_FOUND", path };
}
if (error.message.includes("EACCES")) {
return { type: "PERMISSION_DENIED", path };
}
}
return {
type: "UNKNOWN",
message: error instanceof Error ? error.message : "Unknown error",
};
}
);

const result = await safeReadFile("/filepath/file.txt")
if (result.isErr()) {
console.error(result.error) // do something useful
}

Another example:

// Original async function that might throw
async function fetchUserFromApi(userId: string) {
const response = await fetch(`https://api.com/users/${userId}`);

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

return response.json();
}

// Wrap it with fromThrowable and add rich error context
const safeFetchUser = ResultAsync.fromThrowable(
fetchUserFromApi,
(error): ApiError => {
if (error instanceof Error) {
const statusMatch = error.message.match(/HTTP (\d+)/);
return {
status: statusMatch ? parseInt(statusMatch[1]) : 500,
message: error.message,
endpoint: '/users'
};
}
return {
status: 500,
message: 'Unknown error occurred',
endpoint: '/users'
};
}
);

// Usage
async function example2() {
const result = await safeFetchUser('user-123');

result.match(
user => console.log(`Found user: ${user.name}`),
error => console.error(`API Error ${error.status}: ${error.message}`)
);
}

In the end, I'm lost 🤷‍♂️ on which one is better to use, both seem to achieve the same. Maybe somebody in the comments can make up my mind. .fromPromise() seems a bit nicer.

The boss helper .safeTry() / *yield

This is the most interesting one. In fact, it brings you closer to the effect library (which is not covered in this article, but is an "final boss" of TS libraries).

const result = await safeTry(async function* () {
const res1 = yield* await function1() // can be ResultAsync
const res2 = yield* function2() // you can also chain other helpers like function2().map().mapErr() etc
const sum = res1 + res2 // notice that we're not using res1.value here. Because it gets extracted automatically
if (sum < 0) {
return err("YOU WENT TOO DEEP")
}
return ok(res1 + res2)
});

if (result.isErr()) {
result.error
// ^ FetchError | ZodError
} else {
result.value
// ^ {user: User, foo: string}
}

Another example with fetch():

  async function dummyFetch(path: string) {
const response = await fetch(path);
const data = await response.json();
return data;
}

const resultFetch = ResultAsync.fromPromise(
dummyFetch("https://jsonplaceholder.typicode.com/todos/1"),
() => "Failed to fetch" as const
);

const result = await safeTry(async function* () {
const res = yield* await resultFetch;
return ok(res);
});
// result type: Result<any, "Failed to fetch">

if (result.isErr()) // do stuff

I don't know the exact magic of how it makes such syntax possible, but it's quite nice.

Neverthrow cheatsheet

Cheatsheet

// ✅ 👉 Default usage
function divide(a: number, b: number): {
if (b === 0) return err("Division by zero");
return ok(a / b);
}

// ✅ 👉 checking result
const result = divide()
if (result.isOk()) {
console.log(result.value);
}

if (result.isErr()) {
console.log(result.error);
}

// ✅ 👉 Transforming Success Values
// .map() - transform the Ok value
ok(5)
.map(x => x * 2)
.map(x => `Result: ${x}`);
// Ok("Result: 10")

// .andThen() - chain operations that return Results
ok(5)
.andThen(x => x > 0 ? ok(x) : err("Must be positive"))
.andThen(x => ok(x * 2));
// Ok(10)


// ✅ 👉 Transforming Error Values
// .mapErr() - transform the Err value
err("not found")
.mapErr(e => e.toUpperCase())
.mapErr(e => `Error: ${e}`);
// Err("Error: NOT FOUND")

// .orElse() - recover from errors
err("failed")
.orElse(e => ok("default value"));
// Ok("default value")


// ✅ 👉 Extracting Values
// .unwrapOr() - provide default value
const value = err("oops").unwrapOr(42);
// value is 42

// .match() - handle both cases
const message = divide(10, 2).match(
(value) => `Success: ${value}`,
(error) => `Error: ${error}`
);
// "Success: 5"

// ._unsafeUnwrap() - throws if Err (avoid in production!)
const risky = ok(42)._unsafeUnwrap(); // 42
// err("oops")._unsafeUnwrap(); // throws!

Real-World Example

Courtesy of Claude 4.5 Sonnet. Checked by me.

import { Result, ok, err, ResultAsync } from 'neverthrow';

type User = { id: number; email: string; age: number };
type ValidationError = string;

// Validation functions
function validateEmail(email: string): Result<string, ValidationError> {
if (!email.includes('@')) {
return err("Invalid email format");
}
return ok(email);
}

function validateAge(age: number): Result<number, ValidationError> {
if (age < 18) {
return err("Must be 18 or older");
}
return ok(age);
}

// Database operations
function saveUser(user: User): ResultAsync<User, string> {
return ResultAsync.fromPromise(
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user)
}).then(r => r.json()),
() => "Failed to save user"
);
}

// Compose operations
function createUser(
email: string,
age: number
): ResultAsync<User, ValidationError | string> {
return validateEmail(email)
.andThen(validEmail =>
validateAge(age).map(validAge => ({
id: 0,
email: validEmail,
age: validAge
}))
)
.asyncAndThen(user => saveUser(user));
}

// Usage
const result = await createUser("[email protected]", 25);

result.match(
(user) => console.log("User created:", user),
(error) => console.error("Failed:", error)
);

Combining sync & async Result:

// Sync validations
function validateEmail(email: string) {
if (!email.includes("@")) return err("Invalid email");
return ok(email);
}

// Async operation first
function fetchUser(id: number): ResultAsync<{ email: string }, string> {
return ResultAsync.fromPromise(
new Promise((resolve) => {
setTimeout(() => {
resolve({ email: "[email protected]" });
}, 100); // simulate async delay
}),
() => "Failed to fetch user"
);
}

// Then sync transformations
const result = fetchUser(1) // Async function
.map((user) => user.email) // Sync map works on ResultAsync!
.andThen((email) => validateEmail(email)) // Sync andThen works too!
.map((email) => email.toLowerCase());

const res = await result; // Now you get Result<string, string>
console.log(res); // Ok("[email protected]")

vs Byethrow

Disclaimer: I didn't dive too deep into byethrow, so if things are incorrect - please write a comment or write to me directly. And I'll make sure to fix it.

byethrow is quite similar to neverthrow and some may prefer it's syntax. I find byethrow to be more verbose.

There is a nice article by Karibash (creator of byethrow) that covers benefits & differences: https://dev.to/karibash/a-tree-shakable-result-library-29b6

@praha/byethrow represents Result values as plain serializable objects instead of classes: https://github.com/praha-inc/byethrow/blob/9dce606355a85c9983c24803972ce2280b3bafab/packages/byethrow/src/result.ts#L5-L47 This allows you to safely serialize Result instances to JSON, making it ideal for server-client boundaries such as returning from React Server Components' ServerActions. You can return a Result from the server and continue processing it on the client using @praha/byethrow's utility functions.

Sidenote: if you like the syntax of byethrow, then you may as well like effect, which has very similar syntax if you use it for same use cases as byethrow.

TLDR

neverthrowbyethrow
successok() / okAsync()Result.succeed()
failureerr() / errAsync()Result.fail()
check.isOk()Result.isSuccess(Result)
check.isErr()Result.isFailure(Result)
chain.andThen() / .asyncAndThen()Result.andThen(Result) wrapped in Result.pipe()
chainjust call the method on ResultResult.pipe()
transform.map() / .asyncMap()Result.map(Result)
transform.mapErr()Result.mapError(Result)
extract.match()Result.match(Result)
fallback.orElse() - when returning ResultResult.orElse()
fallback.unwrapOr() - when returning valueResult.upwrap()
wrapping existing codeResult.fromThrowable() or ResultAsync.fromThrowable() or ResultAsync.fromPromise() or safeTry()Result.try()
side effect.andTee()Result.inspect()
side effect.orTee()Result.inspectError()
.safeTry() - for generator function + yield syntax-
.andThrough() / .asyncAndThrough()Result.andThrough()
.combine() / .combineWithAllErrors()Result.combine()

Creating results

import { ok, err, Result } from 'neverthrow';

const success: Result<number, string> = ok(42);
const failure: Result<number, string> = err("failed");

// vs

import { Result } from '@praha/byethrow';

const success = Result.succeed(42);
const failure = Result.fail("failed");

Chaining

.map()

// neverthrow
ok(5)
.map(x => x * 2) // Transform value
.map(x => x.toString()); // Chain transformations

// byethrow
Result.pipe( // notice the "pipe"
Result.succeed(5),
Result.map(x => x * 2), // Transform value
Result.map(x => x.toString()) // Chain transformations
);

.andThen()

// neverthrow
ok(5)
.andThen(x => x > 0 ? ok(x) : err("negative"))
.andThen(x => divide(10, x));

// byethrow
Result.pipe(
Result.succeed(5),
Result.andThen(x => x > 0 ? Result.succeed(x) : Result.fail("negative")),
Result.andThen(x => divide(10, x))
);

Extracting values

// neverthrow
const result = ok(42);

// Method 1: match
result.match(
value => console.log(value),
error => console.error(error)
);

// Method 2: unwrapOr
const value = result.unwrapOr(0);

// Method 3: isOk/isErr with type guards
if (result.isOk()) {
console.log(result.value);
}

///////////////////////////////////
// byethrow
const result = Result.succeed(42);

// Method 1: match
Result.match(result,
value => console.log(value),
error => console.error(error)
);

// Method 2: getOrElse
const value = Result.OrElse(result, () => 0);

// Method 3: isSuccess/isFailure with type guards
if (Result.isSuccess(result)) {
console.log(result.value);
}

More comparisons:

// BEFORE (Neverthrow)
import { ok, err } from 'neverthrow';

const result = ok(user)
.map(u => u.email)
.andThen(validateEmail)
.mapErr(e => new Error(e))
.unwrapOr("[email protected]");

// AFTER (Byethrow)
import { Result } from '@praha/byethrow';

const result = Result.pipe(
Result.succeed(user),
Result.map(u => u.email),
Result.andThen(validateEmail),
Result.mapError(e => new Error(e)),
Result.orElse(() => "[email protected]")
);

Byethrow "Result" doesn't have methods

If you create a function with byethrow like this:

function test() {
if (Math.random() > 0.5) return Result.succeed('Hey-ho!' as const);
return Result.fail(new Error('failed math' as const));
}

const res = test()
// res type: Result.Success<"Hey-ho!"> | Result.Failure<Error>

Then res only has .type property exposed.

const res = test(); // only res.type is available here
if (Result.isFailure(res)) {
const fail = res.error; // now we get access to res.error
console.error(fail)
}

This is the same reason that you can't just use test().andThen() in byethrow as you can with neverthrow.


Final words

All of this neverthrow hassle just for the sake of having typed errors...😮‍💨. Might as well learn C#, as it's very similar to TypeScript. 😅

This article is ~30% sprinkled with examples & explanation from Claude 4.5 Sonnet (via https://claude.ai/chat). I proof-read all the examples & checked for logical errors. Kinda guilty and kinda not. No documentation is worse than AI-assisted documentation.

About me

I've been coding since 2020 (when COVID-19 started). Never finished any "Programming course" or certification (except for CS50 online at Hardvard). My source of information has always been YouTube, docs, some of my friends and Reddit.


Ok, now really final words.

I decided to ask Claude 4.5 Sonnet to create full Beginner's Guide to neverthrow. Here is what it produced. Proof-read and checked by me.

Makes me feel a bit weird, because it reads like a better guide than I was able to produce myself... 😅

Neverthrow: The Complete Beginner's Guide by Claude 4.5 Sonnet

The Problem With Traditional Error Handling

How We Usually Handle Errors

// Traditional approach with try/catch
function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}

// Using it:
try {
const result = divide(10, 0);
console.log(result);
} catch (error) {
console.error("Something went wrong:", error);
}

What's Wrong With This?

  1. Invisible errors: Looking at divide(a, b), you have NO idea it might throw
  2. Easy to forget: Nothing forces you to use try/catch
  3. Lost context: Once you catch an error, you've lost type information
  4. Doesn't compose: Hard to chain operations that might fail
// This looks innocent, but any line could throw!
const user = getUser(id); // Could throw
const validated = validate(user); // Could throw
const saved = save(validated); // Could throw
// Did you remember to wrap ALL of this in try/catch?

A New Way: Errors as Values

The Big Idea

Instead of throwing errors, we return them as normal values. The function's type signature tells you exactly what can go wrong.

import { Result, ok, err } from 'neverthrow';

// New approach: Error is part of the return type
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err("Cannot divide by zero");
}
return ok(a / b);
}

// TypeScript FORCES you to handle both success and failure
const result = divide(10, 2);
// result is Result<number, string>
// You can't just use it like a number!

Why This Is Better

  • Explicit: The type signature says "this returns a number OR an error"
  • Type-safe: TypeScript won't let you ignore errors
  • Composable: Easy to chain operations
  • Clear: Looking at the code, you know what can fail

Core Concept

Result<T, E>

Think of a Result as a box that contains EITHER:

  • Success: Ok(value) - wrapped success value of type T
  • Failure: Err(error) - wrapped error value of type E
// A successful result containing the number 42
const success: Result<number, string> = ok(42);

// A failed result containing an error message
const failure: Result<number, string> = err("Something went wrong");

The Mental Model: A Two-Track System

Input → [Function] → Ok(value)   ← Success track
↘ Err(error) ← Error track

Once you're on the error track, you STAY on the error track (unless you explicitly recover).


Basic Usage

Creating Results

import { ok, err, Result } from 'neverthrow';

// Success
const success = ok(42);
const successString = ok("Hello");
const successObject = ok({ id: 1, name: "Alice" });

// Failure
const failure = err("Not found");
const failureCode = err(404);
const failureObject = err({ code: "ERR_001", message: "Failed" });

Checking What You Got

const result = divide(10, 2);

// Method 1: Using isOk() and isErr()
if (result.isOk()) {
console.log("Success:", result.value); // TypeScript knows .value exists
} else {
console.log("Error:", result.error); // TypeScript knows .error exists
}

// Method 2: Using match() - the most common pattern
const message = result.match(
(value) => `Got result: ${value}`, // Called if Ok
(error) => `Got error: ${error}` // Called if Err
);

// Method 3: Get value with a default
const value = result.unwrapOr(0); // If error, use 0 instead

A Complete Example

type ValidationError = string;

function validateAge(age: number): Result<number, ValidationError> {
if (age < 0) {
return err("Age cannot be negative");
}
if (age > 150) {
return err("Age seems unrealistic");
}
return ok(age);
}

// Using it
const result1 = validateAge(25);
console.log(result1.isOk()); // true

const result2 = validateAge(-5);
console.log(result2.isErr()); // true

// Handle both cases
validateAge(25).match(
(age) => console.log(`Valid age: ${age}`),
(error) => console.log(`Invalid: ${error}`)
);

The Railway Metaphor

This is THE key mental model for understanding neverthrow.

Imagine Two Parallel Railway Tracks

Success Track:  ━━━━━━━━━━━━━━━━━━━━━━━━━━ Ok(value)
↓ (operation succeeds)
━━━━━━━━━━━━━━━━━━━━━━━━━━ Ok(new value)

Error Track: ━━━━━━━━━━━━━━━━━━━━━━━━━━ Err(error)
↓ (stays on error track)
━━━━━━━━━━━━━━━━━━━━━━━━━━ Err(same error)

How It Works

  1. You start on one track (either success or error)
  2. Operations transform values on the success track
  3. If an operation fails, you switch to the error track
  4. Once on the error track, you stay there (unless you explicitly recover)
const result = ok(10)
.map(x => x * 2) // Still ok: Ok(20)
.map(x => x + 5) // Still ok: Ok(25)
.andThen(x => {
if (x > 20) return err("Too big");
return ok(x);
}) // Switches to error track: Err("Too big")
.map(x => x * 100); // Never runs! Still Err("Too big")

// Result is Err("Too big")

Why This Matters

You can chain many operations, and if ANY fail, the rest automatically skip. No nested if-statements needed!

// Traditional approach - nested ifs
function process(data: string): number | null {
const parsed = tryParse(data);
if (parsed === null) return null;

const validated = validate(parsed);
if (validated === null) return null;

const transformed = transform(validated);
if (transformed === null) return null;

return transformed;
}

// Neverthrow approach - linear flow
function process(data: string): Result<number, string> {
return parseData(data)
.andThen(validate)
.andThen(transform);
// If ANY step fails, we skip the rest automatically!
}

Transforming Data

.map() - Transform Success Values

Use .map() when you want to change a value, and that change cannot fail.

// Think of .map() as: "If successful, do this transformation"
ok(5)
.map(x => x * 2) // Ok(10)
.map(x => x.toString()) // Ok("10")
.map(x => x + " items") // Ok("10 items")
.map(x => x.toUpperCase()) // Ok("10 ITEMS")

// On errors, .map() does nothing
err("failed")
.map(x => x * 2) // Still Err("failed") - map is skipped

Real example:

type User = { firstName: string; lastName: string };

function getUser(id: number): Result<User, string> {
// ... fetch user
return ok({ firstName: "Alice", lastName: "Smith" });
}

const fullName = getUser(1)
.map(user => `${user.firstName} ${user.lastName}`)
.map(name => name.toUpperCase());

// fullName is Result<string, string>
// If getUser succeeded: Ok("ALICE SMITH")
// If getUser failed: Err("...")

.andThen() - Chain Operations That Can Fail

Use .andThen() when your transformation can fail (returns another Result).

// Each step can fail
function parseNumber(str: string): Result<number, string> {
const num = parseFloat(str);
return isNaN(num) ? err("Not a number") : ok(num);
}

function validatePositive(num: number): Result<number, string> {
return num > 0 ? ok(num) : err("Must be positive");
}

function squareRoot(num: number): Result<number, string> {
return num >= 0 ? ok(Math.sqrt(num)) : err("Cannot sqrt negative");
}

// Chain them together
const result = parseNumber("16")
.andThen(validatePositive)
.andThen(squareRoot)
.map(x => x.toFixed(2));

// Result: Ok("4.00")

// If any step fails, the rest are skipped
const failed = parseNumber("-4")
.andThen(validatePositive) // Fails here!
.andThen(squareRoot) // Skipped
.map(x => x.toFixed(2)); // Skipped

// Result: Err("Must be positive")

When to Use Which?

Use .map() when:

  • Transformation always succeeds
  • Converting types (number → string)
  • Formatting data
  • Example: .map(user => user.email)

Use .andThen() when:

  • Transformation might fail
  • Calling another function that returns Result
  • Validation logic
  • Example: .andThen(email => validateEmail(email))

Handling Errors

.mapErr() - Transform Error Values

Just like .map() transforms success values, .mapErr() transforms error values.

// Transform the error message
err("not found")
.mapErr(e => e.toUpperCase()) // Err("NOT FOUND")
.mapErr(e => `Error: ${e}`) // Err("Error: NOT FOUND")
.mapErr(e => ({ code: 404, message: e })); // Err({ code: 404, ... })

// On success, mapErr does nothing
ok(42)
.mapErr(e => "This never runs"); // Still Ok(42)

Real example:


function fetchUser(id: number): Result<User, string> {
// Returns simple string errors
return err("Network timeout");
}

// Convert to structured errors
const result = fetchUser(1)
.mapErr(message => ({
code: "FETCH_ERROR",
message: message,
timestamp: new Date()
}));

.orElse() - Recover From Errors

Use .orElse() to try an alternative when something fails.

function getUserFromCache(id: number): Result<User, string> {
return err("Not in cache");
}

function getUserFromDb(id: number): Result<User, string> {
return ok({ id, name: "Alice" });
}

// Try cache first, fallback to database
const user = getUserFromCache(1)
.orElse(error => {
console.log("Cache miss:", error);
return getUserFromDb(1); // Try database instead
});

// user is Ok({ id: 1, name: "Alice" })

Another pattern - default values:

function getConfigValue(key: string): Result<string, string> {
return err("Config not found");
}

const value = getConfigValue("theme")
.orElse(() => ok("default-theme")); // Provide default

// value is Ok("default-theme")

.unwrapOr() - Get Value or Default

Simplest way to handle errors: provide a fallback value.

const result1 = ok(42).unwrapOr(0);     // 42
const result2 = err("fail").unwrapOr(0); // 0

// Real example
const userAge = getUser(id)
.map(user => user.age)
.unwrapOr(18); // Default to 18 if anything fails

// userAge is a plain number, not a Result

Async Operations

Mixing Sync and Async

You can use .map() and .andThen() with BOTH Result and ResultAsync:

// Sync validation function
function validateEmail(email: string): Result<string, string> {
return email.includes('@') ? ok(email) : err("Invalid email");
}

// Mix sync and async!
const result = fetchUser(1) // fetchUser is async
.map(user => user.email) // Sync map on ResultAsync - works!
.andThen(validateEmail) // Sync andThen on ResultAsync - works!
.map(email => email.toLowerCase());

// result is still ResultAsync
await result; // Get final Result

Going from Sync to Async

// Start with sync Result
const emailResult: Result<string, string> = ok("[email protected]");

// Need to do async operation?
const saved = emailResult.asyncAndThen(email =>
saveToDatabase(email) // Returns ResultAsync
);

// Or use asyncMap for async transformations
const processed = emailResult.asyncMap(async (email) => {
await someAsyncOperation();
return email.toLowerCase();
});

Combining Results

Validating Multiple Fields

Often you need multiple things to succeed:

function validateName(name: string): Result<string, string> {
return name.length > 0 ? ok(name) : err("Name required");
}

function validateEmail(email: string): Result<string, string> {
return email.includes('@') ? ok(email) : err("Invalid email");
}

function validateAge(age: number): Result<number, string> {
return age >= 18 ? ok(age) : err("Must be 18+");
}

// Combine all validations
const validatedUser = Result.combine([
validateName("Alice"),
validateEmail("[email protected]"),
validateAge(25)
]);

// If ALL succeed: Ok(["Alice", "[email protected]", 25])
// If ANY fail: Err("first error message")

validatedUser.map(([name, email, age]) => ({
name,
email,
age
}));

Collecting All Errors

Sometimes you want ALL error messages, not just the first:

const results = Result.combineWithAllErrors([
validateName(""), // Err("Name required")
validateEmail("bad-email"), // Err("Invalid email")
validateAge(15) // Err("Must be 18+")
]);

// results is Err(["Name required", "Invalid email", "Must be 18+"])

Combining Async Results

// Run multiple async operations in parallel
const dashboard = ResultAsync.combine([
fetchUser(1),
fetchPosts(1),
fetchComments(1)
]).map(([user, posts, comments]) => ({
user,
postCount: posts.length,
commentCount: comments.length
}));

await dashboard.match(
(data) => console.log("Dashboard:", data),
(error) => console.error("Failed:", error)
);

Real-World Patterns

Pattern 1: Form Validation

type FormData = {
email: string;
password: string;
age: number;
};

type ValidationError = string;

function validateEmail(email: string): Result<string, ValidationError> {
if (!email.includes('@')) return err("Invalid email format");
if (email.length < 5) return err("Email too short");
return ok(email);
}

function validatePassword(password: string): Result<string, ValidationError> {
if (password.length < 8) return err("Password must be 8+ characters");
if (!/[A-Z]/.test(password)) return err("Password needs uppercase letter");
if (!/[0-9]/.test(password)) return err("Password needs a number");
return ok(password);
}

function validateAge(age: number): Result<number, ValidationError> {
if (age < 13) return err("Must be 13 or older");
if (age > 120) return err("Invalid age");
return ok(age);
}

function validateForm(data: FormData): Result<FormData, ValidationError> {
return Result.combine([
validateEmail(data.email),
validatePassword(data.password),
validateAge(data.age)
]).map(([email, password, age]) => ({
email,
password,
age
}));
}

// Usage
const formData = {
email: "[email protected]",
password: "SecurePass123",
age: 25
};

validateForm(formData).match(
(valid) => console.log("Form valid:", valid),
(error) => console.error("Validation error:", error)
);

Pattern 2: API Request Pipeline

type ApiResponse = { data: unknown };
type ApiError = { status: number; message: string };

function makeRequest(url: string): ResultAsync<Response, ApiError> {
return ResultAsync.fromPromise(
fetch(url),
() => ({ status: 0, message: "Network error" })
);
}

function checkStatus(response: Response): Result<Response, ApiError> {
if (response.ok) return ok(response);
return err({
status: response.status,
message: `HTTP ${response.status}`
});
}

function parseJSON(response: Response): ResultAsync<ApiResponse, ApiError> {
return ResultAsync.fromPromise(
response.json(),
() => ({ status: 0, message: "Invalid JSON" })
);
}

// Complete pipeline
function fetchData(url: string): ResultAsync<ApiResponse, ApiError> {
return makeRequest(url)
.andThen(checkStatus)
.andThen(parseJSON);
}

// Usage
await fetchData('/api/users').match(
(data) => console.log("Success:", data),
(error) => console.error(`Error ${error.status}: ${error.message}`)
);

Pattern 3: Multi-Step User Registration

type User = { id: number; email: string; passwordHash: string };
type RegError = "EMAIL_TAKEN" | "WEAK_PASSWORD" | "DB_ERROR";

async function registerUser(
email: string,
password: string
): Promise<Result<User, RegError>> {
return validateEmail(email)
.mapErr(() => "WEAK_PASSWORD" as RegError)
.andThen(() => validatePassword(password))
.mapErr(() => "WEAK_PASSWORD" as RegError)
.asyncAndThen(async () => {
// Check if email exists
const exists = await checkEmailExists(email);
return exists.andThen(taken =>
taken ? err("EMAIL_TAKEN" as RegError) : ok(email)
);
})
.andThen(validEmail => {
// Hash password
return hashPassword(password)
.mapErr(() => "DB_ERROR" as RegError)
.map(hash => ({ email: validEmail, hash }));
})
.asyncAndThen(({ email, hash }) => {
// Save to database
return saveUser({ id: 0, email, passwordHash: hash })
.mapErr(() => "DB_ERROR" as RegError);
});
}

// Usage with specific error handling
const result = await registerUser("[email protected]", "SecurePass123");

result.match(
(user) => console.log("User registered:", user.id),
(error) => {
switch (error) {
case "EMAIL_TAKEN":
console.error("That email is already registered");
break;
case "WEAK_PASSWORD":
console.error("Password doesn't meet requirements");
break;
case "DB_ERROR":
console.error("Server error, please try again");
break;
}
}
);

Pattern 4: Parsing and Transforming Data

type RawData = { value: string };
type ParsedData = { value: number };

function fetchRawData(): ResultAsync<RawData, string> {
return ResultAsync.fromPromise(
fetch('/api/data').then(r => r.json()),
() => "Fetch failed"
);
}

function parseValue(raw: RawData): Result<ParsedData, string> {
const num = parseFloat(raw.value);
if (isNaN(num)) return err("Invalid number");
return ok({ value: num });
}

function validateRange(data: ParsedData): Result<ParsedData, string> {
if (data.value < 0 || data.value > 100) {
return err("Value must be 0-100");
}
return ok(data);
}

function transformData(data: ParsedData): ParsedData {
return { value: data.value * 2 };
}

// Pipeline: fetch → parse → validate → transform
const finalData = fetchRawData()
.andThen(parseValue)
.andThen(validateRange)
.map(transformData)
.map(data => `Result: ${data.value}`);

await finalData.match(
(result) => console.log(result),
(error) => console.error("Pipeline failed:", error)
);

Mental Model

Think in Terms of Tracks

Every operation in your code is like a train station:

        ┌─────────────┐
Input → │ Operation │ → Ok(result) ✓ Success track
│ │ ↘ Err(error) ✗ Error track
└─────────────┘

Once on a track, you stay there unless you explicitly switch:

ok(10)                           // Start on success track
.map(x => x * 2) // Stay on success track: Ok(20)
.andThen(x => {
if (x > 15) return err("Too big");
return ok(x);
}) // Switch to error track: Err("Too big")
.map(x => x + 100) // Still on error track (skipped)
.orElse(() => ok(0)) // Switch back to success: Ok(0)
.map(x => x + 1); // Stay on success: Ok(1)

Functions Return Tracks, Not Just Values

Traditional thinking:

function(input) → output

Neverthrow thinking:

function(input) → Result<output, error>

"Which track?"

Composition Is Key

Small functions that return Results can be combined into complex flows:

// Small, focused functions
const parseName = (str: string) => /* ... */;
const validateName = (name: string) => /* ... */;
const formatName = (name: string) => /* ... */;
const saveName = (name: string) => /* ... */;

// Compose them
const processName = (input: string) =>
parseName(input)
.andThen(validateName)
.andThen(formatName)
.asyncAndThen(saveName);

// Any step can fail, and the rest will be skipped automatically!

Summary: The Neverthrow Way

  1. Make errors explicit: Function signatures show what can fail
  2. Return, don't throw: Errors are values, not exceptions
  3. Chain operations: Use .map() and .andThen() to build pipelines
  4. Stay on the tracks: Once an error occurs, subsequent operations are skipped
  5. Handle at the end: Use .match(), .unwrapOr(), or other methods to extract values
  6. Type safety: Let TypeScript ensure you handle all cases

Instead of this:

try {
const user = getUser(id);
const validated = validate(user);
const saved = save(validated);
return saved;
} catch (error) {
console.error(error);
return null;
}

You write this:

return getUser(id)
.andThen(validate)
.asyncAndThen(save)
.match(
(saved) => saved,
(error) => {
console.error(error);
return null;
}
);

Cleaner, safer, and more composable! 🚂