Introduction
When you want to handle errors in a type-safe way with TypeScript, what kind of approach would you take?
The standard JavaScript/TypeScript error-handling mechanism — try/catch
— lacks type safety and makes it difficult to track which parts of the code may throw errors.
To solve this issue, a common approach is to use Result types for error handling.
A Result type explicitly represents both a success value and a failure error.
When it comes to Result type libraries in TypeScript, neverthrow is the most well-known and widely used option.
Recently, more comprehensive ecosystems such as Effect have emerged, but if you only need to handle Result types, these can feel like overkill.
In this article, I’ll discuss the limitations I encountered while using neverthrow
, how those experiences led me to create a new Result-type library called byethrow, and introduce its core design concepts.
The Limitations I Found with neverthrow
neverthrow
is an excellent library that has been adopted in many projects.
I’ve personally used it extensively in real-world projects over the years.
However, as I continued to use it, I started to encounter some design limitations.
Limitations of the Class-Based Design
The neverthrow
Result type is implemented as several classes such as Ok, Err, and ResultAsync.
This makes intuitive method chaining possible, but adding your own custom behavior becomes quite difficult.
// Since neverthrow's Result is class-based, you need inheritance to add custom methods.
// However, because all existing methods return the default `Ok`/`Err`,
// you have to override all of them to keep type consistency.
type MyResult<T, E> = MyOk<T, E> | MyErr<T, E>;
class MyOk<T, E> extends Ok<T, E> {
isOk(): this is MyOk<T, E> {
return super.isOk();
}
map<A>(f: (t: T) => A): MyResult<A, E> {
return new MyOk(f(this.value))
}
// All other methods must be overridden as well
}
class MyErr<T, E> extends Err<T, E> {
// Same here: all methods need to be overridden
}
As you can see, the class-based design lacks extensibility and makes it difficult to add custom behaviors.
You could avoid this by defining standalone functions instead, but that comes at the cost of convenient method chaining.
Separation of Synchronous and Asynchronous APIs
In neverthrow
, different APIs are provided for synchronous and asynchronous Results.
import { ok, okAsync, Result, ResultAsync } from 'neverthrow';
// Synchronous
const syncResult: Result<string, Error> = ok('value');
// Asynchronous
const asyncResult: ResultAsync<string, Error> = okAsync('value');
// When chaining Results
const combined: ResultAsync<string, Error> = ok('value')
.andThen((value) => ok(value)) // chaining sync Results
.asyncAndThen((value) => okAsync(`${value} async`)); // chaining async Results
You have to distinguish between ok
and okAsync
, Result
and ResultAsync
, and you can’t compose sync and async Results naturally.
In real-world applications, synchronous and asynchronous operations often coexist, so this separation hurts the developer experience.
Stagnant Maintenance
Looking at the neverthrow GitHub repository, you can see that many issues and pull requests have been left unattended for quite some time.
This seems to be mainly because the maintainer is too busy to dedicate enough time to OSS maintenance.
Although a call for maintainers was posted in the past and one maintainer was added, the project still doesn’t see much active maintenance or updates.
Reinventing the Ideal Result Library
To solve these problems, I decided to design and implement a new Result type library from scratch.
While respecting the philosophy of neverthrow
, I restructured it with a more functional (FP) approach — this is byethrow.
Core Design of byethrow
byethrow
inherits the good parts of neverthrow
while aiming for a more flexible and practical design.
- Extensible: Users can easily add custom operations
- Composable: Works seamlessly across sync and async boundaries
- Minimal: Focuses purely on Result, easy to integrate into any codebase
Simple Object Structure
In byethrow
, a Result is represented as a simple object, not a class.
import { Result } from '@praha/byethrow';
const success = Result.succeed(42);
// { type: 'Success', value: 42 }
const failure = Result.fail(new Error('Something went wrong'));
// { type: 'Failure', error: Error }
Because it’s not class-based, users can freely add their own functions.
It also supports a flexible, functional programming–friendly design using pipe()
to compose operations.
const validateId = (id: string) => {
if (!id.startsWith('u')) {
return Result.fail(new Error('Invalid ID format'));
}
return Result.succeed(id);
};
const findUser = Result.try({
try: (id: string) => ({ id, name: 'John Doe' }),
catch: (error) => new Error('Failed to find user', { cause: error }),
});
const result = Result.pipe(
Result.succeed('u123'),
Result.andThrough(validateId),
Result.andThen(findUser),
);
if (Result.isSuccess(result)) {
console.log(result.value); // { id: 'u123', name: 'John Doe' }
}
Unified Sync/Async API
With byethrow
, you don’t need to care whether a Result is sync or async.
import { Result } from '@praha/byethrow';
// Works with both sync and async values using the same API
const syncResult: Result.Result<string, Error> = Result.succeed('value');
const asyncResult: Result.ResultAsync<string, Error> = Result.succeed(Promise.resolve('value'));
// Promises are automatically promoted to async Results
const combined: Result.ResultAsync<string, Error> = Result.pipe(
Result.succeed('value'),
Result.andThen((value) => Result.succeed(value)),
Result.andThen((value) => Result.succeed(Promise.resolve(`${value} async`))),
);
succeed()
and andThen()
automatically detect Promises and promote them to asynchronous Results, so developers can build pipelines without worrying about sync/async boundaries.
Useful Functions and Types Absent in neverthrow
byethrow
includes many powerful utilities that neverthrow
doesn’t provide.
bind: Add Properties to an Object
bind()
lets you safely add new properties to an object within a successful Result.
import { Result } from '@praha/byethrow';
const result = Result.pipe(
Result.succeed({ name: 'Alice' }),
Result.bind('age', () => Result.succeed(20)),
Result.bind('email', () => Result.succeed('alice@example.com')),
);
// result: Success<{ name: string, age: number, email: string }>
This is extremely handy when building objects step by step in validation or data-fetching pipelines.
collect / sequence: Aggregate Multiple Results
You can run multiple Results in parallel — if all succeed, their values are merged; if any fail, the errors are aggregated.
import { Result } from '@praha/byethrow';
// For objects
const result = Result.collect({
user: fetchUser(),
posts: fetchPosts(),
comments: fetchComments(),
});
// Success<{ user: User, posts: Post[], comments: Comment[] }> or Failure<Error[]>
// For arrays
const results = Result.collect([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
// Success<[User, Post[], Comment[]]> or Failure<Error[]>
InferSuccess / InferFailure: Automatic Type Extraction
You can automatically extract success and failure types from a Result or a function returning one — both for sync and async Results.
import { Result } from '@praha/byethrow';
type R = Result.Result<number, string>;
type RSuccess = Result.InferSuccess<R>; // number
type RFailure = Result.InferFailure<R>; // string
type AR = Result.ResultAsync<boolean, Error>;
type ARSuccess = Result.InferSuccess<AR>; // boolean
type ARFailure = Result.InferFailure<AR>; // Error
const fn = (value: number) =>
value < 0 ? Result.fail('Negative value') : Result.succeed(value);
type FnSuccess = Result.InferSuccess<typeof fn>; // number
type FnFailure = Result.InferFailure<typeof fn>; // 'Negative value'
Conclusion
Rather than aiming to be a comprehensive ecosystem like Effect, byethrow
focuses solely on the Result type, pursuing a lightweight and practical design.
byethrow
is under active development, with ongoing improvements such as:
- Enforcing best practices via ESLint rules
- Further improving type inference
- Enhancing documentation
Since it’s open source, feel free to check out the repository, give it a 🌟, or contribute via PR!
https://github.com/praha-inc/byethrow
If you’re struggling with error handling in TypeScript or feel limited by neverthrow
, I encourage you to try out byethrow
.
I’d love to hear your feedback.
We also publish several other TypeScript-related libraries that may help in your development — check them out here:
Top comments (0)