Tipando respostas de API com generics

1. O problema de tipar respostas de API

Ao consumir APIs REST, um dos maiores desafios é garantir que os dados retornados sejam tipados corretamente. Sem uma abordagem estruturada, desenvolvedores recorrem frequentemente ao uso de any ou unknown, resultando em perda de segurança de tipos e inferência prejudicada.

Considere o exemplo abaixo, onde uma função de fetch retorna dados sem tipagem adequada:

async function fetchUserData(url: string): Promise<any> {
  const response = await fetch(url);
  return response.json();
}

// Uso frágil - sem autocomplete ou verificação de tipos
const user = await fetchUserData('/api/users/1');
console.log(user.name); // Nenhum erro em tempo de compilação, mas pode falhar em runtime

Problemas comuns:
- Estruturas de resposta inconsistentes entre endpoints (alguns retornam { data, message, status }, outros apenas o objeto)
- Ausência de tratamento tipado para erros
- Dificuldade em refatorar quando a API muda

2. Fundamentos de Generics em TypeScript

Generics permitem criar componentes que funcionam com vários tipos, mantendo a segurança de tipos. A sintaxe básica utiliza parâmetros de tipo entre <>:

function identity<T>(arg: T): T {
  return arg;
}

// Inferência automática
const num = identity(42); // tipo: number
const str = identity("hello"); // tipo: string

Constraints com extends restringem os tipos permitidos:

function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

getLength("texto"); // OK
getLength([1, 2, 3]); // OK
// getLength(42); // Erro: number não tem length

3. Criando um tipo genérico para resposta de API

Vamos definir um tipo base que encapsula a estrutura comum de respostas:

interface ApiResponse<T> {
  data: T;
  message: string;
  status: number;
  success: boolean;
}

// Uso com diferentes tipos
type UserResponse = ApiResponse<User>;
type ProductResponse = ApiResponse<Product[]>;

Para suporte a paginação:

interface PaginationMeta {
  currentPage: number;
  totalPages: number;
  totalItems: number;
  itemsPerPage: number;
}

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: PaginationMeta;
}

// Exemplo: resposta de lista de usuários paginada
type PaginatedUsers = PaginatedResponse<User>;

Lidando com erros de forma mais robusta:

type ApiResult<T, E = string> = 
  | { success: true; data: T; message: string }
  | { success: false; error: E; message: string };

// Uso
type LoginResult = ApiResult<{ token: string }, { code: number; details: string }>;

4. Tipando funções de fetch com generics

Criamos uma função genérica que tipa automaticamente a resposta:

async function fetchData<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>> {
  const response = await fetch(url, options);

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const result = await response.json();
  return result as ApiResponse<T>;
}

// Uso com tipagem explícita
interface User {
  id: number;
  name: string;
  email: string;
}

const userResponse = await fetchData<User>('/api/users/1');
console.log(userResponse.data.name); // Autocomplete funciona!

// Com tratamento de erros genérico
async function fetchWithError<T, E = string>(url: string): Promise<ApiResult<T, E>> {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return { success: true, data, message: 'Sucesso' };
  } catch (error) {
    return { success: false, error: error as E, message: 'Falha na requisição' };
  }
}

5. Generics em hooks e custom hooks

Hooks genéricos permitem reutilização com diferentes tipos de dados:

import { useState, useEffect } from 'react';

interface UseApiState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

function useApi<T>(url: string): UseApiState<T> {
  const [state, setState] = useState<UseApiState<T>>({
    data: null,
    loading: true,
    error: null
  });

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const result = await response.json();
        setState({ data: result as T, loading: false, error: null });
      } catch (err) {
        setState({ data: null, loading: false, error: (err as Error).message });
      }
    };
    fetchData();
  }, [url]);

  return state;
}

// Hook para listas paginadas
function usePaginatedQuery<T>(baseUrl: string, page: number) {
  return useApi<PaginatedResponse<T>>(`${baseUrl}?page=${page}`);
}

// Uso com diferentes tipos
const { data: user } = useApi<User>('/api/users/1');
const { data: products } = usePaginatedQuery<Product>('/api/products', 1);

6. Avançando: validação e transformação de respostas

Bibliotecas como Zod permitem validar dados em runtime com tipos inferidos:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email()
});

type User = z.infer<typeof UserSchema>;

function validateResponse<T>(data: unknown, schema: z.ZodSchema<T>): T {
  const result = schema.safeParse(data);
  if (!result.success) {
    throw new Error(`Validação falhou: ${result.error.message}`);
  }
  return result.data;
}

// Função de fetch com validação
async function fetchValidated<T>(url: string, schema: z.ZodSchema<T>): Promise<T> {
  const response = await fetch(url);
  const data = await response.json();
  return validateResponse(data, schema);
}

// Transformação genérica de dados
function transformResponse<T, R>(data: T, transformer: (input: T) => R): R {
  return transformer(data);
}

7. Boas práticas e armadilhas comuns

Evite any em fallbacks: Prefira unknown com type guards:

// Ruim
function processData(data: any) {
  return data.value; // Sem segurança
}

// Bom
function processData(data: unknown) {
  if (data && typeof data === 'object' && 'value' in data) {
    return (data as { value: number }).value;
  }
  throw new Error('Dados inválidos');
}

Documente tipos genéricos com JSDoc:

/**
 * Busca dados de uma API e retorna uma resposta tipada
 * @template T - Tipo dos dados retornados
 * @param url - URL do endpoint
 * @returns Promise com ApiResponse<T>
 */

Evite complexidade excessiva: Generics muito aninhados ou tipos recursivos podem impactar performance e legibilidade.

8. Exemplo completo: integração com React Query

React Query (TanStack Query) se beneficia enormemente de generics:

import { useQuery, UseQueryOptions } from '@tanstack/react-query';

// Tipo para função de query genérica
type QueryFunction<T> = () => Promise<ApiResponse<T>>;

// Hook tipado para useQuery
function useApiQuery<T>(
  key: string[],
  queryFn: QueryFunction<T>,
  options?: Omit<UseQueryOptions<ApiResponse<T>, Error, T>, 'queryKey' | 'queryFn'>
) {
  return useQuery<ApiResponse<T>, Error, T>({
    queryKey: key,
    queryFn,
    select: (response) => response.data, // Extrai apenas os dados
    ...options
  });
}

// Componente usando o hook
function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading, error } = useApiQuery<User>(
    ['user', userId.toString()],
    () => fetchData<User>(`/api/users/${userId}`)
  );

  if (isLoading) return <div>Carregando...</div>;
  if (error) return <div>Erro: {error.message}</div>;

  return <div>Nome: {user?.name}</div>;
}

Benefícios dessa abordagem:
- Autocomplete completo nos dados retornados
- Refatoração segura quando a API muda
- Testes facilitados com mocks tipados
- Reutilização do mesmo padrão para diferentes endpoints

Com generics, você transforma chamadas de API de código frágil em componentes robustos e previsíveis, aproveitando todo o poder do sistema de tipos do TypeScript.

Referências