Cliente HTTP em TypeScript: fazendo requisições corretamente do A ao Z
\n\nO desenvolvimento web moderno é impensável sem a interação com servidores remotos. Seja obtendo dados de uma API REST, enviando formulários ou carregando arquivos — as requisições HTTP são a base da arquitetura cliente-servidor. O TypeScript, com sua tipagem estrita e ferramentas poderosas, permite tornar o trabalho com requisições HTTP não apenas seguro, mas também fácil de manter. Neste artigo, vamos abordar como criar um cliente HTTP competente em TypeScript, evitar erros comuns e escrever código que funcione de forma previsível.
\n\n1. Fundamentos: fetch API e sua tipagem
\n\nO fetch nativo do navegador é uma ferramenta moderna e flexível para realizar requisições HTTP. No entanto, seu uso em TypeScript exige a tipagem correta da resposta. Por padrão, fetch retorna Promise<Response>, e precisamos indicar explicitamente quais dados esperamos receber.
interface User {\n id: number;\n name: string;\n email: string;\n}\n\nasync function getUser(id: number): Promise<User> {\n const response = await fetch(`https://api.example.com/users/${id}`);\n \n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n \n const data: User = await response.json();\n return data;\n}\n\n\nObserve a verificação de response.ok. Isso é criticamente importante: mesmo que o servidor retorne um erro (404, 500, etc.), o fetch não lançará uma exceção automaticamente. Precisamos tratar o status manualmente.
Para requisições POST com corpo, é necessário especificar o cabeçalho Content-Type:
async function createUser(userData: Omit<User, 'id'>): Promise<User> {\n const response = await fetch('https://api.example.com/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to create user: ${response.statusText}`);\n }\n\n return response.json();\n}\n\n\n2. Criando um cliente HTTP universal
\n\nEm vez de escrever fetch do zero toda vez, é melhor criar um wrapper — um cliente HTTP universal. Isso permite gerenciar centralizadamente a URL base, cabeçalhos e tratamento de erros. Vejamos um exemplo baseado em classes:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';\n\ninterface RequestConfig<TBody = unknown> {\n method: HttpMethod;\n url: string;\n body?: TBody;\n headers?: Record<string, string>;\n params?: Record<string, string>;\n}\n\nclass HttpClient {\n private baseUrl: string;\n private defaultHeaders: Record<string, string>;\n\n constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {\n this.baseUrl = baseUrl;\n this.defaultHeaders = {\n 'Content-Type': 'application/json',\n ...defaultHeaders,\n };\n }\n\n private async request<TResponse, TBody = unknown>(\n config: RequestConfig<TBody>\n ): Promise<TResponse> {\n const url = new URL(`${this.baseUrl}${config.url}`);\n \n if (config.params) {\n Object.entries(config.params).forEach(([key, value]) => {\n url.searchParams.append(key, value);\n });\n }\n\n const response = await fetch(url.toString(), {\n method: config.method,\n headers: { ...this.defaultHeaders, ...config.headers },\n body: config.body ? JSON.stringify(config.body) : undefined,\n });\n\n if (!response.ok) {\n const errorBody = await response.text();\n throw new Error(`Request failed: ${response.status} ${response.statusText}. Body: ${errorBody}`);\n }\n\n // Para DELETE ou outros métodos que podem não retornar corpo\n if (response.status === 204) {\n return undefined as TResponse;\n }\n\n return response.jso