Evolving Our Error Handling: Why We Built ts-result, a Rust-Inspired Result Type for TypeScript

By Kerim Buyukakyuz

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:

  1. Liberal use of try-catch blocks, especially around database interactions, API calls, or complex business logic.
  2. Functions sometimes returning null or undefined to signal "not found" or other non-successful states.

While functional, these approaches had drawbacks:

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:

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:

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:

  1. Faithful Implementation: Mirror the core API and behavior of Rust's Result<T, E>.
  2. TypeScript Idioms: Leverage TypeScript's discriminated unions, type guards, and generics effectively.
  3. Modern Tooling: Provide dual ESM/CJS support via package.json exports and ensure good JSDoc documentation.
  4. 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:

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:

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:

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:

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!

We'd love to hear your feedback and experiences!