Tipando Web APIs: fetch, FormData, FileReader
1. Introdução à Tipagem de Web APIs com TypeScript
Quando trabalhamos com Web APIs nativas em JavaScript, o maior desafio é a ausência de contratos claros entre o que enviamos e o que recebemos. Uma chamada fetch pode retornar qualquer coisa; um FormData aceita qualquer par de chave-valor; um FileReader dispara eventos com dados de tipos variados. Sem tipagem, o desenvolvedor fica refém de any e unknown, perdendo autocompletion, suporte a refatoração e, principalmente, segurança em tempo de compilação.
TypeScript resolve isso permitindo que definamos estruturas tipadas para cada uma dessas APIs. O ganho é imediato: erros de tipo são capturados antes de ir para produção, a documentação vive no código e a manutenção se torna previsível.
Neste artigo, vamos explorar como tipar fetch, FormData e FileReader — três APIs essenciais para qualquer aplicação web moderna.
2. Tipando fetch com Generics e Respostas Estruturadas
O fetch nativo não sabe o formato da resposta. Para resolver isso, criamos um wrapper genérico que recebe um tipo para o corpo esperado:
interface User {
id: number;
name: string;
email: string;
}
async function fetchTyped<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
// Uso tipado
const user = await fetchTyped<User>('/api/users/1');
console.log(user.name); // autocompletion funciona!
Para maior segurança, podemos validar a resposta com zod:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function fetchValidated<T>(
url: string,
schema: z.ZodSchema<T>
): Promise<T> {
const response = await fetch(url);
const data = await response.json();
return schema.parse(data); // lança erro se inválido
}
3. Trabalhando com FormData Tipado
FormData é dinâmico por natureza, mas podemos estruturá-lo com interfaces:
interface UploadForm {
avatar: File;
username: string;
bio?: string;
}
function buildFormData<T extends Record<string, any>>(data: T): FormData {
const form = new FormData();
for (const key in data) {
if (data.hasOwnProperty(key)) {
const value = data[key];
if (value instanceof File) {
form.append(key, value);
} else if (typeof value === 'string') {
form.append(key, value);
}
}
}
return form;
}
// Uso tipado
const formData = buildFormData<UploadForm>({
avatar: fileInput.files![0],
username: 'joao',
bio: 'Desenvolvedor',
});
const result = await fetchTyped<{ success: boolean }>('/api/upload', {
method: 'POST',
body: formData,
// headers: { 'Content-Type': 'multipart/form-data' } // fetch define automaticamente
});
Para receber FormData tipado no servidor (Node.js com multer ou similar), use:
interface ReceivedFile {
fieldname: string;
originalname: string;
mimetype: string;
buffer: Buffer;
size: number;
}
4. Tipando FileReader para Leitura Assíncrona de Arquivos
FileReader usa eventos, o que dificulta a tipagem. Vamos criar wrappers baseados em Promise:
function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
const result = event.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Resultado inesperado: não é string'));
}
};
reader.onerror = (event: ProgressEvent<FileReader>) => {
reject(event.target?.error ?? new Error('Erro desconhecido'));
};
reader.readAsText(file);
});
}
function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
const result = event.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Resultado inesperado'));
}
};
reader.onerror = (event: ProgressEvent<FileReader>) => {
reject(event.target?.error);
};
reader.readAsDataURL(file);
});
}
// Uso
const text = await readFileAsText(someFile); // tipo: string
const dataUrl = await readFileAsDataURL(someFile); // tipo: string
Para ArrayBuffer:
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
const result = event.target?.result;
if (result instanceof ArrayBuffer) {
resolve(result);
} else {
reject(new Error('Resultado inesperado'));
}
};
reader.onerror = (event: ProgressEvent<FileReader>) => {
reject(event.target?.error);
};
reader.readAsArrayBuffer(file);
});
}
5. Combinando fetch e FormData com Tipos Avançados
Vamos criar um exemplo completo de upload com metadados:
interface UploadPayload {
file: File;
metadata: {
title: string;
tags: string[];
isPublic: boolean;
};
}
interface UploadResponse {
id: string;
url: string;
uploadedAt: string;
}
async function uploadFile(
payload: UploadPayload
): Promise<UploadResponse> {
const formData = new FormData();
formData.append('file', payload.file);
formData.append('title', payload.metadata.title);
formData.append('tags', JSON.stringify(payload.metadata.tags));
formData.append('isPublic', String(payload.metadata.isPublic));
return fetchTyped<UploadResponse>('/api/files', {
method: 'POST',
body: formData,
});
}
Usando Partial<T> e Pick<T> para formulários opcionais:
interface UserProfile {
name: string;
email: string;
avatar: File | null;
bio: string;
website?: string;
}
type OptionalProfile = Partial<Pick<UserProfile, 'bio' | 'website'>>;
type RequiredProfile = Pick<UserProfile, 'name' | 'email' | 'avatar'>;
async function updateProfile(
required: RequiredProfile,
optional?: OptionalProfile
): Promise<void> {
const formData = new FormData();
formData.append('name', required.name);
formData.append('email', required.email);
formData.append('avatar', required.avatar);
if (optional?.bio) formData.append('bio', optional.bio);
if (optional?.website) formData.append('website', optional.website);
await fetchTyped<{ ok: boolean }>('/api/profile', {
method: 'PUT',
body: formData,
});
}
6. Tratamento de Erros e Tipagem de Exceções
Crie um tipo padronizado para erros de API:
interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
async function safeFetch<T>(
url: string,
options?: RequestInit
): Promise<T> {
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorBody: ApiError = await response.json().catch(() => ({
code: 'UNKNOWN',
message: `HTTP ${response.status}`,
}));
throw errorBody;
}
return response.json() as Promise<T>;
} catch (error) {
if (error instanceof TypeError) {
// Erro de rede
throw { code: 'NETWORK', message: 'Falha de conexão' } as ApiError;
}
throw error;
}
}
Para FileReader, tipamos DOMException:
function readFileSafe(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
const result = event.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new DOMException('Tipo inesperado', 'InvalidStateError'));
}
};
reader.onerror = () => {
reject(reader.error ?? new DOMException('Erro na leitura', 'NotReadableError'));
};
reader.readAsText(file);
});
}
7. Boas Práticas e Padrões de Projeto
Organize os tipos em arquivos dedicados:
// types/api.ts
export interface ApiResponse<T> {
data: T;
meta: { page: number; total: number };
}
export interface ApiError {
code: string;
message: string;
}
// types/forms.ts
export interface FileUpload {
file: File;
description?: string;
}
Use AbortController com tipagem:
interface CancellablePromise<T> {
promise: Promise<T>;
cancel: () => void;
}
function fetchWithCancel<T>(
url: string,
options?: RequestInit
): CancellablePromise<T> {
const controller = new AbortController();
const promise = fetchTyped<T>(url, {
...options,
signal: controller.signal,
});
return {
promise,
cancel: () => controller.abort(),
};
}
Teste a tipagem com satisfies:
const config = {
url: '/api/users',
method: 'GET' as const,
} satisfies { url: string; method: 'GET' | 'POST' };
8. Conclusão e Próximos Passos
Tipar Web APIs nativas com TypeScript transforma código frágil em código robusto. Vimos como fetch, FormData e FileReader podem ser encapsulados com tipos genéricos, validação com zod e wrappers baseados em Promise. O resultado é autocompletion preciso, erros capturados em compilação e uma base de código mais previsível.
Para aprofundar, explore bibliotecas como ky (fetch tipado leve) ou axios com tipos. Os próximos passos naturais são tipar eventos DOM e contextos React — temas que se beneficiam das mesmas técnicas.
Referências
- MDN Web Docs: Fetch API — Documentação oficial da API fetch, incluindo exemplos de uso com TypeScript.
- MDN Web Docs: FormData — Referência completa sobre FormData, construtores e métodos.
- MDN Web Docs: FileReader — Documentação oficial do FileReader com detalhes sobre eventos e tipos de resultado.
- TypeScript Handbook: Generics — Guia oficial sobre generics em TypeScript, essencial para wrappers tipados.
- Zod: TypeScript-first schema validation — Biblioteca de validação de esquemas que integra perfeitamente com tipos TypeScript.
- Ky: Tiny & elegant HTTP client — Cliente HTTP moderno e tipado para navegador, alternativa leve ao fetch puro.
- TypeScript Deep Dive: Type Guards — Tutorial avançado sobre type guards, útil para validação de respostas de API.