Simplify TypeScript Error Handling with the Attempt Pattern

Simplify TypeScript Error Handling with the Attempt Pattern

March 15, 2025

7 min read

Simplify TypeScript Error Handling with the Attempt Pattern

Say goodbye to verbose try-catch blocks in TypeScript! In this article, I'll introduce you to the attempt function—a cleaner, more functional approach to error handling that has transformed how I write defensive code. This simple yet powerful utility encapsulates the try-catch pattern into a reusable function that improves both readability and maintainability. You can find the complete implementation in my open-source [RadzionKit](https://github.com/radzionc/radzionkit) repository.

The Problem with Traditional Error Handling

Traditional try-catch blocks often lead to cluttered, difficult-to-follow code. They force error handling to the end of execution flow, which can be counterintuitive when you want to handle errors first. Sometimes you simply need a fallback value if an operation fails, without complex error handling logic. Languages like Go implement this pattern as a core feature, returning both a result and an error that can be checked immediately. The attempt function brings this cleaner approach to TypeScript, allowing for more concise code that separates the happy path from error handling in a structured, readable way.

Defining the Result Type

export type Success<T> = { data: T; error?: never }
export type Failure<E = unknown> = { data?: never; error: E }
export type Result<T, E = unknown> = Success<T> | Failure<E>

Our attempt function will return a Result type, which can be either a Success or a Failure. This discriminated union leverages TypeScript's type narrowing capabilities to provide type safety throughout your error handling flow. When you check for the presence of an error property, TypeScript automatically narrows the type, ensuring that in the error case, data is unavailable, while in the success case, data is guaranteed to be present and properly typed—not T | undefined, but exactly T. This eliminates the need for additional null checks or non-null assertions that often plague traditional error handling approaches.

Type Safety in Action

const result = await attempt(fetchUserData())

if ("error" in result) {
  console.log("Error occurred:", result.error)
  // TypeScript knows result is Failure here
  // result.data is not accessible
  return
}

// TypeScript has narrowed the type to Success<User>
// We can safely access data without optional chaining or type assertions
const user = result.data
console.log("User name:", user.name) // No TypeScript errors!

Function Overloads for Maximum Flexibility

The attempt function's versatility comes from its three function overloads, each designed to handle a different common scenario in modern TypeScript applications. The first overload accepts a function that returns a Promise, perfect for wrapping asynchronous operations like API calls or database queries. The second handles synchronous functions that might throw exceptions, such as parsing JSON or accessing potentially undefined properties. The third overload accepts a Promise directly, allowing you to wrap already-created Promises without defining an additional function. This flexibility means you can use the same consistent error-handling pattern regardless of whether you're working with synchronous or asynchronous code, making your codebase more uniform and predictable.

export function attempt<T, E = unknown>(
  fn: () => Promise<T>,
): Promise<Result<T, E>>

export function attempt<T, E = unknown>(fn: () => T): Result<T, E>

export function attempt<T, E = unknown>(
  promise: Promise<T>,
): Promise<Result<T, E>>

Implementation Details

The implementation of attempt handles all three overloads with a single function body. It first checks if the input is a function using a type guard. If it is, it executes the function within a try-catch block, capturing any synchronous exceptions. If the function returns a Promise, it recursively calls attempt on that Promise to handle asynchronous errors. For direct Promise inputs, it attaches .then() and catch handlers that transform the resolved value or rejection reason into our Result type. This unified approach ensures consistent error handling regardless of whether you're working with synchronous functions, asynchronous functions, or raw Promises, all while maintaining proper type information throughout.

export function attempt<T, E = unknown>(
  input: Promise<T> | (() => T) | (() => Promise<T>),
): Result<T, E> | Promise<Result<T, E>> {
  if (typeof input === "function") {
    try {
      const result = input()
      if (isPromise<T>(result)) {
        return attempt(result)
      }
      return { data: result } as Result<T, E>
    } catch (error) {
      return { error: error as E }
    }
  } else {
    return input.then(
      (data): Result<T, E> => ({ data }),
      (error): Result<T, E> => ({ error: error as E }),
    )
  }
}

Helper Functions

To support this implementation, we need a helper function that can reliably detect if a value is a Promise. The isPromise utility checks if a value has a then method, which is the defining characteristic of a Promise-like object.

export function isPromise<T>(value: unknown): value is Promise<T> {
  return !!value && typeof (value as any).then === "function"
}

Handling Fallbacks Elegantly

While the attempt function provides a structured way to handle errors, you'll often want to extract the successful data or use a default value when an operation fails—all in a single line of code. The withFallback utility is specifically designed to work with the Result type returned by attempt, creating a powerful composition pattern. It takes a Result object (or a Promise that resolves to one) and a fallback value, then either extracts the successful data or substitutes the fallback when an error occurs. This pattern is particularly useful for operations like parsing JSON where you want to provide a sensible default when parsing fails:

// Parse JSON with a fallback to the original value if parsing fails
const parsedConfig = withFallback(
  attempt(() => JSON.parse(value) as T),
  value as T,
)

The withFallback Implementation

Like attempt, withFallback handles both synchronous and asynchronous results through function overloads, maintaining the same functional approach to error handling throughout your codebase.

export function withFallback<T, E = unknown>(
  result: Result<T, E>,
  fallback: T,
): T
export function withFallback<T, E = unknown>(
  result: Promise<Result<T, E>>,
  fallback: T,
): Promise<T>
export function withFallback<T, E = unknown>(
  result: Result<T, E> | Promise<Result<T, E>>,
  fallback: T,
): T | Promise<T> {
  if (isPromise<Result<T, E>>(result)) {
    return result.then((res): T => {
      if ("error" in res) {
        return fallback
      }
      return res.data
    })
  }

  if ("error" in result) {
    return fallback
  }

  return result.data
}

I hope this video convinced you that the attempt and withFallback utilities are a powerful and flexible way to handle errors in TypeScript. We don't need to convince AI, let's just add a cursor rule so that AI will also always use attempt instead of try-catch.

Conclusion

The attempt pattern transforms error handling from a verbose, nested structure into a clean, functional flow that improves code readability and maintainability. By returning strongly-typed Result objects, it leverages TypeScript's type system to ensure errors are handled consistently throughout your application. I've found this approach so valuable that I've integrated it as a Cursor rule in my development workflow—ensuring that all error handling follows this pattern instead of traditional try-catch blocks.

---
description: USE attempt utility WHEN handling errors INSTEAD OF try-catch blocks TO ensure consistent error handling
globs: *.{ts,tsx,js,jsx}
alwaysApply: false
---

# Use attempt Utility Instead of try-catch

## Context

- Applies when handling errors in asynchronous or synchronous operations
- The codebase provides a utility function `attempt` in `lib/utils/attempt.ts`
- This utility provides a consistent way to handle errors with Result types

## Requirements

- Always use the `attempt` utility instead of try-catch blocks
- Return and handle Result objects with `data` and `error` properties
- Use `withFallback` when a default value is needed in case of error

## Examples

<example>
// Good: Using attempt utility for error handling
import { attempt } from '@lib/utils/attempt'

// For synchronous functions
const result = attempt(() => parseJSON(data))
if ('data' in result) {
// Handle success case
processData(result.data)
} else {
// Handle error case
logError(result.error)
}

// For asynchronous functions
const result = await attempt(() => fetchData())
if ('data' in result) {
// Handle success case
processData(result.data)
} else {
// Handle error case
logError(result.error)
}

// Using withFallback for default values
const data = withFallback(attempt(() => parseJSON(data)), defaultValue)
</example>

<example type="invalid">
// Bad: Using try-catch directly
try {
  const data = parseJSON(data)
  processData(data)
} catch (error) {
  logError(error)
}

// Bad: Using try-catch with async/await
try {
const data = await fetchData()
processData(data)
} catch (error) {
logError(error)
}
</example>