🎯 neverthrow Error Handling Patterns

Interactive demonstration of type-safe error handling in TypeScript

Pattern 1Simple Enum Error Types

The simplest error handling pattern uses string literal unions to represent different error states. This is perfect when you just need to distinguish between different failure modes without carrying additional context.

type AuthError = 'InvalidCredentials' | 'AccountLocked' | 'SessionExpired'; function authenticate(username: string, password: string): Result<User, AuthError> { if (!password || password.length < 8) { return err('InvalidCredentials'); } if (username === 'locked_user') { return err('AccountLocked'); } return ok({ id: '123', name: username, email: `${username}@example.com` }); }

🔍 What's happening under the hood:

When the authentication function runs, it checks conditions in order:

  • Password validation: If the password is missing or less than 8 characters, returns err('InvalidCredentials')
  • Account status check: If the username is 'locked_user', returns err('AccountLocked')
  • Success case: If all checks pass, returns ok(user) with user data

The Result type forces you to handle both success and error cases explicitly using .match() or other combinators.

Try it yourself:

Test different error scenarios:

✨ Why use simple enum errors?

  • TypeScript exhaustiveness checking: The compiler ensures you handle all error cases in switch statements
  • Minimal overhead: Just string literals, no complex objects
  • Clear intent: Error types are self-documenting
  • Perfect for binary conditions: When you don't need to pass extra data about what went wrong

Pattern 2Union Error Types with Data

When errors need context, use discriminated unions. Each error type is an object with a type field (the discriminator) plus additional fields carrying relevant information. This lets you pass detailed error information up the call stack.

type ValidationError = { type: 'ValidationError'; field: string; message: string; attemptedValue: unknown; }; type NotFoundError = { type: 'NotFoundError'; resource: string; id: string; }; type DatabaseError = { type: 'DatabaseError'; query: string; originalError: Error; }; type AppError = ValidationError | NotFoundError | DatabaseError;

🔍 What's happening under the hood:

This pattern chains multiple operations that can each fail with different error types:

  • Step 1 - Find product: Searches for product by ID, can return NotFoundError or DatabaseError
  • Step 2 - Validate product: If found, validates the update data, can return ValidationError
  • Error propagation: If either step fails, the error (with all its context) is returned immediately
  • Success case: Only if both steps succeed do we get the updated product

The .match() method uses TypeScript's discriminated unions to give you type-safe access to error-specific fields.

Try it yourself:

Test different error scenarios:

✨ Why use union error types with data?

  • Rich error context: Pass field names, attempted values, original errors, etc.
  • Debugging friendly: All the information you need to diagnose issues is attached to the error
  • Type-safe error handling: TypeScript narrows the type based on the discriminator, giving you autocomplete for error-specific fields
  • Composable: Different functions can return different error types, and they compose naturally via union types
  • No exceptions: All error paths are explicit in the type signature

Pattern 3Coalescing Multiple Results

When processing multiple operations at once, neverthrow provides utilities to combine results. You can either fail fast (stop at first error) or collect all errors (continue through all operations).

// Fail fast - stops at first error Result.combine(results): Result<T[], E> // Collect all errors - continues through all operations Result.combineWithAllErrors(results): Result<T[], E[]>

🔍 What's happening under the hood:

Result.combine():

  • Processes results in order until it hits the first error
  • Returns immediately with that error (fail-fast behavior)
  • If all succeed, returns ok([...all values])
  • Error type is singular: Result<T[], E>

Result.combineWithAllErrors():

  • Processes ALL results regardless of failures
  • Collects every error into an array
  • Only returns ok if ALL operations succeeded
  • Error type is an array: Result<T[], E[]>

Manual accumulation:

  • Useful when you want partial success (some items valid, some invalid)
  • Gives you both the successful results AND all the errors
  • Perfect for validation scenarios where you want to show all issues at once

Try it yourself:

Test different combination strategies:

💡 Test with different order IDs:

  • 123,456,789 - Try all invalid IDs (except 123) to see multiple errors
  • 123 - Single valid ID (all succeed)
  • 123,456 - Mix of valid and invalid
  • error,123,456 - Database error followed by other errors

Remember: Only ID '123' is valid. Any other ID will produce a NotFoundError. ID 'error' produces a DatabaseError.

✨ When to use each strategy:

Use Result.combine() when:

  • You need all operations to succeed for the result to be valid
  • Early termination saves resources (e.g., no point continuing if first step fails)
  • You only care about the first error

Use Result.combineWithAllErrors() when:

  • You want to show users ALL validation errors at once
  • Each operation is independent (e.g., validating form fields)
  • You need a complete picture of what went wrong

Use manual accumulation when:

  • Partial success is acceptable (some items can fail)
  • You want to process what succeeded AND report what failed
  • You need custom aggregation logic beyond just arrays

🎓 Summary

neverthrow provides three main patterns for type-safe error handling:

1️⃣ Simple Enum Errors

Use when errors don't need extra data. Fast, simple, TypeScript-exhaustive.

2️⃣ Union Error Types with Data

Use when errors need context. Provides rich debugging information while maintaining type safety.

3️⃣ Combining Multiple Results

Use when processing multiple operations. Choose between fail-fast and collect-all-errors based on your needs.

The key benefit: All error paths are explicit in your type signatures. No silent failures, no forgotten error handling, no exceptions bubbling up unexpectedly. TypeScript forces you to handle every error case, making your code more reliable.