Error handling. It's one of those fundamental aspects of software development that can significantly impact reliability and maintainability, yet the "best" approach often feels elusive, especially in flexible languages like TypeScript. Here at Trylon AI, as we built out our platform, we went through a distinct evolution in how we handled operations that could fail, culminating in an open-source library we call ts-result
. This is the story of how we got there.
The Starting Point: try-catch
and Ambiguity
Like many TypeScript projects, our initial codebase relied heavily on standard JavaScript patterns. This often meant:
- Liberal use of
try-catch
blocks, especially around database interactions, API calls, or complex business logic. - Functions sometimes returning
null
orundefined
to signal "not found" or other non-successful states.
While functional, these approaches had drawbacks:
try-catch
blocks, while necessary for unexpected runtime errors, often made the primary logic flow harder to follow when used for predictable, non-exceptional failures.- Returning
null
was ambiguous. Did it mean "no user found" (a valid outcome) or "database query failed"? This required careful checking and context-dependent interpretation.
Consider a typical API route handler using these patterns:
// Simplified early version
export async function GET(request: Request) {
try {
// Auth might throw or return null
const user = await validateAuth(request);
if (!user) {
return new Response(JSON.stringify({
success: false,
message: 'User not found'
}), { status: 401 });
}
// Database call might throw
const userData = await database.getUser(user.id);
if (!userData) {
return new Response(JSON.stringify({
success: false,
message: 'User data not found'
}), { status: 404 });
}
return new Response(JSON.stringify({
success: true,
data: userData
}), { status: 200 });
} catch (error: any) {
console.error('Error:', error);
// Which error was it? Auth? DB? Validation?
const statusCode = error.status || 500;
const message = error.message || 'An unexpected error occurred';
return new Response(JSON.stringify({
success: false,
message
}), { status: statusCode });
}
}
This pattern works, but has several issues:
- Error handling logic is scattered throughout the function
- The
catch
block handles a mix of expected and unexpected errors - Type safety is limited with generic error objects
- The function's return type doesn't express that it could fail in specific ways
Step 1: Explicit Outcome Objects - Our Internal Result
To make function outcomes more explicit, we introduced a simple internal pattern. We defined interfaces for Success
and Failure
and used factory functions to create them:
// Our internal types (simplified)
interface Success<T> {
success: true;
data: T;
metadata?: Record<string, string>;
}
interface Failure {
success: false;
message: string;
httpStatusCode?: number; // For API responses
metadata?: Record<string, string>;
}
type Result<T> = Success<T> | Failure;
// Factory functions
const Results = {
success<T>(data: T, metadata?: Record<string, string>): Success<T> {
return { success: true, data, metadata };
},
failure(
message: string,
httpStatusCode?: number,
metadata?: Record<string, string>
): Failure {
return { success: false, message, httpStatusCode, metadata };
},
// A helper for chaining Results
async chain<T, U>(
result: Result<T>,
fn: (data: T) => Promise<Result<U>>
): Promise<Result<U>> {
if (!result.success) {
return result; // Return the failure unchanged
}
return await fn(result.data);
},
};
Our service methods started returning Promise<Result<T>>
. This was a definite improvement. The function signature now hinted at a binary outcome, and we could carry specific error messages and status codes.
Our API service code began to look like this:
// Example service method using internal Result
import { Result, Results } from './types';
export class UserService {
public static async getUser(
userId: string
): Promise<Result<User>> {
try {
const user = await db.collection('users').findOne({ id: userId });
if (!user) {
// Explicit failure for "not found"
return Results.failure('User not found', 404);
}
// Explicit success
return Results.success(user);
} catch (error) {
// Catch unexpected DB errors
console.error('Error fetching user:', error);
return Results.failure('Error fetching user', 500);
}
}
public static async updateProfile(
userId: string,
profileData: ProfileUpdate
): Promise<Result<User>> {
// Input validation
if (!profileData.name?.trim()) {
return Results.failure('Name is required', 400);
}
try {
// First get the user
const userResult = await UserService.getUser(userId);
// The ubiquitous check
if (!userResult.success) {
return userResult; // Early return on failure
}
const user = userResult.data;
// Update the user
const updatedUser = await db.collection('users').findOneAndUpdate(
{ id: userId },
{ $set: { ...profileData, updatedAt: new Date() } },
{ returnDocument: 'after' }
);
if (!updatedUser) {
return Results.failure('Failed to update user', 500);
}
return Results.success(updatedUser);
} catch (error) {
console.error('Error updating user:', error);
return Results.failure('Failed to update user', 500);
}
}
}
This approach provided several benefits:
- Explicit success and failure states with discriminated union types
- Error messages and status codes carried with the result
- Better type safety throughout the codebase
- Clearer function signatures that advertise possible failure
However, notice the repeated if (!result.success) { return result; }
pattern. While clear, it felt verbose, especially when chaining multiple operations that could each fail. Our Results.chain
helper helped somewhat, but didn't fully solve the desire for smoother composition:
// Using our chain helper
async function processUserData(userId: string): Promise<Result<ProcessedData>> {
const userResult = await UserService.getUser(userId);
// Chain additional operations that might fail
return await Results.chain(userResult, async (user) => {
const preferencesResult = await PreferenceService.getUserPreferences(userId);
// Still need nested checks
if (!preferencesResult.success) {
return preferencesResult;
}
const preferences = preferencesResult.data;
// Process and return
return Results.success({
userName: user.name,
theme: preferences.theme,
// Other processed data...
});
});
}
The "Aha!" Moment: Embracing Rust's Result
As we used and refined our internal Result
type, we noticed the pattern strongly resembled the Result<T, E>
enum from Rust, a language admired for its robust error handling.
Rust's Result
isn't just about the Ok(T)
and Err(E)
variants; it's about the rich set of methods defined on the type (map
, and_then
, or_else
, unwrap_or
, etc.). These methods allow for powerful functional composition, letting you transform successes, handle errors, chain operations, and provide defaults, all without explicit if/else
checks for the success/failure state.
We realized that instead of incrementally adding these methods to our custom type, we could gain a lot by adopting the well-defined semantics and battle-tested API design of Rust's Result
. There's something elegant about the way Rust handles errors as values that can be passed around, transformed, and dealt with explicitly rather than exceptions that jump across the call stack.
Of course, we weren't the first to have this realization. Several TypeScript libraries already implement similar patterns (like neverthrow, True Myth, and the more comprehensive Effect-ts). But after evaluating these options, we found some were unmaintained, others had different API designs than what we wanted, and some were part of larger functional programming ecosystems that would require a bigger shift in our codebase. We decided to build our own focused implementation that would fit our specific needs and existing patterns.
Building ts-result
This led us to create ts-result
, our open-source implementation. The goals were:
- Faithful Implementation: Mirror the core API and behavior of Rust's
Result<T, E>
. - TypeScript Idioms: Leverage TypeScript's discriminated unions, type guards, and generics effectively.
- Modern Tooling: Provide dual ESM/CJS support via
package.json
exports and ensure good JSDoc documentation. - Lightweight & Focused: Provide just the
Result
pattern without bundling it into a larger functional programming library.
Here's a simplified look at our implementation:
// Core Result type definition
export type Result<T, E = unknown> = Ok<T, E> | Err<T, E>;
// Ok variant - represents success
export class OkImpl<T, E = never> {
readonly _tag = 'Ok';
public readonly value: T;
constructor(value: T) {
this.value = value;
}
isOk(): this is Ok<T, E> {
return true;
}
isErr(): this is Err<T, E> {
return false;
}
// Transform the success value
map<U>(mapper: (value: T) => U): Result<U, E> {
return new OkImpl(mapper(this.value));
}
// Chain with another operation that might fail
andThen<U>(op: (value: T) => Result<U, E>): Result<U, E> {
return op(this.value);
}
// Many more methods...
}
// Err variant - represents failure
export class ErrImpl<T = never, E = unknown> {
readonly _tag = 'Err';
public readonly error: E;
constructor(error: E) {
this.error = error;
}
isOk(): this is Ok<T, E> {
return false;
}
isErr(): this is Err<T, E> {
return true;
}
// Pass through any transformation of the success value
map<U>(_mapper: (value: T) => U): Result<U, E> {
return this as unknown as Result<U, E>;
}
// Transform the error value
mapErr<F>(mapper: (error: E) => F): Result<T, F> {
return new ErrImpl(mapper(this.error));
}
// Many more methods...
}
// Type aliases
export type Ok<T, E = unknown> = OkImpl<T, E>;
export type Err<T = never, E = unknown> = ErrImpl<T, E>;
// Factory functions
export function Ok<T>(value: T): Ok<T, never> {
return new OkImpl(value);
}
export function Err<E>(error: E): Err<never, E> {
return new ErrImpl(error);
}
With ts-result
, our example transforms to a more functional, composable style:
import { Ok, Err, Result } from '@trylonai/ts-result';
interface UserError {
message: string;
statusCode: number;
}
class UserService {
static async getUser(userId: string): Promise<Result<User, UserError>> {
try {
const user = await db.collection('users').findOne({ id: userId });
if (!user) {
return Err({
message: 'User not found',
statusCode: 404
});
}
return Ok(user);
} catch (error) {
console.error('Error fetching user:', error);
return Err({
message: 'Error fetching user',
statusCode: 500
});
}
}
}
async function processUserData(userId: string): Promise<Result<ProcessedData, UserError>> {
// Start with getting the user
const userResult = await UserService.getUser(userId);
// Chain operations with andThen for a more linear flow
return userResult.andThen(user =>
// Only runs if userResult is Ok
PreferenceService.getUserPreferences(userId)
// Transform the preferences if that succeeded
.map(preferences => ({
userName: user.name,
theme: preferences.theme,
// Other processed data...
}))
);
}
This approach provides several major benefits:
- Explicit Error Types: The
E
generic parameter enforces consistent error types - Chainable Operations: Methods like
map
,andThen
,orElse
make compositions cleaner - Less Branching Logic: Fewer explicit if-statements checking for success/failure
- Enhanced Type Safety: TypeScript can track success and error types through transformations
- Focus on Happy Path: You can write your primary logic flow without error handling disruptions
But there are challenges too. TypeScript doesn't enforce exhaustive matching on discriminated unions the way Rust does with its pattern matching, so it's possible to forget to handle a Result entirely. Additionally, since TypeScript/JavaScript has built-in exceptions, you'll inevitably need to deal with both paradigms in the same codebase. We've found some patterns that help with this dual reality:
To help bridge these worlds, we added some utility functions to our implementation:
// Safely wrap functions that might throw exceptions
export function catches<T, E = unknown>(fn: () => T): Result<T, E> {
try {
return Ok(fn());
} catch (error) {
return Err(error as E);
}
}
// Async version for Promise-returning functions
export async function catchesAsync<T, E = unknown>(
fn: () => Promise<T>
): Promise<Result<T, E>> {
try {
return Ok(await fn());
} catch (error) {
return Err(error as E);
}
}
Using this approach, our API handler becomes:
export async function GET(request: Request) {
// Authentication returns a Result
const authResult = await validateAuth(request);
// Process the request using Result methods for composition
const response = await authResult
.andThen(user => UserService.getUser(user.id))
.match({
// Handle success case
Ok: (userData) => new Response(
JSON.stringify({ success: true, data: userData }),
{ status: 200 }
),
// Handle error case
Err: (error) => new Response(
JSON.stringify({ success: false, message: error.message }),
{ status: error.statusCode }
)
});
return response;
}
The code is now more declarative, with error handling naturally integrated into the flow rather than scattered throughout the function. By using match()
at the boundary of our system, we can ensure we're handling both success and error states comprehensively.
Tip: We've found that Result
works best when applied within well-defined boundaries of your application. We use it heavily in our domain logic and service layer, while still letting exceptions flow naturally in infrastructure code or when integrating with third-party libraries. The key is having clear adapter boundaries where you convert between these paradigms.
Why Build Our Own? (Alternatives Considered)
Before creating yet another Result library, we evaluated existing options:
- neverthrow: A solid library with a similar focus, but we had some API preferences that differed.
- ts-results: Well designed but appeared to be unmaintained at the time we evaluated it.
- True Myth: A comprehensive library with both Result and Maybe types, but we wanted something more lightweight.
fp-ts
/effect-ts
: These are fantastic, comprehensive libraries offeringEither
(similar to Result) and powerful effect systems. However, they represent a significant paradigm shift and dependency. We wanted something minimal and focused just on theResult
pattern, serving as an easier incremental step for teams not ready for a full functional programming adoption.- Custom Internal Solution: While we could continue evolving our internal
Result
type, we'd essentially be recreating Rust's well-designed API. By making this a separate library with a clear public API, we gained better separation of concerns and the ability to share with the community.
ts-result
aims to fill that middle ground: a modern, focused, Rust-inspired Result
implementation for everyday TypeScript error handling that doesn't require buying into a complete functional programming paradigm.
We specifically aimed for a few key design goals:
- Rust-inspired naming: We deliberately kept Rust's method names like
andThen
rather than using alternatives likebind
orflatMap
. This was partly because we found Rust's names more self-explanatory and partly because many of our team members were familiar with Rust's conventions. - Modern TypeScript features: Full support for ESM/CJS via package exports, extensive TypeScript type safety, and comprehensive JSDoc documentation.
- No additional dependencies: The library is entirely self-contained.
What about Promise
rejection? JavaScript's native Promise
has a built-in success/failure model using rejections. However, rejections are best suited for exceptional, unexpected errors rather than representing expected failure states that are part of your domain logic. Result
complements Promise
by providing a way to model expected failures with rich type information, while still leveraging Promise
for async operations.
Practical Tips for Using Result in TypeScript
Through our experience implementing and using ts-result
, we've gathered some practical tips:
- Define clear domain boundaries: Use Result primarily in your domain logic and service layers where you control the code patterns.
- Create adapter functions: Build utility functions like
catches()
to convert between exception-throwing code and Result-returning code. - Be consistent with Promise handling: Decide whether your async functions will return
Result<T, E>
orPromise<Result<T, E>>
and stick with it. - Define your error types: Create custom error types that extend
Error
for better type checking in your error cases. - Consider linting rules: If possible, create a linting rule to ensure Results are always handled (similar to how TypeScript can enforce Promise handling).
- Start small: Begin by applying the Result pattern to a single bounded context in your application before expanding it.
Conclusion
Our journey reflects a common path: starting with basic error handling, identifying limitations, creating custom solutions, and eventually converging on established, robust patterns from other ecosystems. Implementing and open-sourcing ts-result
was a natural step for us at Trylon AI, driven by our need for more explicit and composable error handling in TypeScript.
We find it significantly improves the clarity and reliability of our code where predictable success/failure outcomes are common. While it doesn't replace try-catch
for truly exceptional runtime errors or magically solve all error handling complexities, it provides a powerful, type-safe tool for managing the expected failure paths within our application logic.
Is this approach right for every project? Absolutely not. If your team is comfortable with exceptions or prefers other error handling patterns, that's perfectly valid. We've found that for our specific needs and preferences, the Result pattern offers a nice balance of explicitness, type safety, and composition that works well with our development style.
We hope ts-result
might be useful for your projects too. And if you've built something similar or have thoughts on error handling in TypeScript, we'd love to hear about your experiences!
- GitHub: https://github.com/TrylonAI/ts-result
- npm:
npm install @trylonai/ts-result
We'd love to hear your feedback and experiences!