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 400Esse 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 redeMiddlewares 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
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.