O uso de try/catch é o padrão mais comum para lidar com erros no Javascript. Entretanto, essa abordagem possui algumas desvantagens. Não existe uma forma de verificar se uma função possivelmente faz um throw. Também não é possível verificar quais os erros que o catch vai receber. Isso tudo favorece um código que geralmente inclui grandes blocos de try/catch, onde múltiplos erros são tratados de forma igual, e muitas informações são perdidas.

Além disso, o uso de throw prejudica o entendimento do código porque muda o fluxo do código. Nesse caso, para entender qual código vai ser executado depois do erro ser lançado, é necessário navegar por todo o código em busca do catch mais próximo, o que pode ser difícil e confuso.

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);
  }
}
Por que o erro do catch é sempre unknown?

No JavaScript, o throw permite lançar qualquer tipo de valor, não apenas Error. É perfeitamente válido escrever throw "deu ruim", throw 404, ou até mesmo throw null.

Devido a essa flexibilidade, o TypeScript adota uma postura segura e tipa a variável de erro no bloco catch como unknown. Isso força o desenvolvedor a verificar o tipo do erro (usando typeof ou instanceof) antes de tentar acessar propriedades como .message ou .stack, garantindo que o valor existe e é do tipo esperado.

Uma alternativa possível é o padrão de utilizar Erro como valor. Essa abordagem é utilizada em algumas linguagens como Go, Rust e recentemente Kotlin. A ideia é que as funções retornem os erros como valores, deixando claro para o consumidor daquela função quais são os possíveis erros.

Esse padrão pode ser simulado no typescript com o tipo Result:

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

O tipo Result representa a resposta de uma função sobre dois genéricos, o T, que representa os dados retornados, e E que representa um erro. Além disso, possui uma propriedade discriminatória ok, que indica de que se trata de um erro ou de um sucesso.

// 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;
  }
}

O objetivo não é eliminar completamente o uso do throw/catch, mas sim reservá-lo para situações de falha extrema, situações em que a execução deve ser parada imediatamente. Por exemplo, quando uma variável de ambiente obrigatória não está presente na inicialização, ou quando a conexão com o banco de dados falha irremediavelmente durante o boot.

Assim, temos uma separação entre erros esperados (usuário não encontrado, saldo insuficiente, falha de validação) e erros não esperados (estouro de memória, erros de sintaxe SQL). Além disso, podemos adicionar metadados aos erros esperados, que facilitam debugação e observabilidade do sistema.

Embora seja possível definir seu próprio tipo Result e funções auxiliares manualmente, para aplicações reais é recomendado utilizar uma solução com mais recursos. A partir daqui, utilizaremos a biblioteca typescript-result.

A seguir, discutiremos três padrões distintos para tratar melhor os erros nas suas aplicações.

Criando um Result

Antes de poder consumir um Result, nós precisamos adaptar nossas funções para gerar um Result. O typescript-result expõe algumas APIs para isso.

Result.ok e Result.error geram, respectivamente, um resultado de uma operação bem sucedida e um resultado de um erro.

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);

Você pode explicitamente definir o retorno como Result<number, DomainError>, por exemplo. Apesar do TypeScript inferir os tipos automaticamente, definir o retorno explicitamente é considerado uma boa prática se você está criando uma função pública (biblioteca ou API), pois garante que o contrato da função seja respeitado e evita que tipos internos "vazem" acidentalmente.

Para facilitar a geração de Results a partir de funções que podem fazer um throw, você pode utilizar o try ou 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);
Por que os erros possuem a propriedade \_tag?

Typescript possui verificação estrutural, o que significa que, para fins de verificação, os seguintes erros são os mesmos:

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

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

Em alguns casos, o Typescript pode "colapsar" os tipos em um só, quando na verdade eles possuem significados semânticos diferentes.

A literal _tag facilita a distinção dos erros em runtime, evitando o uso de instanceof. Em aplicações modernas (grandes monorepos, micro-frontends, etc), o instanceof pode falhar. Isso acontece porque a classe instanciada pode vir de um contexto ou bundle diferente da classe que você está usando para a comparação, mesmo que elas tenham o mesmo nome e código. Uma string literal (_tag) é imune a esse problema.

Você pode ler mais sobre a verificação estrutural do typescript no meu artigo: Entendendo Structural Typing no TypeScript.

Essas funções utilitárias são fundamentais quando precisamos interagir com bibliotecas externas ou código legado que não utilizam o padrão Result. Elas funcionam como uma barreira de proteção nas bordas da sua aplicação, encapsulando exceções imprevisíveis e trazendo-as para dentro do fluxo controlado e tipado do Result.

Até agora, vimos exemplos que retornam Promise<Result<T, E>>. Entretanto, a biblioteca typescript-result fornece um tipo especializado chamado AsyncResult<T, E>, que é essencialmente um alias para Promise<Result<T, E>>.

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

Verificação Manual de Erros

Agora, quando utilizamos uma função que retorna um erro, precisamos verificar se não temos um erro. Podemos tomar uma decisão com o que fazer com aquele erro, ou repassar ele para cima:

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);
}

Esse padrão é muito semelhante ao padrão utilizado em Go, onde funções, por convenção, retornam sempre um possível resultado e um possível erro. Em Go, em vez de usar throw/catch, as funções retornam múltiplos valores: o resultado esperado e um erro (se houver). É como se JavaScript tivesse: [resultado, erro] = await minhaFuncao() em cada chamada.

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
}

Uma das vantagens dessa abordagem é que ela é extremamente simples de entender, mesmo se alguém não estiver familiarizado com essa biblioteca ou padrão. Ele deixa explícito os pontos de tomada de decisão.

Entretanto, um problema é a verbosidade. Por exemplo, um erro precisa se propagar (bubble up) até atingir a camada adequada que vai lidar com ele.

Encadeando operações (Chaining)

E se, em vez de verificar o conteúdo de um Result, a gente tivesse um método que aplicasse uma função no Result somente se ele fosse um sucesso, retornando o result imediatamente se ele na verdade for um erro?

Toda instância de Result possui alguns métodos que permitem trabalhar com o conteúdo de um Result, sem a necessidade de verificar seu conteúdo.

O .map() aplica uma função somente se o resultado corresponder com um sucesso (que pode retornar um valor, ou até mesmo um outro Result), enquanto que o .mapError() aplica a função somente se o resultado for um erro. Note que essas funções retornam outro Result, o que possibilita encadear multiplas chamadas.

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.`;
}

Além disso, você pode utilizar o .else() para lidar com todos os outros erros da mesma forma.

O .match() funciona somente em falhas, é necessário verificar o valor de .ok antes.

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();

Conclusão

Mesmo que você não utilize especificamente a biblioteca typescript-result ou o padrão Result, eu acredito que é importante você estar ciente dessas formas de tratar erros, e parar para pensar em como você está lidando com erros na sua aplicação.

Se você tem interesse, eu recomendo ler a documentação do typescript-result, que possui outros métodos e informações de como melhor utilizar a biblioteca.

Além disso, você pode explorar outras bibliotecas neverthrow e fp-ts. A biblioteca Effect vai além, gerando um framework e ecossistemas em volta do conceito de Effect, uma função que recebe um contexto e retorna um Result.