fetch retorna uma Response, não o objeto que a sua regra de negócio espera. Um status 404 também não rejeita a Promise por si só. Se o helper transforma tudo em T com as User, o compilador deixa de mostrar justamente a fronteira onde o dado externo ainda não foi verificado.

O padrão mais simples é retornar uma união discriminada: { ok: true, data } para sucesso e { ok: false, error } para falha. O erro pode distinguir status HTTP, falha da requisição, JSON inválido e dado que não passou pelo validador. Assim, cada consumidor precisa escolher o caminho antes de usar o valor.

Isso complementa a validação de JSON externo antes de usar o valor em TypeScript. O artigo atual concentra a validação em runtime; aqui, a pergunta é como transportar o resultado completo pela aplicação sem esconder os erros em exceções genéricas.

Diagrama mostra uma resposta de fetch passando por uma checagem de tipo e seguindo para sucesso, erro HTTP ou falha de requisição e JSON.

Resposta curta

  • fetch pode cumprir a Promise com 404 ou 500; confira response.ok antes de ler o dado.
  • response.json() continua sendo assíncrono e pode falhar depois que os headers chegaram.
  • Use Result<T, E> com um discriminante literal, como ok: true ou ok: false.
  • Use unknown na entrada e um validador em runtime antes de afirmar que o resultado é T.

Por que fetch não é um resultado de negócio?

A Promise de fetch representa a resposta da rede, não o sucesso da operação da aplicação. A documentação do MDN sobre fetch explica que a Promise é cumprida com um objeto Response e não rejeita apenas porque o servidor respondeu com 404 ou 504. O campo ok indica status entre 200 e 299.

Há ainda uma segunda etapa. response.json() lê o corpo e também retorna uma Promise. O servidor pode enviar headers válidos e depois entregar JSON malformado, ou uma estrutura diferente da esperada. Por isso, um helper que só captura o catch de fetch não representa todos os caminhos que o consumidor precisa tratar.

O tipo Response também não conhece User, Order ou qualquer outro contrato da sua aplicação. A interface TypeScript descreve o que você declarou, mas não inspeciona bytes recebidos pela rede. Essa mesma diferença entre declaração e prova aparece quando você valida dados adicionados por middleware no Express.

Como modelar sucesso e erro com uma união discriminada?

Uma união discriminada usa uma propriedade comum com valores literais diferentes para representar cada variante. O handbook de narrowing do TypeScript mostra que uma comparação com esse discriminante reduz a união ao membro compatível. Para uma resposta, ok: true permite data; ok: false permite error.

O tipo genérico abaixo mantém o valor de sucesso e o erro independentes. O exemplo é ilustrativo e deve ser verificado com tsc --strict no projeto que o adotar:

type Ok<T> = {
  ok: true;
  data: T;
};

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

type Result<T, E> = Ok<T> | Err<E>;

function showUser(result: Result<User, FetchError>) {
  if (result.ok) {
    return result.data.name;
  }

  return `request failed: ${result.error.kind}`;
}

Não use ok: boolean nas duas variantes. Se o discriminante for apenas boolean, o compilador perde a relação entre data e error. Também não modele os dois campos como opcionais em um único objeto. Esse desenho permite estados como { ok: true, error: ... } e exige mais verificações manuais.

Como separar HTTP, requisição e JSON inválido?

Modele as falhas que o consumidor consegue tratar de formas diferentes. O status HTTP pode virar uma mensagem para o usuário, uma falha de rede pode pedir uma tentativa posterior e um JSON inválido pode abrir um alerta para o fornecedor. Uma união de erros conserva essa diferença sem obrigar cada chamada a analisar uma string de mensagem.

type FetchError =
  | { kind: "http"; status: number }
  | { kind: "request"; cause: unknown }
  | { kind: "invalid-json"; cause: unknown }
  | { kind: "invalid-data"; message: string };

type Validator<T> = (value: unknown) => value is T;

export async function getJson<T>(
  input: RequestInfo | URL,
  isT: Validator<T>,
): Promise<Result<T, FetchError>> {
  let response: Response;

  try {
    response = await fetch(input);
  } catch (cause) {
    return { ok: false, error: { kind: "request", cause } };
  }

  if (!response.ok) {
    await response.body?.cancel().catch(() => undefined);
    return { ok: false, error: { kind: "http", status: response.status } };
  }

  let value: unknown;
  try {
    value = await response.json();
  } catch (cause) {
    return { ok: false, error: { kind: "invalid-json", cause } };
  }

  if (!isT(value)) {
    return {
      ok: false,
      error: { kind: "invalid-data", message: "response shape is not valid" },
    };
  }

  return { ok: true, data: value };
}

O retorno de response.json() entra como unknown por escolha de segurança. O validador é a prova em runtime que transforma esse valor em T. Se a aplicação usa Zod, Valibot, JSON Schema ou um cliente gerado, troque apenas a função isT. Não remova a fronteira porque o compilador não consegue ler a resposta que veio da rede.

O body.cancel() no caminho HTTP também é uma decisão explícita. Se você não vai consumir o corpo de uma resposta que já sabe ser inválida, cancele-o ou consuma-o conforme o cliente e o protocolo usados. O detalhe não transforma um status HTTP em exceção automaticamente; ele só evita deixar uma resposta sem destino.

Como consumir o resultado sem casts?

O consumidor deve estreitar primeiro e acessar a propriedade depois. Um switch no campo kind torna os caminhos de erro visíveis e permite uma checagem exaustiva. O exemplo continua ilustrativo:

function assertNever(value: never): never {
  throw new Error(`unhandled result: ${String(value)}`);
}

function describe(result: Result<User, FetchError>): string {
  if (result.ok) {
    return `Hello, ${result.data.name}`;
  }

  switch (result.error.kind) {
    case "http":
      return `upstream returned ${result.error.status}`;
    case "request":
      return "the request could not be completed";
    case "invalid-json":
      return "the upstream response was not valid JSON";
    case "invalid-data":
      return result.error.message;
    default:
      return assertNever(result.error);
  }
}

Se uma nova variante for adicionada a FetchError, o assertNever faz o tsc apontar para esse switch até que o consumidor escolha um comportamento. Isso é diferente de prometer que o sistema nunca falhará. O tipo apenas torna o conjunto de decisões conhecido no código que compila.

Não desestruture data e error antes de verificar ok só para encurtar o código. Em versões e formatos diferentes, a relação de narrowing pode ficar menos evidente. Preserve o objeto até a comparação do discriminante; depois acesse a variante estreitada.

Como verificar se o tipo protege a fronteira?

Comece pelo compilador. Execute npx tsc --noEmit com as mesmas opções do CI e confirme que result.data falha no ramo ok: false, que result.error falha no ramo de sucesso e que o switch acusa uma variante nova. Se o projeto ainda não tem TypeScript configurado, trate os blocos deste artigo como ilustrativos até criar uma fixture mínima com strict: true.

Depois teste o comportamento em quatro casos: resposta 2xx com estrutura válida, status HTTP de erro, corpo que não é JSON e JSON que não passa pelo validador. Adicione uma falha de conexão usando um endpoint local ou um mock do transporte. O teste deve conferir a variante e os campos relevantes, não apenas se uma Promise terminou.

Para a camada de teste, use a mesma ideia aplicada a testar lógica de retry em JavaScript sem esperar: controle a dependência externa e verifique a transição observável. Não transforme o teste em uma prova de que o TypeScript executou validação em runtime. Tipos desaparecem quando o código vira JavaScript.

O que esse padrão não resolve?

Uma união Result não valida JSON sozinha, escolhe o timeout correto, torna um POST idempotente, repete uma chamada com segurança ou garante que o servidor desfez um efeito externo. Ela organiza o contrato de retorno. A política de retry, cancelamento, telemetria e idempotência continua sendo uma decisão da camada que conhece a operação.

Também não é obrigatório substituir todas as exceções. Use um resultado para falhas esperadas que o chamador precisa decidir. Reserve exceções para invariantes quebrados ou falhas que a camada atual realmente não consegue representar. Se uma biblioteca já entrega um tipo de resultado, adapte-o na fronteira em vez de criar uma segunda convenção em cada chamada.

Perguntas frequentes

fetch rejeita a Promise quando recebe 404?

Não. A Promise normalmente cumpre com um Response, e o consumidor precisa examinar response.ok ou response.status. O guia do MDN sobre Fetch separa status HTTP de falhas como rede ou URL inválida. O helper pode converter o status em uma variante http sem lançar uma exceção genérica.

Uma interface TypeScript valida o JSON recebido?

Não. A interface descreve o que o programa espera, mas não inspeciona o valor em runtime. Leia o corpo como unknown e use um type guard ou uma biblioteca de schema para provar o formato antes de retornar T. A validação de JSON externo mostra essa fronteira com um objeto aninhado.

Result<T, E> substitui try/catch em todo o código?

Não. Ele é útil quando sucesso e falha fazem parte do contrato que o chamador precisa tratar. Um erro inesperado ainda pode ser lançado e capturado por uma camada de infraestrutura. O benefício está em escolher, por função, quais falhas esperadas viram dados e quais continuam sendo exceções.

Conclusão

Tipar uma resposta de fetch exige separar o que a rede entregou do que a aplicação pode confiar. Primeiro confira o status. Depois leia o corpo, valide o formato e só então produza T. Uma união discriminada leva esse contrato até o consumidor sem esconder falhas em any, as ou mensagens de erro não estruturadas.

O padrão fica pequeno quando cada camada tem uma responsabilidade: fetch transporta, o validador prova o formato e Result<T, E> torna as decisões explícitas. A partir daí, retry, cancelamento e observabilidade podem evoluir sem reescrever a forma como toda chamada interpreta sucesso e erro.

Fontes consultadas