Pular para o conteúdo
TypeScript

Como Criar API Client Type-Safe em TypeScript

Chega de erros silenciosos em chamadas HTTP. Monte um client que valida endpoints, params e responses antes do código rodar.

Por que isso é importante

Como Criar API Client Type-Safe em TypeScript. Chega de erros silenciosos em chamadas HTTP. Monte um client que valida endpoints, params e responses antes do código rodar.

Por que isso é importante

APIs sem tipagem são bombas-relógio em produção. Um typo no endpoint, um campo faltando no body, uma propriedade renomeada no backend... tudo isso vira erro em runtime que TypeScript poderia ter pego antes mesmo de você rodar o código.

Dá pra fazer muito melhor. Com um API client completamente tipado, você ganha autocomplete em endpoints, validação de parâmetros, detecção de campos obrigatórios e responses totalmente inferidos. Zero erros bobos, zero consultas na documentação da API pra saber qual campo usar.

E não é só sobre prevenir bugs. É sobre velocidade de desenvolvimento. Quando você digita api.users. e o VSCode já mostra todos os métodos disponíveis, quando você passa um ID errado e o TypeScript reclama na hora... você programa mais rápido e com mais confiança.

O Custo de APIs Sem Tipo

Antes de entrar na solução, vamos ver o que você provavelmente tá fazendo hoje. Fetch direto com strings soltas, sem nenhuma validação:

// Abordagem comum (e perigosa)
const getUser = async (id: string) => {
  const response = await fetch(`/api/users/${id}`);
  return response.json(); // tipo: any 💀
};

// Chamada sem nenhuma segurança
const user = await getUser('123');
console.log(user.nmae); // typo: ninguém reclama ❌

// Post sem validação de body
const createUser = async (data: any) => {
  await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(data) // data pode ser qualquer coisa
  });
};

// Esqueceu um campo obrigatório? Só vai descobrir em runtime
await createUser({ name: 'João' }); // faltou email, erro 400

Esse código funciona até que não funciona mais. Alguém renomeia um campo no backend, você digita errado o endpoint, esquece um header obrigatório... e tudo vira debugging de erro 400/500 em produção.

O problema não é o fetch em si. É a falta de contrato entre frontend e backend. TypeScript tem todo o poder pra validar isso, mas você precisa dar informação pra ele trabalhar. E é aí que entra o API client tipado.

Quando você define exatamente quais endpoints existem, quais parâmetros cada um aceita e qual response cada um retorna, o compilador passa a ser seu aliado. Ele não deixa você fazer besteira. Mudou a API? O código quebra em tempo de compilação, não em produção.

Definindo a Config de Endpoints

Primeiro passo: criar uma definição central de todos os endpoints da sua API. Uma única fonte de informação que descreve rotas, métodos HTTP, parâmetros de URL, body e response esperado.

Vamos começar com os tipos base:

// Métodos HTTP suportados
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

// Estrutura de um endpoint
type EndpointConfig = {
  path: string;           // Template da rota: '/users/:id'
  method: HttpMethod;     // Método HTTP
  params?: object;        // Parâmetros de URL
  query?: object;         // Query string
  body?: object;          // Request body
  response: unknown;      // Response esperado
};

// Mapeamento completo da API
type ApiEndpoints = {
  [key: string]: EndpointConfig;
};

Agora vem a parte top: definir todos os endpoints da sua API de uma vez. Isso aqui substitui documentação swagger, comentários dispersos e aquela planilha que ninguém atualiza:

// Types dos dados
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

interface Post {
  id: string;
  title: string;
  content: string;
  authorId: string;
  createdAt: string;
}

// Definição completa da API
const apiConfig = {
  // Users
  getUser: {
    path: '/users/:id',
    method: 'GET',
    params: { id: '' },
    response: {} as User
  },
  
  listUsers: {
    path: '/users',
    method: 'GET',
    query: { page: 0, limit: 0 },
    response: {} as { users: User[]; total: number }
  },
  
  createUser: {
    path: '/users',
    method: 'POST',
    body: {} as Omit,
    response: {} as User
  },
  
  updateUser: {
    path: '/users/:id',
    method: 'PUT',
    params: { id: '' },
    body: {} as Partial,
    response: {} as User
  },
  
  deleteUser: {
    path: '/users/:id',
    method: 'DELETE',
    params: { id: '' },
    response: {} as { success: boolean }
  },
  
  // Posts
  getPost: {
    path: '/posts/:id',
    method: 'GET',
    params: { id: '' },
    response: {} as Post
  },
  
  createPost: {
    path: '/posts',
    method: 'POST',
    body: {} as Omit,
    response: {} as Post
  }
} as const satisfies ApiEndpoints;

Olha que massa o que rolou aqui. Cada endpoint tem tudo que você precisa saber sobre ele. O path com template de parâmetros, o método HTTP, quais dados aceita e qual response retorna. E tudo tipado corretamente.

O 'as const' no final é importante. Ele transforma tudo em tipos literais, garantindo que os valores exatos sejam preservados no sistema de tipos. E o 'satisfies ApiEndpoints' valida que a estrutura tá correta sem perder a inferência específica.

Repara que usamos {} as User em vez de criar objetos reais. É só pra dar o tipo pro TypeScript inferir. Os valores em si não importam, só os tipos que eles carregam.

Tipos Auxiliares com Infer

Agora que temos a config de endpoints, precisamos extrair informações dela. Pra cada chamada de API, queremos saber automaticamente quais params aceita, qual body espera e qual response retorna.

É aí que entram os utility types com infer. Esses tipos são meio mágicos: eles conseguem 'ler' outros tipos e extrair pedaços específicos. Vamos criar três helpers que vão fazer o trabalho pesado:

// Extrai o tipo dos parâmetros de URL
type ExtractParams = T extends { params: infer P } 
  ? P 
  : never;

// Extrai o tipo do request body
type ExtractBody = T extends { body: infer B } 
  ? B 
  : never;

// Extrai o tipo da response
type ExtractResponse = T extends { response: infer R } 
  ? R 
  : never;

// Extrai o tipo da query string
type ExtractQuery = T extends { query: infer Q } 
  ? Q 
  : never;

// Testa os helpers
type GetUserParams = ExtractParams;
// Resultado: { id: string }

type CreateUserBody = ExtractBody;
// Resultado: Omit

type UserResponse = ExtractResponse;
// Resultado: User

type ListUsersQuery = ExtractQuery;
// Resultado: { page: number; limit: number }

Deixa eu explicar a mágica do infer. Quando você escreve 'T extends { params: infer P }', você tá dizendo pro TypeScript: 'se T tem uma propriedade params, captura o tipo dela e chama de P'. Depois você retorna P.

Se T não tem a propriedade em questão, o tipo retorna never. Isso é perfeito porque nem todo endpoint tem params, body ou query. Endpoints GET normalmente não têm body, por exemplo.

Esses helpers são a base do autocomplete top que vamos ter no client. Quando você chamar api.getUser(), o TypeScript vai usar ExtractParams pra saber que você precisa passar { id: string }. Automático, sem você precisar escrever o tipo manualmente.

Montando o APIClient

Agora vem a parte mais divertida: construir o client que usa toda essa tipagem. Ele vai ser uma classe genérica que aceita a config de endpoints e expõe métodos completamente tipados.

A ideia é você instanciar uma vez e usar em todo o projeto. Cada chamada vai ter autocomplete perfeito, validação de parâmetros e response inferido. Vamos lá:

// Tipo para as opções de chamada
type RequestOptions = {
  params?: ExtractParams;
  query?: ExtractQuery;
  body?: ExtractBody;
  headers?: Record;
  signal?: AbortSignal;
};

class APIClient {
  constructor(
    private baseURL: string,
    private config: T,
    private defaultHeaders: Record = {}
  ) {}

  // Substitui params no path: /users/:id -> /users/123
  private buildPath(
    template: string, 
    params?: Record
  ): string {
    if (!params) return template;
    
    return Object.entries(params).reduce(
      (path, [key, value]) => 
        path.replace(`:${key}`, String(value)),
      template
    );
  }

  // Adiciona query string na URL
  private buildURL(
    path: string, 
    query?: Record
  ): string {
    const url = new URL(path, this.baseURL);
    
    if (query) {
      Object.entries(query).forEach(([key, value]) => {
        if (value !== undefined && value !== null) {
          url.searchParams.set(key, String(value));
        }
      });
    }
    
    return url.toString();
  }

  // Método principal de chamada
  async request(
    endpoint: K,
    options?: RequestOptions
  ): Promise> {
    const config = this.config[endpoint];
    
    // Monta a URL completa
    const path = this.buildPath(config.path, options?.params as any);
    const url = this.buildURL(path, options?.query as any);
    
    // Headers padrão + custom
    const headers = {
      'Content-Type': 'application/json',
      ...this.defaultHeaders,
      ...options?.headers
    };
    
    // Monta o request
    const requestInit: RequestInit = {
      method: config.method,
      headers,
      signal: options?.signal
    };
    
    // Adiciona body se existir
    if (options?.body) {
      requestInit.body = JSON.stringify(options.body);
    }
    
    // Faz a chamada
    const response = await fetch(url, requestInit);
    
    // Trata erros HTTP
    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new APIError(
        response.status,
        response.statusText,
        error
      );
    }
    
    // Retorna response tipado
    return response.json();
  }
}

// Classe de erro customizada
class APIError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public data: any
  ) {
    super(`API Error ${status}: ${statusText}`);
    this.name = 'APIError';
  }
}

Esse client é enxuto mas poderoso. A mágica toda tá no método request que usa generics pra inferir tudo automaticamente. Quando você passa o endpoint, o TypeScript já sabe quais options são válidas.

Vamos ver na prática como fica o uso:

// Instancia o client
const api = new APIClient(
  'https://api.exemplo.com',
  apiConfig,
  { Authorization: 'Bearer token123' }
);

// GET /users/123 - autocomplete em params
const user = await api.request('getUser', {
  params: { id: '123' } // TypeScript sabe que id é obrigatório
});
console.log(user.name); // user é do tipo User, autocomplete funciona ✅

// GET /users?page=1&limit=10 - query string tipada
const users = await api.request('listUsers', {
  query: { page: 1, limit: 10 }
});
console.log(users.total); // tipo inferido corretamente ✅

// POST /users - body validado
const newUser = await api.request('createUser', {
  body: {
    name: 'João',
    email: 'joao@email.com',
    role: 'user'
  }
});
// Se esquecer um campo obrigatório, TypeScript reclama ✅

// PUT /users/123 - params + body
await api.request('updateUser', {
  params: { id: '123' },
  body: { name: 'João Silva' } // Partial, só campos que quer mudar
});

// DELETE /users/123
await api.request('deleteUser', {
  params: { id: '123' }
});

// Erros tipados
try {
  await api.request('getUser', { params: { id: '999' } });
} catch (error) {
  if (error instanceof APIError) {
    console.log(error.status); // 404
    console.log(error.data);   // { message: 'User not found' }
  }
}

Olha que diferença. Cada chamada tem validação completa. Você não consegue passar params errados, esquecer campos obrigatórios no body ou acessar propriedades inexistentes na response. Tudo checado em tempo de compilação.

E o autocomplete é top demais. Quando você digita api.request('', o VSCode já lista todos os endpoints disponíveis. Escolheu um endpoint, ele mostra quais options são válidas. É desenvolvimento em outra velocidade.

Variadic Tuple Types para Params Opcionais

Tem um detalhe que incomoda no client atual: você sempre precisa passar um objeto options, mesmo quando não há params nem body. Pra um GET simples, seria melhor chamar só api.request('listUsers') sem precisar do segundo argumento.

Dá pra melhorar isso com conditional types e variadic tuples. A ideia é tornar o segundo parâmetro opcional quando o endpoint não tem params, query nem body:

// Verifica se um tipo é vazio
type IsEmptyObject = T extends Record ? true : false;

// Verifica se endpoint precisa de options
type RequiresOptions = 
  ExtractParams extends never
    ? ExtractQuery extends never
      ? ExtractBody extends never
        ? false
        : true
      : true
    : true;

// Parâmetros condicionais
type RequestParams = 
  RequiresOptions extends true
    ? [options: RequestOptions]
    : [options?: RequestOptions];

class APIClient {
  // ... construtor e métodos auxiliares iguais

  // Método request com params condicionais
  async request(
    endpoint: K,
    ...args: RequestParams
  ): Promise> {
    const [options] = args;
    const config = this.config[endpoint];
    
    // ... resto da implementação igual
  }
}

Agora o uso fica muito mais limpo:

// Endpoints sem params/body - segundo argumento opcional
const users = await api.request('listUsers'); // ✅

// Pode passar options se quiser
const usersFiltered = await api.request('listUsers', {
  query: { page: 2, limit: 20 }
}); // ✅

// Endpoints com params - segundo argumento obrigatório
const user = await api.request('getUser', {
  params: { id: '123' }
}); // ✅

// TypeScript reclama se esquecer
const user2 = await api.request('getUser'); 
// ❌ Erro: Expected 2 arguments, but got 1

// Endpoints com body - também obrigatório
const newUser = await api.request('createUser', {
  body: { name: 'João', email: 'joao@email.com', role: 'user' }
}); // ✅

Esse refinamento deixa a API muito mais ergonômica. Você só passa options quando realmente precisa, mas o TypeScript ainda força você a passar quando são obrigatórias. Melhor dos dois mundos.

O truque tá no RequestParams que retorna uma tupla variádica. Se o endpoint requer options, a tupla tem um elemento obrigatório. Se não requer, o elemento é opcional. E isso tudo é calculado automaticamente pelo sistema de tipos.

Integrando com React/Next.js

API client tipado é ainda mais poderoso quando você encapsula ele em hooks customizados. Dá pra criar wrappers que lidam com loading, error handling, caching e revalidação automática.

Vamos criar hooks que funcionam com SWR e React Query, duas libs mais usadas pra data fetching:

// hooks/useAPI.ts
import useSWR from 'swr';
import { useState } from 'react';
import type { APIClient } from '../lib/api-client';

// Hook para queries (GET)
export function useQuery<
  T extends ApiEndpoints,
  K extends keyof T
>(
  api: APIClient,
  endpoint: K,
  options?: RequestOptions,
  swrOptions?: SWRConfiguration
) {
  // Cria key única pra cache
  const key = options 
    ? [endpoint, JSON.stringify(options)]
    : [endpoint];
  
  // Fetcher tipado
  const fetcher = () => api.request(endpoint, options as any);
  
  // SWR com tipos corretos
  const result = useSWR>(
    key,
    fetcher,
    swrOptions
  );
  
  return result;
}

// Hook para mutations (POST, PUT, DELETE)
export function useMutation<
  T extends ApiEndpoints,
  K extends keyof T
>(
  api: APIClient,
  endpoint: K
) {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const mutate = async (
    options?: RequestOptions
  ): Promise> => {
    try {
      setIsLoading(true);
      setError(null);
      const result = await api.request(endpoint, options as any);
      return result;
    } catch (err) {
      setError(err as Error);
      throw err;
    } finally {
      setIsLoading(false);
    }
  };
  
  return { mutate, isLoading, error };
}

Usando nos componentes:

// components/UserProfile.tsx
import { useQuery, useMutation } from '../hooks/useAPI';
import { api } from '../lib/api';

function UserProfile({ userId }: { userId: string }) {
  // Query com autocomplete e tipos corretos
  const { data: user, error, isLoading } = useQuery(
    api,
    'getUser',
    { params: { id: userId } },
    { refreshInterval: 30000 } // revalida a cada 30s
  );
  
  // Mutation pra update
  const { mutate: updateUser, isLoading: isUpdating } = useMutation(
    api,
    'updateUser'
  );
  
  const handleUpdate = async () => {
    try {
      await updateUser({
        params: { id: userId },
        body: { name: 'Novo Nome' }
      });
      // Revalida query após update
      mutate(['getUser', JSON.stringify({ params: { id: userId } })]);
    } catch (error) {
      console.error('Falha ao atualizar:', error);
    }
  };
  
  if (isLoading) return 
Carregando...
; if (error) return
Erro: {error.message}
; if (!user) return
Usuário não encontrado
; return (

{user.name}

{/* autocomplete funciona ✅ */}

{user.email}

Role: {user.role}

); } // components/UserList.tsx function UserList() { // Query com paginação const [page, setPage] = useState(1); const { data, error, isLoading } = useQuery( api, 'listUsers', { query: { page, limit: 10 } } ); // Mutation pra criar usuário const { mutate: createUser } = useMutation(api, 'createUser'); const handleCreate = async () => { const newUser = await createUser({ body: { name: 'Novo Usuário', email: 'novo@email.com', role: 'user' } }); console.log('Criado:', newUser.id); // tipo inferido ✅ }; if (isLoading) return
Carregando...
; if (error) return
Erro ao carregar
; return (
{data?.users.map(user => ( // tipos corretos ✅
{user.name}
))}
Página {page}
); }

Viu como ficou limpo? Os hooks encapsulam toda a complexidade de loading states, error handling e revalidação. E mantêm a tipagem completa do início ao fim.

Quando você acessa user.name no JSX, o TypeScript sabe exatamente que é uma string. Quando você passa body pro createUser, ele valida se todos os campos obrigatórios estão presentes. Zero chance de erro bobo.

E o melhor: você escreve essa estrutura uma vez e reutiliza em todo o projeto. Qualquer novo endpoint que você adicionar no apiConfig automaticamente fica disponível nos hooks com tipagem completa. É produtividade em escala.

Middleware e Interceptors Tipados

API clients reais precisam de mais do que só fazer requests. Você precisa adicionar tokens de autenticação, tratar erros globalmente, fazer retry em falhas de rede, logar chamadas em desenvolvimento... tudo isso sem perder a tipagem.

Vamos adicionar um sistema de middleware ao nosso client:

// Tipos para middleware
type RequestContext = {
  endpoint: string;
  url: string;
  init: RequestInit;
};

type ResponseContext = {
  endpoint: string;
  response: Response;
  data: T;
};

type RequestMiddleware = (
  context: RequestContext
) => RequestContext | Promise;

type ResponseMiddleware = (
  context: ResponseContext
) => ResponseContext | Promise;

type ErrorMiddleware = (
  error: Error,
  context: RequestContext
) => Error | Promise;

class APIClient {
  private requestMiddlewares: RequestMiddleware[] = [];
  private responseMiddlewares: ResponseMiddleware[] = [];
  private errorMiddlewares: ErrorMiddleware[] = [];

  constructor(
    private baseURL: string,
    private config: T,
    private defaultHeaders: Record = {}
  ) {}

  // Registra middlewares
  useRequest(middleware: RequestMiddleware) {
    this.requestMiddlewares.push(middleware);
    return this;
  }

  useResponse(middleware: ResponseMiddleware) {
    this.responseMiddlewares.push(middleware);
    return this;
  }

  useError(middleware: ErrorMiddleware) {
    this.errorMiddlewares.push(middleware);
    return this;
  }

  async request(
    endpoint: K,
    ...args: RequestParams
  ): Promise> {
    const [options] = args;
    const config = this.config[endpoint];
    
    const path = this.buildPath(config.path, options?.params as any);
    const url = this.buildURL(path, options?.query as any);
    
    let requestContext: RequestContext = {
      endpoint: String(endpoint),
      url,
      init: {
        method: config.method,
        headers: {
          'Content-Type': 'application/json',
          ...this.defaultHeaders,
          ...options?.headers
        },
        signal: options?.signal
      }
    };
    
    if (options?.body) {
      requestContext.init.body = JSON.stringify(options.body);
    }
    
    // Aplica request middlewares
    for (const middleware of this.requestMiddlewares) {
      requestContext = await middleware(requestContext);
    }
    
    try {
      // Faz request
      const response = await fetch(
        requestContext.url, 
        requestContext.init
      );
      
      if (!response.ok) {
        const error = await response.json().catch(() => ({}));
        throw new APIError(response.status, response.statusText, error);
      }
      
      const data = await response.json();
      
      // Aplica response middlewares
      let responseContext: ResponseContext = {
        endpoint: String(endpoint),
        response,
        data
      };
      
      for (const middleware of this.responseMiddlewares) {
        responseContext = await middleware(responseContext);
      }
      
      return responseContext.data;
      
    } catch (error) {
      // Aplica error middlewares
      let processedError = error as Error;
      
      for (const middleware of this.errorMiddlewares) {
        processedError = await middleware(processedError, requestContext);
      }
      
      throw processedError;
    }
  }
}

Agora vamos criar middlewares úteis pra usar no client:

// middleware/auth.ts
export const authMiddleware: RequestMiddleware = (context) => {
  // Pega token do localStorage
  const token = localStorage.getItem('auth_token');
  
  if (token) {
    context.init.headers = {
      ...context.init.headers,
      Authorization: `Bearer ${token}`
    };
  }
  
  return context;
};

// middleware/logger.ts
export const loggerMiddleware: RequestMiddleware = (context) => {
  if (process.env.NODE_ENV === 'development') {
    console.log('🚀 API Request:', {
      endpoint: context.endpoint,
      method: context.init.method,
      url: context.url,
      body: context.init.body
    });
  }
  
  return context;
};

export const loggerResponseMiddleware: ResponseMiddleware = (context) => {
  if (process.env.NODE_ENV === 'development') {
    console.log('✅ API Response:', {
      endpoint: context.endpoint,
      status: context.response.status,
      data: context.data
    });
  }
  
  return context;
};

// middleware/retry.ts
export const retryMiddleware = (maxRetries = 3): ErrorMiddleware => {
  const retryCount = new Map();
  
  return async (error, context) => {
    // Só retenta em erros de rede
    if (!(error instanceof TypeError)) {
      throw error;
    }
    
    const key = `${context.endpoint}:${context.url}`;
    const count = retryCount.get(key) || 0;
    
    if (count >= maxRetries) {
      retryCount.delete(key);
      throw error;
    }
    
    retryCount.set(key, count + 1);
    
    // Espera com backoff exponencial
    await new Promise(resolve => 
      setTimeout(resolve, Math.pow(2, count) * 1000)
    );
    
    // Refaz request
    const response = await fetch(context.url, context.init);
    
    if (!response.ok) {
      throw new APIError(
        response.status,
        response.statusText,
        await response.json().catch(() => ({}))
      );
    }
    
    retryCount.delete(key);
    return error; // Não usado, mas precisa retornar
  };
};

// middleware/rate-limit.ts
export const rateLimitMiddleware: RequestMiddleware = (() => {
  const queue: Array<() => void> = [];
  let activeRequests = 0;
  const maxConcurrent = 5;
  
  return (context) => {
    return new Promise((resolve) => {
      const execute = () => {
        activeRequests++;
        resolve(context);
        
        // Libera slot após request completar
        const originalFetch = fetch;
        fetch = async (...args) => {
          try {
            return await originalFetch(...args);
          } finally {
            activeRequests--;
            if (queue.length > 0) {
              const next = queue.shift();
              next?.();
            }
          }
        };
      };
      
      if (activeRequests < maxConcurrent) {
        execute();
      } else {
        queue.push(execute);
      }
    });
  };
})();

Configurando o client com middlewares:

// lib/api.ts
import { APIClient } from './api-client';
import { apiConfig } from './api-config';
import {
  authMiddleware,
  loggerMiddleware,
  loggerResponseMiddleware,
  retryMiddleware,
  rateLimitMiddleware
} from './middleware';

export const api = new APIClient(
  process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
  apiConfig
)
  .useRequest(authMiddleware)
  .useRequest(loggerMiddleware)
  .useRequest(rateLimitMiddleware)
  .useResponse(loggerResponseMiddleware)
  .useError(retryMiddleware(3));

// Uso direto
await api.request('getUser', { params: { id: '123' } });
// -> Adiciona token automaticamente
// -> Loga request/response em dev
// -> Limita concorrência
// -> Retenta até 3x em falha de rede

Middlewares deixam o client super flexível. Você configura comportamentos globais uma vez e todas as chamadas se beneficiam. Auth, logging, retry, rate limiting... tudo transparente pro código que usa a API.

E o melhor: continua completamente tipado. Os middlewares não interferem nos tipos das chamadas. Quando você faz api.request('getUser', ...), ainda tem autocomplete completo e validação de types, independente de quantos middlewares estejam rodando por baixo.

Checklist do API Client Type-Safe

  • Defina config central com todos endpoints, paths, métodos e tipos
  • Use 'as const satisfies ApiEndpoints' pra preservar tipos literais
  • Crie utility types com infer pra extrair params, body e response
  • Implemente APIClient genérico que aceita a config de endpoints
  • Use conditional types pra tornar options opcional quando não necessário
  • Adicione error handling com classe APIError customizada
  • Crie hooks (useQuery, useMutation) pra integração com React
  • Implemente sistema de middleware pra auth, logging e retry
  • Configure middlewares na criação do client, não em cada chamada
  • Teste que autocomplete funciona em endpoints, params e responses
  • Valide que TypeScript reclama de campos obrigatórios faltando
  • Verifique que tipos de response são inferidos corretamente
  • Adicione AbortController pra cancelamento de requests
  • Use buildPath pra substituir params de URL (:id → 123)
  • Implemente buildURL pra adicionar query string
  • Configure headers padrão no construtor do client
  • Adicione suporte a custom headers por request
  • Implemente retry com backoff exponencial em falhas de rede
  • Adicione rate limiting pra evitar sobrecarga de requests
  • Use SWR ou React Query pra caching e revalidação automática

Conclusão

API client type-safe não é luxo, é necessidade em qualquer projeto sério. A quantidade de bugs que você evita é absurda: endpoints digitados errado, campos faltando no body, typos em propriedades da response... tudo isso vira erro de compilação em vez de bug em produção.

E não é só sobre prevenir erros. É sobre velocidade de desenvolvimento. Quando você tem autocomplete perfeito em todos os endpoints, quando o TypeScript te guia sobre quais params passar, quando você não precisa ficar consultando documentação de API... você programa muito mais rápido.

O investimento inicial de criar a config de endpoints e os utility types compensa já no primeiro sprint. Cada novo endpoint que você adiciona automaticamente fica disponível com tipagem completa. Refatorou o backend e mudou um campo? O TypeScript mostra exatamente onde você precisa atualizar no frontend.

E com middlewares, você centraliza comportamentos que antes ficavam espalhados pelo código: autenticação, retry logic, logging, rate limiting. Tudo configurado uma vez e aplicado globalmente. Limpo, manutenível, testável.

Comece simples: defina os endpoints principais, monte o client básico, adicione os hooks. Depois você vai incrementando com middlewares conforme a necessidade. Mas mesmo a versão mais básica já elimina uma categoria inteira de bugs.

E quando você integra com React Query ou SWR, aí sim você tem um sistema completo: validação de tipos, caching inteligente, revalidação automática, loading states gerenciados... tudo sem perder a segurança de tipos do TypeScript.

Simples assim: defina seus endpoints uma vez, use em todo lugar com tipagem completa. Zero surpresas em runtime, desenvolvimento mais rápido, código mais confiável. É TypeScript sendo usado do jeito certo.