Using try/catch is the most common way to handle errors in JavaScript. However, this approach has some drawbacks. There is no built-in way to verify whether a function might throw. You also cannot statically know which errors catch will receive. This usually leads to large try/catch blocks where multiple errors are handled the same way and useful information gets lost.

On top of that, throw makes control flow harder to understand because it jumps execution. To know what runs after an error is thrown, you often need to navigate through the codebase until you find the nearest catch, which can be confusing.

async function getUser(id: string) {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      // Error 1: HTTP failure (404, 500, ...)
      throw new Error(`Request failed: ${response.status}`);
    }

    // Error 2: response body is not valid JSON
    const data = await response.json();
    return data;
  } catch (error) {
    // Error 3: network error (DNS failed, offline, ...)

    // `error` mixes all scenarios above.
    // It is hard to react to each case without extra checks.
    console.error("Failed to fetch user", error);
  }
}
Why is the catch error always unknown?

In JavaScript, throw can throw any value, not only Error. throw "oops", throw 404, or even throw null are all valid.

Because of this flexibility, TypeScript safely types the catch variable as unknown. That forces you to narrow it (typeof, instanceof, etc.) before reading properties like .message or .stack.

A common alternative is treating errors as values. This approach exists in languages like Go, Rust, and more recently Kotlin. The idea is that functions return errors as values, so callers can clearly see possible failures.

You can model this in TypeScript with a Result type:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

Result represents an operation with two generics: T (success value) and E (error type). It also includes a discriminant field, ok.

// Possible errors
type NetworkError = { _tag: "NetworkError"; message: string };
type NotFoundError = { _tag: "NotFoundError"; userId: string };
type ParseError = { _tag: "ParseError"; message: string };

type FetchUserError = NetworkError | NotFoundError | ParseError;

async function fetchUser(id: string): Promise<Result<User, FetchUserError>> {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (response.status === 404) {
      return { ok: false, error: { _tag: "NotFoundError", userId: id } };
    }

    if (!response.ok) {
      return {
        ok: false,
        error: {
          _tag: "NetworkError",
          message: `HTTP ${response.status}`,
        },
      };
    }

    const data = await response.json();
    return { ok: true, value: data };
  } catch {
    return {
      ok: false,
      error: {
        _tag: "ParseError",
        message: "Failed to parse response",
      },
    };
  }
}

const userResult = await fetchUser("123");

if (userResult.ok) {
  console.log("User found:", userResult.value);
} else {
  switch (userResult.error._tag) {
    case "NotFoundError":
      console.error(`User ${userResult.error.userId} not found`);
      break;
    case "NetworkError":
      console.error(`Network error: ${userResult.error.message}`);
      break;
    case "ParseError":
      console.error(`Parse error: ${userResult.error.message}`);
      break;
  }
}

The goal is not to eliminate throw/catch entirely. Use it for critical failures where execution should stop immediately, like a missing required environment variable at startup, or an unrecoverable DB connection failure during boot.

That gives us a clear split between expected errors (user not found, insufficient balance, validation failure) and unexpected errors (OOM, fatal runtime failures, etc.). It also lets us attach richer metadata to expected errors.

Although you can define your own Result manually, for real applications it is usually better to use a richer library. From here on, we'll use typescript-result.

Creating a Result

Before consuming Result, we need to return it from functions. typescript-result provides APIs for this.

Result.ok and Result.error create success and failure results:

import { Result } from "typescript-result";

type DiscountError =
  | { _tag: "NegativeAmount"; amount: number }
  | { _tag: "ExceedsMaximum"; amount: number; max: number };

function calculateDiscountPercentage(
  amount: number,
): Result<number, DiscountError> {
  if (amount <= 0) {
    return Result.error({ _tag: "NegativeAmount", amount });
  }
  if (amount > 100) {
    return Result.error({ _tag: "ExceedsMaximum", amount, max: 100 });
  }

  const finalValue = amount * 0.9;
  return Result.ok(finalValue);
}

const result = calculateDiscountPercentage(30);

You can explicitly declare the return type as Result<number, DomainError>. Even if TypeScript infers it, explicit public signatures (APIs/libraries) are a good practice to keep contracts stable.

To adapt functions that might throw, use try or wrap:

import { Result } from "typescript-result";

function divide(a: number, b: number) {
  if (b === 0) throw new Error("Cannot divide by zero");
  return a / b;
}

// Executes immediately and captures exceptions
const immediateResult = Result.try(() => divide(10, 0));

// Optional error mapping
const taggedResult = Result.try(
  () => divide(10, 0),
  (error) => ({ _tag: "MathError", message: String(error) }),
);

// Returns a new function that always returns Result
const safeDivide = Result.wrap(divide);
const result = safeDivide(10, 0);
Why do errors use a \_tag field?

TypeScript uses structural typing. For type checking, the following classes can be compatible:

class ValidationError {
  constructor(public message: string) {}
}

class DatabaseError {
  constructor(public message: string) {}
}

In some cases, TypeScript may collapse semantically different error types. A literal _tag makes runtime and type-level distinction explicit. It also avoids fragile instanceof checks in large apps (multiple bundles/contexts), where class identity can fail.

These helpers are useful when integrating external libraries or legacy code that still throws exceptions.

So far, examples returned Promise<Result<T, E>>. The library also exposes AsyncResult<T, E> (an alias):

async function fetchUser(id: string): AsyncResult<User, FetchUserError> {
  // ...
}

Manual error checking

When a function returns Result, you check it explicitly and either handle or propagate errors:

type ValidationError = { _tag: "ValidationError"; field: string };
type DatabaseError = { _tag: "DatabaseError"; message: string };
type NotFoundError = { _tag: "NotFoundError"; id: string };

async function processOrder(
  orderId: string,
): AsyncResult<Order, ValidationError | DatabaseError | NotFoundError> {
  const orderResult = await fetchOrder(orderId);
  if (!orderResult.ok) return orderResult;

  const order = orderResult.value;

  const validationResult = validateOrder(order);
  if (!validationResult.ok) {
    if (validationResult.error._tag === "ValidationError") {
      console.error(`Validation failed: ${validationResult.error.field}`);
    }
    return validationResult;
  }

  const saveResult = await saveToDatabase(order);
  if (!saveResult.ok) return saveResult;

  return Result.ok(saveResult.value);
}

This pattern is close to idiomatic Go (return result + error and check at each step). Its strength is explicitness. The downside is verbosity.

func processOrder(orderId string) (*Order, error) {
    // In Go, the function returns two values: order and err
    // If err is different to nil, it means there was an error
    order, err := fetchOrder(orderId)
    if err != nil {
        return nil, err
    }

    // Validate the order
    err = validateOrder(order)
    if err != nil {
        log.Printf("Validation failed: %v", err)
        return nil, err
    }

    // Save to database
    savedOrder, err := saveToDatabase(order)
    if err != nil {
        return nil, err
    }

    return savedOrder, nil
}

Chaining operations

Instead of manually checking every Result, you can transform only success values and leave failures untouched.

Result instances provide methods for this:

  • .map() runs only on success
  • .mapError() runs only on failure
  • both return new Result, so they can be chained ::
async function getUserEmail(
  userId: string,
): AsyncResult<string, FetchUserError> {
  return fetchUser(userId)
    .map((user) => user.email)
    .map((email) => email.toLowerCase());
}

async function processUserData(userId: string): AsyncResult<User, AppError> {
  return fetchUser(userId).mapError((error) => {
    switch (error._tag) {
      case "NetworkError":
        return { _tag: "AppError" as const, message: "Service unavailable" };
      case "NotFoundError":
        return { _tag: "AppError" as const, message: "User not found" };
      case "ParseError":
        return { _tag: "AppError" as const, message: "Invalid data" };
    }
  });
}

If you only want side effects (logging/metrics), use onSuccess / onFailure:

async function loginUser(
  email: string,
  password: string,
): AsyncResult<User, LoginError> {
  return authenticateUser(email, password)
    .onSuccess((user) => {
      console.log(`Login successful for ${user.email}`);
      analytics.track("user_login", { userId: user.id, timestamp: Date.now() });
    })
    .onFailure((error) => {
      console.error(`Login failed: ${error._tag}`);
    });
}

This style is often called Railway Oriented Programming (ROP): one track for success, another for failure.

You can also move from failure back to success with .recover() (fallbacks):

type ApiError = { _tag: "PrimaryApiDown" } | { _tag: "SecondaryApiDown" };

async function fetchDataWithFallback(id: string): AsyncResult<Data, ApiError> {
  return fetchFromPrimaryApi(id).recover(() => fetchFromSecondaryApi(id));
}

This pattern is very common in functional programming, treating Result as a monad. This pattern is similar to the use of pipelines. In Rust, there is a very similar pattern:

fn process_user_data(id: &str) -> Result<String, AppError> {
    fetch_user(id)
        .map(|user| user.email)
        .map(|email| email.to_lowercase())
        .map_err(|err| AppError::from(err))
}

However, it may deviate from the typical JavaScript/TypeScript function format. It can also cause readability issues when all logic needs to be implemented in functions.

Using generators

Result also supports generator-based flows with Result.gen():

function calculateTotal(items: Item[]): Result<number, ValidationError> {
  return Result.gen(function* () {
    const validatedItems = yield* validateItems(items);
    const subtotal = yield* calculateSubtotal(validatedItems);
    const discount = yield* applyDiscount(subtotal);

    return subtotal - discount;
  });
}

Result.gen() executes your generator and, at each yield*, it:

  • returns immediately if that Result is an error
  • unwraps the success value and continues otherwise

This feels similar to async/await.

Remember the asterisk: yield*.

A nice benefit: TypeScript can infer the full union of possible errors from all yielded operations.

Understanding generators and yield* in detail

Generators allow a form of inversion of control, where the flow of a function is externally controlled. They are generally used to create custom lazy iterators or to implement complex state machines.

yield pauses function execution, sends a value out, and waits for a command to continue. When a generator is consumed, it's also possible to inject values back into the function.

yield* allows generators to delegate all iteration to other internal generators. This is exactly what happens with Result.gen() and yield* — when you do yield* fetchOrder(orderId), you are delegating to that operation, and Result.gen() extracts the result automatically.

// Using generators with Result.gen()
async function processOrder(
  orderId: string,
): AsyncResult<Order, ValidationError | DatabaseError | NotFoundError> {
  return Result.gen(function* () {
    // yield* "extracts" the Result value or returns the error immediately
    const order = yield* fetchOrder(orderId);

    // If we got here, order is of type Order (not Result<Order, ...>)
    const validatedOrder = yield* validateOrder(order);

    // Continues extracting values smoothly
    const savedOrder = yield* saveToDatabase(validatedOrder);

    // Returns the final value
    return savedOrder;
  });
}

This pattern is close to Rust's ? operator, which acts similarly, "extracting" the value from Result or immediately returning the function with an error:

fn process_order(order_id: &str) -> Result<Order, OrderError> {
    let order = fetch_order(order_id)?;
    let validated_order = validate_order(order)?;
    let saved_order = save_to_database(validated_order)?;
    Ok(saved_order)
}

Producing the final value

At some boundary (HTTP handler/UI/etc.), you usually need a final value no matter what. Result provides helpers like:

  • getOrDefault()
  • getOrElse() ::
async function getUserName(userId: string): Promise<string> {
  const result = await fetchUser(userId);
  return result.getOrDefault("Unknown User");
}

async function getUserEmail(userId: string): Promise<string> {
  const result = await fetchUser(userId);
  return result.getOrElse((error) => {
    console.error("Could not fetch user:", error);
    return `user-${userId}@placeholder.com`;
  });
}

When each error requires a different reaction, use .match():

type PaymentError =
  | { _tag: "InsufficientFunds"; required: number; available: number }
  | { _tag: "InvalidCard"; reason: string }
  | { _tag: "NetworkError"; message: string }
  | { _tag: "SessionExpired" };

async function processPayment(amount: number): Promise<string> {
  const result = await attemptPayment(amount);

  if (!result.ok) {
    return result
      .match()
      .when(
        "InsufficientFunds",
        (err) =>
          `Insufficient funds. Required: $${err.required}, available: $${err.available}`,
      )
      .when("InvalidCard", (err) => `Invalid card: ${err.reason}`)
      .when("NetworkError", (err) => `Network error: ${err.message}`)
      .when(
        "SessionExpired",
        () => `Your session expired. Please log in again.`,
      )
      .run();
  }

  return `Payment of $${amount} was processed successfully.`;
}

You can also use .else() as a catch-all:

.match() works on failures, so check result.ok first.

return result
  .match()
  .when("NotFound", (err) => `Resource "${err.resource}" not found.`)
  .when("Unauthorized", () => `You are not allowed to access this resource.`)
  .else((err) => `Unexpected error: ${err._tag}. Try again later.`)
  .run();

Conclusion

Even if you do not adopt typescript-result specifically, it is worth understanding these error-handling styles and evaluating how your application treats failures.

If you are interested, read the typescript-result docs for more patterns. You can also explore alternatives like neverthrow, fp-ts, and Effect.