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.
Resposta curta
fetchpode cumprir a Promise com404ou500; confiraresponse.okantes 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, comook: trueouok: false.- Use
unknownna 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.