Destino Dev

Blog

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:

  1. Mantenha wrappers pequenos e previsíveis
  2. Adicione logs claros nas entradas e saídas do wrapper
  3. 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:

  1. Adicionar HttpClient com tratamento centralizado de JSON e erros
  2. Definir validators mínimos para tipos críticos
  3. Converter um serviço por vez para usar decorators
  4. Adicionar testes de contrato e smoke tests
  5. 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