DEV Community

Cover image for neverthrow - tutorial (with a bit of "byethrow")
DJ NUO (Alex Nesterov)
DJ NUO (Alex Nesterov)

Posted on • Originally published at dj-nuo.com

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

This article is originally published on my blog

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


link - npm download trends of some relevant libs

Before reading this article, I would highly advise to watch this talk from Scott Wlaschin, which explains the whole (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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

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()
Enter fullscreen mode Exit fullscreen mode

.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
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.


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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

._unsafeUnwrap()

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

.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))
Enter fullscreen mode Exit fullscreen mode

"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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}`)
  );
}
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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("alice@example.com", 25);

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

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: "Test@example.com" });
        }, 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("test@example.com")
Enter fullscreen mode Exit fullscreen mode

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

neverthrow byethrow
success ok() / okAsync() Result.succeed()
failure err() / errAsync() Result.fail()
check .isOk() Result.isSuccess(Result)
check .isErr() Result.isFailure(Result)
chain .andThen() / .asyncAndThen() Result.andThen(Result) wrapped in Result.pipe()
chain just call the method on Result Result.pipe()
transform .map() / .asyncMap() Result.map(Result)
transform .mapErr() Result.mapError(Result)
extract .match() Result.match(Result)
fallback .orElse() - when returning Result Result.orElse()
fallback .unwrapOr() - when returning value Result.upwrap()
wrapping existing code Result.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");

Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

.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))
);

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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("default@email.com");

// 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(() => "default@email.com")
);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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

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");
Enter fullscreen mode Exit fullscreen mode

The Mental Model: A Two-Track System

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

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" });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`)
);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

Transforming Data {#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
Enter fullscreen mode Exit fullscreen mode

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("...")
Enter fullscreen mode Exit fullscreen mode

.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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
  }));
Enter fullscreen mode Exit fullscreen mode

.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" })
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Going from Sync to Async

// Start with sync Result
const emailResult: Result<string, string> = ok("alice@example.com");

// 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();
});
Enter fullscreen mode Exit fullscreen mode

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("alice@example.com"),
  validateAge(25)
]);

// If ALL succeed: Ok(["Alice", "alice@example.com", 25])
// If ANY fail: Err("first error message")

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

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+"])
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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: "alice@example.com",
  password: "SecurePass123",
  age: 25
};

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

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}`)
);
Enter fullscreen mode Exit fullscreen mode

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("alice@example.com", "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;
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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
        └─────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Functions Return Tracks, Not Just Values

Traditional thinking:

function(input) → output
Enter fullscreen mode Exit fullscreen mode

Neverthrow thinking:

function(input) → Result<output, error>
                     ↑
                  "Which track?"
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

You write this:

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

Cleaner, safer, and more composable! 🚂

Top comments (0)