Uma função de service pode falhar de duas formas que o chamador precisa tratar de maneira diferente. Um pedido pode ser inválido, um registro pode não existir ou uma regra de negócio pode impedir a operação. Esses são resultados esperados do domínio. Já uma conexão de banco que caiu, uma premissa quebrada ou um bug de programação não são escolhas normais do chamador.

Use Result<T, E> para o primeiro grupo e deixe o segundo grupo lançar uma exception. Essa separação não é uma regra de estilo para todo projeto. Ela torna o contrato explícito: o tipo descreve o que o chamador pode decidir, enquanto throw preserva a interrupção de um caminho que precisa de diagnóstico ou tratamento mais alto.

Este artigo é um spoke do guia sobre como tipar sucesso e erro de um fetch em TypeScript. O guia anterior trata dos resultados da rede e do JSON. Se a entrada ainda não foi conferida, veja também como validar JSON externo antes de usá-lo em TypeScript. Aqui, a pergunta é o que a camada de service deve prometer depois que essas entradas já passaram por um adaptador.

Não estou apresentando uma experiência de produção ou uma comparação de bibliotecas. Compilei uma fixture local com TypeScript 5.9.3, usando strict e skipLibCheck, para conferir o narrowing do Result e a forma do exemplo. A fixture não mede desempenho nem prova que uma política serve para todo domínio.

Diagrama compara Result para falhas esperadas, throw e catch para falhas inesperadas e a tradução na borda da API.

Resposta curta

  • Retorne Result<T, E> quando a falha fizer parte do resultado que o chamador consegue escolher como tratar.
  • Lance uma exception quando a função não puder oferecer uma recuperação local segura ou quando uma premissa de implementação tiver quebrado.
  • Na borda HTTP ou de uma fila, converta erros de domínio em respostas do protocolo e mantenha falhas inesperadas visíveis para observabilidade.
  • Use um erro discriminado e preserve cause; não transforme toda falha em Result<T, Error> nem capture tudo com a mesma mensagem.

A diferença é o que o chamador pode decidir

A primeira pergunta não é se Result parece mais funcional ou se try/catch parece mais idiomático. Pergunte se o chamador tem uma decisão normal para aquele caso.

Situação no service O chamador consegue decidir? Canal que costuma comunicar melhor
Pedido inválido para a regra de negócio Sim, pode informar o cliente ou corrigir a entrada Result<T, E>
Registro não encontrado, quando isso faz parte do contrato Sim, pode devolver 404, criar o registro ou seguir outro fluxo Result<T, E>
Conflito de estado conhecido Sim, pode devolver 409 ou pedir uma nova leitura Result<T, E>
Banco indisponível ou dependência quebrada Não há decisão local segura para concluir o pedido exception com contexto
Invariante impossível ou bug de programação Não é um resultado esperado do domínio exception, alerta e investigação

Essa classificação depende do contrato. NotFound pode ser um Result em uma busca de usuário e uma exception em uma rotina interna que recebeu um ID que deveria existir. O mesmo problema externo pode ser esperado para um adaptador e inesperado para a regra de negócio que o chama.

O Handbook do TypeScript sobre narrowing documenta como um campo literal compartilhado permite reduzir uma união discriminada a uma variante. Essa é a propriedade que faz um Result ser um contrato verificável, e não apenas um objeto com dois campos opcionais.

Como modelar um Result para o service

Comece com duas variantes que não podem existir ao mesmo tempo. O discriminante ok informa ao compilador e ao leitor qual campo está disponível.

type Success<T> = {
  ok: true;
  value: T;
};

type Failure<E> = {
  ok: false;
  error: E;
};

type Result<T, E> = Success<T> | Failure<E>;

type PlaceOrderError =
  | { kind: "not-found"; orderId: string }
  | { kind: "conflict"; reason: "already-closed" }
  | { kind: "invalid-input"; field: "quantity" };

type Order = {
  id: string;
  status: "created" | "closed";
};

O erro também precisa de um discriminante. Result<Order, Error> diz apenas que existe algum objeto de erro. PlaceOrderError dá ao consumidor uma lista de casos que ele consegue mapear para uma decisão, como 404, 409 ou 422. A lista não é uma promessa de que nenhuma exception pode escapar; ela é a parte esperada do contrato.

Um service pode usar essa união sem biblioteca adicional:

type OrderRepository = {
  load(orderId: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
};

async function placeOrder(
  orderId: string,
  quantity: number,
  repository: OrderRepository,
): Promise<Result<Order, PlaceOrderError>> {
  if (!Number.isInteger(quantity) || quantity < 1) {
    return {
      ok: false,
      error: { kind: "invalid-input", field: "quantity" },
    };
  }

  const order = await repository.load(orderId);

  if (!order) {
    return { ok: false, error: { kind: "not-found", orderId } };
  }

  if (order.status === "closed") {
    return {
      ok: false,
      error: { kind: "conflict", reason: "already-closed" },
    };
  }

  await repository.save(order);
  return { ok: true, value: order };
}

O exemplo é ilustrativo: a quantidade ainda não altera o pedido, e a persistência não foi ligada a um banco. O que importa para o contrato é que entradas e estados esperados retornam uma variante explícita. Se repository.load lançar porque o banco ficou indisponível, essa falha não é engolida por este Result.

Quando uma exception é a escolha mais clara?

throw interrompe a função atual e procura o primeiro catch na pilha de chamadas, conforme a referência da MDN para a instrução throw. Use essa interrupção quando seguir com um valor normal poderia fazer o chamador acreditar que o trabalho terminou.

Uma exception não precisa ser uma mensagem sem contexto. Quando você adiciona contexto, preserve a causa original:

class UnexpectedServiceError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
    this.name = "UnexpectedServiceError";
  }
}

async function saveWithContext(
  order: Order,
  repository: OrderRepository,
): Promise<void> {
  try {
    await repository.save(order);
  } catch (cause) {
    throw new UnexpectedServiceError("order repository failed", { cause });
  }
}

O campo cause existe para manter a causa original quando um erro recebe uma mensagem mais útil, como explica a documentação da MDN sobre Error.cause. O log pode registrar o erro original e o contexto do service, enquanto a resposta pública continua sem detalhes de infraestrutura.

Não capture tudo apenas para devolver Result. Se você transformar uma indisponibilidade de banco em { ok: false, error: ... } sem uma decisão clara para o chamador, o tipo fica mais uniforme, mas o sinal de incidente fica mais fraco. O contrato deve dizer quem consegue recuperar, não apenas esconder a pilha.

A borda da API traduz os dois caminhos

O controller ou handler conhece o protocolo externo. Ele pode converter uma falha esperada em HTTP e deixar a falha inesperada chegar ao middleware de erro, ao logger ou ao mecanismo de retry apropriado. Essa divisão cabe na arquitetura Model-Controller-Service para backends TypeScript, desde que o service não passe a conhecer o formato final da resposta.

function statusFor(error: PlaceOrderError): number {
  switch (error.kind) {
    case "invalid-input":
      return 422;
    case "not-found":
      return 404;
    case "conflict":
      return 409;
  }
}

async function handlePlaceOrder(
  orderId: string,
  quantity: number,
  repository: OrderRepository,
): Promise<Response> {
  try {
    const result = await placeOrder(orderId, quantity, repository);

    if (!result.ok) {
      return Response.json(
        { error: result.error.kind },
        { status: statusFor(result.error) },
      );
    }

    return Response.json(result.value, { status: 200 });
  } catch (error) {
    console.error("unexpected place-order failure", error);
    return Response.json(
      { error: "internal-error" },
      { status: 500 },
    );
  }
}

O if (!result.ok) faz o narrowing para Failure<PlaceOrderError>. O switch também deixa a política de protocolo visível. Se alguém acrescentar uma nova variante ao erro, o retorno explícito e a verificação do compilador tornam o ponto de revisão fácil de encontrar.

Esse handler não é um modelo universal de middleware. Em um framework que já possui um tradutor global de exceptions, o service pode deixar UnexpectedServiceError escapar e o handler pode retornar a resposta 500 em um lugar central. A regra continua: traduza erros esperados onde o protocolo é conhecido e não transforme um defeito operacional em sucesso parcial.

Como escolher sem transformar o código em cerimônia?

Use estas perguntas para cada falha que atravessa a fronteira do service:

  1. O caso pode acontecer em uma entrada válida ou em um estado normal do domínio?
  2. O chamador tem uma ação definida, como corrigir dados, devolver 404 ou repetir com outra versão?
  3. O consumidor precisa distinguir esse caso de outros para decidir o fluxo?
  4. Se a função retornar um erro, alguém ainda consegue observar a causa, o request ID e a operação que falhou?

Se as três primeiras respostas forem sim, Result<T, E> costuma tornar o contrato mais honesto. Se a última resposta for não, o problema não é escolher entre Result e exception. Falta uma política de diagnóstico.

O caminho híbrido costuma ser o mais simples em aplicações TypeScript:

  • adapters convertem respostas externas e validações conhecidas em erros de domínio;
  • services retornam Result para decisões normais do caso de uso;
  • falhas inesperadas preservam sua causa e sobem como exceptions;
  • controllers traduzem resultados de domínio para HTTP, mensagens de fila ou eventos;
  • testes verificam cada variante sem tratar uma falha inesperada como saída válida.

Essa divisão também ajuda agentes de código e revisores: a assinatura mostra o caminho que deve ser tratado, enquanto um catch amplo não vira uma autorização para ignorar erros. O tipo não substitui testes, logs ou limites de permissão, mas reduz a parte implícita da conversa.

O que testar antes de adotar a política?

O teste mais útil não mede a velocidade da exception. Ele confirma que o contrato não mudou silenciosamente:

  • uma entrada inválida retorna a variante de erro esperada;
  • um registro ausente não chama o método de persistência;
  • um conflito retorna seu discriminante e o código HTTP correspondente;
  • uma exceção do repositório não vira um Result de sucesso;
  • o handler não expõe a mensagem ou o cause de uma dependência;
  • uma nova variante de PlaceOrderError força a revisão do switch.

A fixture deste artigo foi compilada com strict, noEmit, target: ES2022 e lib: ["ES2022", "DOM"]. Ela confere a forma dos exemplos e o narrowing, mas não executa um banco nem simula um servidor HTTP. Para um projeto real, coloque esses casos no runner usado pelo repositório e inclua a falha do adapter, do banco e do protocolo que realmente existem no sistema.

Fontes e limites

O comportamento de narrowing usado no Result vem do Handbook do TypeScript sobre narrowing. O controle de fluxo de throw e catch vem da referência da MDN para throw, e a preservação da causa vem da referência da MDN para Error.cause. A referência da MDN para Promise.prototype.catch descreve a fronteira de rejeição que uma função async entrega ao chamador.

As escolhas de 422, 404, 409 e 500 no exemplo são uma política ilustrativa, não uma recomendação universal de status HTTP. Não comparei bibliotecas como neverthrow ou Effect, não medi overhead e não afirmo uma convenção dominante na comunidade. A pesquisa, a redação, a imagem e a tradução tiveram assistência de IA; a assistência não executou a fixture nem substitui a verificação das fontes e a responsabilidade editorial do autor.