Client HTTP type-safe com Fetch: decorators, generics e type guards
Por John Pires · 08 de março de 2026

Client HTTP type-safe com Fetch: decorators, generics e type guards
1) Contexto e motivacao
Por que type-safety importa em clientes HTTP
APIs retornam JSON e o TypeScript dá tipagem estática apenas em tempo de compilação. Em execução, o runtime não protege contra mudanças de contrato ou respostas mal formadas. Um cliente HTTP type-safe combina generics, decorators e type guards para: 1) manter a ergonomia do TS, 2) validar runtime, 3) reduzir bugs silenciosos causados por alterações de contrato.
Sem validação em runtime você tem duas classes de problemas: erros silenciosos (undefined em campos esperados) e falhas abruptas (acesso a propriedades de null/undefined). A proposta aqui é equilibrar ergonomia com segurança, sem adotar bibliotecas pesadas.
Objetivos práticos deste padrão
Ao terminar, você terá um cliente que: usa decorators para declarar endpoints, emprega generics para propagar tipos de request/response, e aplica type guards para validação runtime. O foco é aplicável em projetos com 1–3 anos de experiência em TypeScript, sem dependências externas obrigatórias.
O padrão valoriza:
- Clareza na declaração de endpoints
- Validação runtime mínima mas eficaz
- Reutilização de tipos e convenções de erro
Dica: não tente validar tudo no runtime. Valide apenas o essencial (campos obrigatórios, tipos primários) e trate com estratégias claras de fallback.
2) Como funciona por baixo dos panos
Fluxo de chamada e responsabilidade
O fluxo básico do cliente é: método decorado -> wrapper gerado pelo decorator -> normalização dos parâmetros -> chamada para HttpClient.request -> fetch -> validação com type guard -> retorno tipado. Cada etapa tem responsabilidade única. Isso facilita testes e substituição de componentes (p.ex. trocar fetch por uma implementação mock).
Separar responsabilidades evita classes monolíticas e facilita retry, logging, métricas e tratamento centralizado de erros.
Como decorators integram tipagem e runtime
Decorators não alteram o sistema de tipos do TS — eles atuam no runtime. Aqui usamos decorators para gerar wrappers que chamam um cliente HTTP, mantendo a assinatura TypeScript do método. No corpo do decorator aplicamos uma função que converte argumentos em payload/params e dispara a requisição.
Os generics garantem que a assinatura do método embarque os tipos de entrada e saída, enquanto os type guards fornecem segurança quando os dados chegam do servidor. O decorator apenas organiza o fluxo, sem embaralhar validação ou lógica de request.
3) Implementacao passo a passo
Tipos base e type guards
Antes de tudo, defina tipos de domínio e type guards. Um type guard é uma função runtime que afirma que um objeto corresponde a um tipo TypeScript. Exemplo: validação simples para User.
Contexto do código abaixo: define tipos de resposta padrão, helpers e um guard para validar que um objeto é um User. Isso serve como base para validar respostas do fetch.
// O que faz: define tipos e um type guard para User
export type User = {
id: string;
name: string;
email?: string;
};
export type ApiError = { message: string; code?: number };
export function isUser(obj: unknown): obj is User {
if (typeof obj !== 'object' || obj === null) return false;
const o = obj as Record<string, unknown>;
return typeof o.id === 'string' && typeof o.name === 'string' && (o.email === undefined || typeof o.email === 'string');
}
// O que acontece: você consegue usar isUser(data) no runtime para garantir segurança antes de confiar no objeto
Depois da execução desse bloco, o projeto tem tipos e um guard leve. Para casos mais complexos, recomenda-se bibliotecas de validação (zod/io-ts), mas para muitos endpoints, guards manuais são suficientes e mais performáticos.
HttpClient genérico com validação
Agora implementamos um HttpClient que aceita generics para tipar a resposta e exige um validator. O método request recebe URL, opções e um guard que valida o corpo recebido. Em caso de falha, lança um erro detalhado.
// O que faz: implementação minimalista de HttpClient que usa fetch e type guard
export class HttpClient {
constructor(private baseUrl: string, private defaultHeaders: Record<string, string> = {}) {}
async request<T>(
path: string,
opts: RequestInit = {},
validator?: (v: unknown) => v is T,
): Promise<T> {
const url = this.baseUrl + path;
const headers = { 'Content-Type': 'application/json', ...this.defaultHeaders, ...(opts.headers || {}) } as Record<string, string>;
const res = await fetch(url, { ...opts, headers });
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) {
const apiError: ApiError = typeof body === 'object' && body !== null && 'message' in (body as any) ? (body as any) : { message: res.statusText, code: res.status };
throw Object.assign(new Error('HTTP error'), { status: res.status, body: apiError });
}
if (validator) {
if (!validator(body)) {
throw new Error('Response validation failed');
}
}
return body as T;
}
}
// O que acontece: chamadas ao request validam respostas e centralizam parsing e erros
Observação: em ambientes node você precisa de um polyfill para fetch (node-fetch ou usar Node 18+). O cliente centraliza headers e o parse JSON, reduzindo repetição.
Decorators para mapear endpoints
Os decorators geram wrappers de método que chamam o cliente. A ideia é manter a assinatura do método para o TypeScript, mas interceptar a chamada em runtime para executar uma requisição. O decorator recebe path e método HTTP, e aceita um validator para a resposta.
Contexto do seguinte código: implementa um method decorator factory Endpoint usado em classes de serviço. O decorator substitui o método por uma função que usa HttpClient.request.
// O que faz: decorator que substitui o método por um wrapper que faz fetch
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export function Endpoint<TResp>(method: HttpMethod, path: string, validator?: (v: unknown) => v is TResp) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
// 'this' precisa expor um client: HttpClient
const client: HttpClient = this.client;
// support simple conventions: if last arg is options, extract it
const maybeOptions = args.length && typeof args[args.length - 1] === 'object' && !(args[args.length - 1] instanceof Array) ? args.pop() : undefined;
// format path with params: /users/:id -> replace :id with args[0]
const runtimePath = path.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
const v = args.shift();
return encodeURIComponent(String(v));
});
const requestInit: RequestInit = {
method,
...(maybeOptions || {}),
};
// if POST/PUT and there's remaining arg, use as body
if ((method === 'POST' || method === 'PUT') && args.length) {
requestInit.body = JSON.stringify(args[0]);
}
return client.request<TResp>(runtimePath, requestInit, validator);
};
return descriptor;
};
}
// O que acontece: você aplica @Endpoint('GET', '/users/:id', isUser) em métodos e eles passam a chamar o client automaticamente
Esse decorator é intencionalmente simples. Ele assume convenções (parâmetros posicionais e corpo único). Para APIs REST mais complexas, estenda-o para suportar query params, headers por método e autenticação.
Exemplo de uso: serviço com endpoints tipados
Agora combinamos tudo: criamos um UserService que injeta HttpClient e usa decorators para declarar endpoints com validators. Isso torna o código declarativo e cada método retorna Promise.
// O que faz: define um service que usa decorators para endpoints
export class UserService {
constructor(public client: HttpClient) {}
@Endpoint<User>('GET', '/users/:id', isUser)
async getUser(id: string): Promise<User> {
// corpo nunca executado: decorator substitui, mas manter assinatura é útil para tipos
throw new Error('Not implemented');
}
@Endpoint<User[]>('GET', '/users', (v): v is User[] => Array.isArray(v) && v.every(isUser))
async listUsers(): Promise<User[]> { throw new Error('Not implemented'); }
}
// O que acontece: calls como await userService.getUser('abc') resultam em fetch + validação via isUser
Com essa abordagem, a ergonomia do serviço é alta: você usa métodos no estilo RPC mas com todas as garantias de tipo e validação runtime.
4) Patterns e boas praticas
Design de validators e trade-offs
Validators devem ser simples e focados no que importa. Para responses com muitos campos opcionais, foque em validar os campos que controlam lógica crítica (ids, flags, listas). Evite validar toda a árvore profunda se não for necessário — isso afeta latência e manutenção.
Para DTOs complexos, prefira bibliotecas (zod, io-ts) que geram validators e tipos automaticamente. Mas mantenha o cliente desacoplado: aceite um validator como argumento em decorators ou HttpClient para flexibilidade.
Configuracao e environment
Mantenha configuração fora do código: baseUrl, tempo de timeout, cabeçalhos default e estratégia de retry devem ser passados ao construir HttpClient. Isso facilita mocks em testes e substituição em diferentes ambientes (staging/production).
Exemplo de configuração recomendada:
- baseUrl por env
- timeout configurável
- hooks de request/response para logging
Retry, backoff e idempotência
Implementar retry síncrono para GETs é seguro, mas para POSTs exige cuidado: repetições podem criar duplicações. Use cabeçalhos idempotency-key ou implementar retry apenas para erros de rede e 5xx, com backoff exponencial.
Coloque retry como responsabilidade do HttpClient — decorators não precisam lidar com isso. Um middleware de retry pode ser inserido entre o parse e a validação.
Testabilidade e mocks
Construa HttpClient de forma que possa ser mockado facilmente. Em testes unitários, crie um FakeHttpClient que implementa request, ou use fetch-mock para interceptar chamadas. Como as classes de serviço recebem client via construtor, é trivial injetar mocks.
Dica: mantenha as classes de serviço livres de lógica de parsing. Tudo que for relacionado a rede fica no HttpClient e decorators.
5) Armadilhas comuns e como evitar
Assumir que TS garante segurança no runtime
Armadilha clássica: confiar apenas no compilador. Se a API muda (p.ex. name -> fullName), o código compila, mas em runtime os dados estarão incorretos. A solução: validators para os campos essenciais e testes de contrato (contract tests) entre backend e frontend.
Faça smoke tests automatizados em CI que confirmem contratos críticos, e documente quais campos são considerados obrigatórios.
Decorators que escondem lógica e dificultam depuração
Decorators podem tornar stack traces menos óbvias. Para evitar isso:
- Mantenha wrappers pequenos e previsíveis
- Adicione logs claros nas entradas e saídas do wrapper
- Evite lógica de negócio dentro do decorator
Se algo falhar, ter logs no nível do HttpClient ajuda a localizar problemas (URL, status, body truncated).
Parsing de JSON sem proteção
Fazer JSON.parse direto e assumir estrutura é perigoso. O HttpClient apresentado trata texto vazio e parse; porém, para endpoints que retornam strings ou códigos 204, cuide do caso em que body é vazio.
Recomendações:
- Trate 204/empty responses separadamente
- Forneça um validator opcional
- Em erros de parsing, capture e transforme em erro descritivo
Exceções liberais e falta de tipagem de erros
Não lance erros genéricos. Crie tipos de erro com propriedades úteis (status, code, body). Isso ajuda tratamento no frontend sem precisar inspecionar strings.
// O que faz: padrão simples para erro HTTP com metadados
export class HttpError extends Error {
constructor(public status: number, public body: unknown) {
super(`HTTP ${status}`);
}
}
// O que acontece: agora lançamos HttpError com status e body para tratamento específico
Substitua lançamentos genéricos por essa classe para permitir switch/case no consumer.
Conclusao e passos praticos
Resumo das decisões chave
Você aprendeu uma abordagem pragmática para construir um cliente HTTP type-safe com fetch usando decorators para mapeamento de endpoint, generics para propagar tipos e type guards para validação runtime. A separação de responsabilidades (HttpClient, decorators, validators) torna o sistema testável e extensível.
As decisões importantes foram: centralizar parsing e erros no HttpClient; aceitar validators para segurança runtime; usar decorators apenas como orquestradores; manter métodos de serviço com assinaturas claras para a equipe.
Checklist para adotar esse padrão
Para aplicar este padrão em um projeto existente, siga este checklist:
- Adicionar HttpClient com tratamento centralizado de JSON e erros
- Definir validators mínimos para tipos críticos
- Converter um serviço por vez para usar decorators
- Adicionar testes de contrato e smoke tests
- Revisar retries e idempotency para endpoints mutáveis
Executando esse checklist você reduz risco e melhora progressivamente a robustez da camada de integração com APIs.
Exemplo completo de uso (integração final)
Contexto: montagem rápida de client, service e chamada real. Este exemplo mostra como instanciar o HttpClient e usar o UserService.
// O que faz: exemplo de bootstrap e chamada a API
async function bootstrap() {
const client = new HttpClient('https://api.example.com', { Authorization: 'Bearer token' });
const userService = new UserService(client);
try {
const user = await userService.getUser('123');
console.log('got user', user.name);
} catch (err) {
if (err instanceof HttpError) {
console.error('api error', err.status, err.body);
} else {
console.error('unexpected', err);
}
}
}
// O que acontece: chama o endpoint /users/123, valida com isUser e retorna um User ou lança um HttpError
Termine implementando logs e testes unitários para o HttpClient. A estabilidade do cliente depende tanto do código quanto da disciplina de documentação e testes entre equipes.
Se optar por usar bibliotecas externas para validação, mantenha a interface do HttpClient idêntica — isso facilita migrações e testes.
Dica final: comece com guards simples e adicione validações só onde os dados realmente impactam a lógica do app.
Assuntos: TypeScript, fetch, decorators, generics, type-guards
Leia também
Config type-safe em Node.js com TypeScript: Zod, generics e utility types
Aprenda a fazer configuração type-safe em Node.js com TypeScript usando Zod, generics e utility types. Passo a passo prático para validar e inferir tipos do…
Config type-safe em TypeScript: loader genérico com merges e type guards
Padrão prático para loaders de config em TypeScript: substitua casts por um loader genérico que aplica defaults, faz deep merge e valida com type guards…
Configuração tipada em TypeScript: env, generics e decorators práticos
Como evitar casts perigosos em process.env e construir loaders tipados em TypeScript usando generics e decorators. Padrões práticos, erros comuns e…
DTOs type-safe em TypeScript: decorators, generics e type guards passo a passo
Aprenda a transformar JSON em DTOs type-safe com TypeScript usando decorators, generics e type guards. Guia passo a passo para iniciantes: configuração…
Sistema de plugins type-safe em TypeScript com decorators e generics
Implementação prática de um sistema de plugins type-safe em TypeScript usando decorators, generics e type guards. Padrões, armadilhas comuns e exemplos reais…
Feature flags type-safe em TypeScript: configuração, generics e type guards
Aprenda a implementar feature flags type-safe em TypeScript: defina keys literais, use generics, type guards e decorators para evitar typos e validar…