back to home

Error Handling in TypeScript: Neverthrow, Try-Catch, and EffectTS

2025-04-22

Robust error handling is a cornerstone of reliable application development. It's not just about preventing crashes—it's about gracefully handling exceptional cases, providing meaningful feedback, and maintaining application state integrity. In TypeScript applications, developers face particular challenges with traditional error handling mechanisms:

  • JavaScript's runtime exceptions provide limited type safety
  • Error propagation can be difficult to track across asynchronous operations
  • The lack of compile-time checking for unhandled error paths
  • Potential for inconsistent error handling patterns across teams

This article explores three distinct approaches to error handling in TypeScript, each representing a different paradigm:

  1. try-catch blocks: The built-in, imperative approach familiar to most developers
  2. neverthrow: A functional library providing explicit Result types
  3. Effect.ts: A comprehensive functional effect system

We'll examine each approach in detail, provide practical examples, compare their strengths and weaknesses, and offer guidance on when to choose each one.

##Deep Dive: try-catch Blocks

###The Mechanism

The try-catch block is JavaScript's native error handling construct, built directly into the language. It consists of:

  • A try block containing code that might throw an exception
  • A catch block that executes when an exception occurs
  • An optional finally block that executes regardless of whether an exception was thrown

Here's a basic example:

typescript
function divideNumbers(a: number, b: number): number {
  try {
    if (b === 0) {
      throw new Error("Division by zero");
    }
    return a / b;
  } catch (error) {
    console.error("An error occurred:", error);
    return NaN; // Return a fallback value
  } finally {
    console.log("Division operation attempted");
  }
}

###TypeScript-Specific Considerations

In TypeScript, we can improve type safety by checking error types:

typescript
class DatabaseError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "DatabaseError";
  }
}

class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

function processUserData(userId: string): void {
  try {
    const userData = fetchUserData(userId);
    processData(userData);
  } catch (error) {
    if (error instanceof DatabaseError) {
      // Handle database errors specifically
      notifyAdmin("Database error: " + error.message);
    } else if (error instanceof ValidationError) {
      // Handle validation errors
      displayUserMessage("Invalid data: " + error.message);
    } else {
      // Handle unknown errors
      logError("Unknown error:", error);
    }
  }
}

###Handling Async Operations

With the advent of async/await, try-catch can also handle asynchronous operations:

typescript
async function fetchAndProcessUser(userId: string): Promise<UserData> {
  try {
    const response = await fetch(`/api/users/${userId}`);

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

    const userData = await response.json();
    return processUserData(userData);
  } catch (error) {
    console.error("Failed to fetch user:", error);
    telemetryClient.trackException(error);
    throw error; // Re-throw or handle as appropriate
  }
}

###Pros of try-catch

  • Built-in language feature: No external dependencies required
  • Universally understood: Most developers are familiar with this pattern
  • Works with both sync and async code: Especially with async/await
  • Simple implementation: Straightforward for basic error handling needs

###Cons of try-catch

  • Untyped errors by default: TypeScript doesn't track which errors might be thrown
  • Runtime-only mechanism: No compile-time guarantees about error handling
  • Can lead to verbose code: Especially with nested try-catch blocks
  • Imperative control flow: Can make code harder to reason about
  • Doesn't force error handling: Callers can ignore potential errors

##Deep Dive: neverthrow Library

The neverthrow library brings the functional "Result" type pattern to TypeScript. This pattern, popular in languages like Rust and Scala, represents computations that might fail, making errors explicit values in the type system.

###Core Concept: The Result Type

The central concept is a Result<T, E> type, which can be either:

  • Ok<T>: Representing success with a value of type T
  • Err<E>: Representing failure with an error of type E

This explicit representation forces developers to acknowledge and handle potential failures at compile time.

###Basic Usage

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

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return err('Division by zero');
  }
  return ok(a / b);
}

// Using the result
const result = divide(10, 2);

if (result.isOk()) {
  console.log('Result:', result.value);
} else {
  console.error('Error:', result.error);
}

// Or using match for more concise handling
result.match(
  (value) => console.log('Result:', value),
  (error) => console.error('Error:', error)
);

###Wrapping Functions That Might Throw

Neverthrow provides utilities to wrap functions that might throw exceptions:

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

function parseJSON(json: string): Result<unknown, Error> {
  return Result.fromThrowable(
    JSON.parse,
    (error) => error instanceof Error ? error : new Error(String(error))
  )(json);
}

// Usage
const result = parseJSON('{"name": "John"}');
const invalidResult = parseJSON('{"name": John}'); // Missing quotes, will be an Err

###Chaining Operations

One of neverthrow's strengths is operation chaining:

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

interface User {
  id: number;
  name: string;
}

function findUser(id: number): Result<User, string> {
  // Simulated database lookup
  if (id === 1) {
    return ok({ id: 1, name: 'John' });
  }
  return err(`User with id ${id} not found`);
}

function validateUserName(user: User): Result<User, string> {
  if (user.name.length >= 3) {
    return ok(user);
  }
  return err('User name too short');
}

function processUser(user: User): Result<string, string> {
  // Some processing
  return ok(`Processed user ${user.name}`);
}

// Chain operations together
const result = findUser(1)
  .andThen(validateUserName)  // Only runs if findUser returns Ok
  .andThen(processUser);      // Only runs if validateUserName returns Ok

// Handle the final result
result.match(
  (success) => console.log(success),
  (error) => console.error('Error:', error)
);

###Async Support

Neverthrow also handles asynchronous operations:

typescript
import { ResultAsync, okAsync, errAsync } from 'neverthrow';

function fetchUser(id: number): ResultAsync<User, Error> {
  return ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then(res => res.json()),
    (error) => error instanceof Error ? error : new Error(String(error))
  );
}

// Chain async operations
const result = fetchUser(1)
  .andThen(user => {
    if (user.isActive) {
      return okAsync(user);
    }
    return errAsync(new Error('User is inactive'));
  })
  .map(user => user.name);

// Handle the result
result
  .match(
    (name) => console.log('User name:', name),
    (error) => console.error('Error:', error)
  );

###Pros of neverthrow

  • Type-safe errors: Error types are explicitly declared and checked
  • Explicit error handling: Makes potential failures visible in function signatures
  • Composable operations: Easy to chain operations that might fail
  • Predictable control flow: Clear paths for success and failure cases
  • Forces acknowledgment: Callers must handle or propagate errors explicitly
  • Minimal learning curve: Relatively simple concept compared to full effect systems

###Cons of neverthrow

  • External dependency: Requires adding a library to your project
  • Some boilerplate: Can be more verbose than try-catch for simple cases
  • Limited scope: Focused primarily on the Result type pattern
  • Learning curve: Requires adjustment for developers used to imperative error handling

##Deep Dive: Effect.ts Library

Effect.ts is a comprehensive functional programming library for TypeScript that goes beyond simple error handling. It represents computations as values through the Effect<A, E, R> type, where:

  • A: The success type the effect might produce
  • E: The error type the effect might fail with
  • R: The environment/dependencies the effect requires to run

This approach treats errors as a fundamental aspect of the type system, integrated with other concerns like dependencies and asynchrony.

###Core Concepts

typescript
import { Effect } from 'effect';

// Create a successful effect
const success = Effect.succeed("hello");

// Create a failing effect
const failure = Effect.fail(new Error("Something went wrong"));

// Run the effect
Effect.runSync(success); // Returns "hello"
Effect.runSyncExit(failure); // Returns a failure Exit value

###Wrapping Throwing Functions

Effect provides utilities to safely wrap functions that might throw:

typescript
import { Effect } from 'effect';

// Wrap a synchronous function that might throw
const parseJson = (input: string) =>
  Effect.try({
    try: () => JSON.parse(input),
    catch: (error) => new Error(`Parse error: ${String(error)}`)
  });

// Wrap an asynchronous function that might throw
const fetchData = (url: string) =>
  Effect.tryPromise({
    try: () => fetch(url).then(res => res.json()),
    catch: (error) => new Error(`Fetch error: ${String(error)}`)
  });

###Creating Tagged Errors

Effect encourages the use of tagged errors for better error handling:

typescript
import { Effect, Data } from 'effect';

// Define custom error types
class ValidationError extends Data.TaggedError("ValidationError")<{
  message: string;
}> {}

class DatabaseError extends Data.TaggedError("DatabaseError")<{
  code: number;
  message: string;
}> {}

// Function that can fail with a specific error type
const validateUser = (user: unknown): Effect.Effect<User, ValidationError> => {
  if (!user || typeof user !== 'object' || !('name' in user)) {
    return Effect.fail(new ValidationError({ message: 'Invalid user structure' }));
  }
  return Effect.succeed(user as User);
};

const getUserFromDB = (id: number): Effect.Effect<User, DatabaseError> => {
  if (id <= 0) {
    return Effect.fail(new DatabaseError({ code: 404, message: 'User not found' }));
  }
  return Effect.succeed({ id, name: 'John Doe' });
};

###Composing Effects

One of Effect's strengths is composition:

typescript
import { Effect } from 'effect';

// Define our effect pipeline
const program = Effect.gen(function* (_) {
  // Get user ID from somewhere
  const userId = yield* _(getUserId);

  // Get user from database
  const user = yield* _(getUserFromDB(userId));

  // Validate the user
  const validUser = yield* _(validateUser(user));

  // Process the valid user
  const result = yield* _(processUser(validUser));

  return result;
});

// Run the effect
Effect.runPromise(program)
  .then(result => console.log('Success:', result))
  .catch(error => console.error('Error:', error));

###Complex Error Handling

Effect provides powerful error handling capabilities:

typescript
import { Effect } from 'effect';

const program = getUserFromDB(5)
  // Handle specific error types
  .catchTag('DatabaseError', (error) => {
    if (error.code === 404) {
      return Effect.succeed({ id: 5, name: 'Guest User' }); // Fallback
    }
    return Effect.fail(error); // Re-throw other database errors
  })
  .pipe(
    // Transform errors
    Effect.mapError(error =>
      new Error(`Processing failed: ${error.message}`)
    ),
    // Provide retry policy
    Effect.retry({ times: 3, delay: 1000 }),
    // Add timeout
    Effect.timeout('5 seconds')
  );

###Resource Management

Effect also handles resource acquisition and release:

typescript
import { Effect } from 'effect';

const acquireConnection = Effect.acquireRelease(
  // Acquire resource
  Effect.sync(() => {
    console.log('Opening database connection');
    return { query: (sql: string) => `Result of ${sql}` };
  }),
  // Release resource (always called, even on failure)
  (connection) => Effect.sync(() => {
    console.log('Closing database connection');
  })
);

const program = Effect.gen(function* (_) {
  const connection = yield* _(acquireConnection);
  const result = connection.query('SELECT * FROM users');
  return result;
});

Effect.runPromise(program);

###Pros of Effect.ts

  • Comprehensive error handling: Fully integrated with dependencies and async operations
  • Superior type safety: Errors are tracked precisely in the type system
  • Highly composable: Complex operations can be combined with clear error flow
  • Powerful combinators: Rich library of utilities for error handling
  • Resource safety: Built-in constructs for proper resource management
  • Concurrency support: First-class tools for handling errors in concurrent operations
  • Testability: Effects are descriptive, making them easier to test

###Cons of Effect.ts

  • Steeper learning curve: Requires understanding functional programming concepts
  • Significant paradigm shift: Different way of structuring code
  • Larger dependency: More substantial library than neverthrow
  • Potential overhead: Slightly more runtime overhead than native constructs
  • Possibly overkill: May be excessive for simple applications

##Comparative Analysis

Criteriontry-catchneverthrowEffect.ts
Type SafetyLimited (runtime only)Good (explicit Result type)Excellent (integrated with effect system)
ExplicitnessLow (hidden in implementation)High (visible in function signatures)High (core part of Effect type)
ComposabilityPoor (imperative, breaks flow)Good (chainable operations)Excellent (comprehensive combinators)
Runtime vs. Compile-timeRuntime onlyCompile-time enforcedCompile-time enforced
Learning CurveLow (built-in)Moderate (simple concept)Steep (full effect system)
BoilerplateLow for simple cases, high for complexModerateInitially high, but pays off for complex logic
ScopeError handling onlyPrimarily error handlingComplete effect system (errors, async, resources, etc.)
Ecosystem IntegrationNative JS/TSSimple integrationRequires more adaptation
PerformanceNative (fastest)Minor overheadSome overhead, optimized for larger applications

##When to Choose Which

###Choose try-catch when:

  • Building simple scripts or small applications
  • Working at application boundaries (e.g., top-level error handlers)
  • Integrating with libraries that rely heavily on exceptions
  • The team is not familiar with functional programming concepts
  • You need to minimize dependencies

Example scenario: A small utility script, a prototype, or when wrapping third-party code that throws exceptions at the API boundary.

###Choose neverthrow when:

  • You want explicit, type-safe error handling without a major paradigm shift
  • Building libraries or modules where error states should be clear to consumers
  • Working in a team with mixed familiarity with functional programming
  • You need better composition than try-catch but don't want the full Effect.ts complexity
  • Your project primarily needs the Result pattern without other functional programming features

Example scenario: A medium-sized application, a shared utility library, or when implementing business logic with clearly defined error states.

###Choose Effect.ts when:

  • Building complex applications with sophisticated error handling needs
  • Your team is comfortable with functional programming concepts
  • You need integrated handling of errors, async operations, and resources
  • Working on a large codebase where consistency and testability are crucial
  • Performance and scalability are important concerns
  • You want a comprehensive approach to functional programming in TypeScript

Example scenario: A large-scale backend service, a complex frontend application with extensive state management, or when working in a team fully committed to functional programming principles.

##Conclusion

TypeScript offers a spectrum of error handling approaches, from the familiar but limited try-catch blocks to the comprehensive Effect.ts library. Each has its place:

  • try-catch provides simplicity and familiarity but lacks compile-time safety.
  • neverthrow strikes a balance with its Result type, offering explicit error handling without a complete paradigm shift.
  • Effect.ts delivers the most powerful and type-safe approach but requires embracing functional programming concepts.

The trend in modern TypeScript development is clearly moving toward more explicit, type-safe error handling. While try-catch remains useful at application boundaries, libraries like neverthrow and Effect.ts reflect the growing recognition that errors should be first-class citizens in our type systems.

Your choice should reflect your team's expertise, project complexity, and specific requirements. For teams new to functional error handling, neverthrow offers an excellent entry point. For those ready to embrace a more comprehensive approach, Effect.ts provides a robust foundation for building complex, reliable systems.

Remember: the best error handling approach is the one that helps your team build more reliable software while maintaining productivity and code clarity.