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