Buscar wrappers, formulários e UI


Cada tutorial do TypeScript apresenta genéricos com a função de identidade, mas os genéricos desbloqueiam abstrações reutilizáveis ​​e seguras de tipo na busca de dados, manipulação de formulários e composição de componentes de UI. Este guia cria três padrões prontos para produção e fornece uma tabela de decisão para escolher entre genéricos, uniões e inferência de esquema Zod.

Índice

Por que os genéricos são importantes além da função de identidade

Cada tutorial TypeScript apresenta genéricos com o mesmo exemplo:

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

Esta função de três linhas captura a sintaxe central dos genéricos TypeScript: um parâmetro de tipo que flui pela assinatura da função, garantindo que o tipo de retorno corresponda à entrada. O manual do TypeScript usa este exemplo exato como ponto de partida. A maioria dos tutoriais para aqui.

A função de identidade demonstra a mecânica, mas não revela nada sobre onde os genéricos resolvem problemas reais no código de produção. Os genéricos desbloqueiam abstrações reutilizáveis ​​e de tipo seguro na busca de dados, manipulação de formulários e composição de componentes de UI. Eles permitem que os desenvolvedores escrevam uma única função, gancho ou componente que funcione corretamente em dezenas de tipos concretos, sem sacrificar a capacidade do compilador de detectar erros.

Este guia cria três padrões prontos para produção: um wrapper de busca digitado com validação de tempo de execução Zod, um gancho de formulário genérico com tipos de campo inferidos por esquema e um componente React polimórfico que preserva adereços de elementos nativos. A implementação principal de cada padrão pode ser copiada e colada e projetada para uso imediato em bases de código reais; exemplos de uso requerem um componente de encapsulamento ou contexto de função. Uma tabela de decisão no final esclarece quando recorrer a uniões genéricas versus uniões discriminadas versus inferência de esquema Zod.

Pré-requisitos: TypeScript intermediário (familiaridade com aliases de tipo, interfaces e tipos de utilitários), React básico com ganchos e uma configuração de projeto padrão baseada em npm. O código neste artigo é direcionado a TypeScript ≥ 4.7, React ≥ 17 e Zod 3 ou 4.

Atualização rápida: funções genéricas, interfaces e restrições

Funções genéricas e parâmetros de tipo

Funções genéricas declaram um ou mais parâmetros de tipo entre colchetes antes da lista de parâmetros. Um único parâmetro cobre a maioria dos casos, enquanto vários parâmetros lidar com transformações onde os tipos de entrada e saída diferem. O TypeScript infere argumentos de tipo a partir do uso quando possível, portanto, os chamadores raramente precisam especificá-los explicitamente. Quando a inferência falha ou surge ambiguidade, argumentos de tipo explícitos (functionName(arg)) determine o tipo pretendido.

Interfaces genéricas e aliases de tipo

Por que parametrizar a forma de um objeto? Considere um ApiResponse interface que será recorrente ao longo deste artigo como o tipo de retorno padrão para utilitários de busca de dados:

type ErrorKind = "network" | "validation" | "json_parse" | "http" | "unknown";

interface ApiResponse<T> {
  data: T | null;
  error: string | null;
  errorKind: ErrorKind | null;
  status: number;
}

Essa interface captura quatro preocupações em um único formato reutilizável: a carga útil digitada, uma mensagem de erro, uma categoria de erro legível por máquina e o código de status HTTP. Toda função que retorna ApiResponse herda o mesmo contrato independentemente do que T resolve. O errorKind campo permite que os chamadores ramifiquem na categoria de erro sem corresponder à string error mensagem. Aliases de tipo funcionam de forma idêntica aqui (type ApiResponse = { ... }), embora as interfaces sejam convencionais para formatos de objetos que os autores esperam estender.

Restrições genéricas com extends

Parâmetros de tipo irrestrito aceitam qualquer coisa, o que limita quais operações o corpo da função pode executar. O extends palavra-chave restringe o conjunto de tipos aceitáveis:

function getProperty<T extends Record<string, unknown>, K extends keyof T>(obj: T, key: K): T(K) {
  return obj(key);
}

Aqui, T extends Record garante T é um tipo de objeto e K extends keyof T restringe o segundo argumento às chaves reais desse objeto. O tipo de retorno T(K) é um tipo de acesso indexado que resolve o tipo da propriedade específica. Este padrão de restrição sustenta o validador de formulário construído no Padrão 2.

Padrão 1: Wrapper de busca digitado com validação de tempo de execução

O problema com não digitado fetch

O fetch Retornos de API Promisee ligando .json() nessa resposta produz Promise. Que any type desativa totalmente o compilador. Os desenvolvedores geralmente lançam o resultado com as Tmas as asserções de tipo não fornecem garantia de tempo de execução. Se a API retornar uma carga com formato diferente do esperado, como um campo renomeado ou um valor nulo onde uma string foi assumida, a incompatibilidade aparecerá como um erro de tempo de execução longe da chamada de busca. A afirmação dizia ao compilador para confiar no desenvolvedor, e o desenvolvedor estava errado.

Em ambientes Node.js anteriores à v18, você deve instalar um polyfill como node-fetch. O RequestInit e Response os tipos serão diferentes do padrão do navegador em alguns aspectos; verifique as definições de tipo do polyfill.

Prédio typedFetch

Um wrapper de busca digitado resolve isso combinando genéricos com validação de esquema Zod. O parâmetro genérico T é inferido do esquema Zod via z.inferportanto, os chamadores nunca especificam o tipo manualmente. A função valida a resposta em tempo de execução e, se a validação for bem-sucedida, o wrapper retorna dados que transportam o tipo correto pelo restante do aplicativo.

O invólucro inclui um AbortControllertempo limite baseado em -para evitar promessas suspensas indefinidamente em conexões paralisadas. Os chamadores também podem passar seus próprios signal através de options para cancelamento manual.

import { z } from "zod";

type ErrorKind = "network" | "validation" | "json_parse" | "http" | "unknown";

interface ApiResponse<T> {
  data: T | null;
  error: string | null;
  errorKind: ErrorKind | null;
  status: number;
}

async function typedFetch<T>(
  url: string,
  schema: z.ZodType<T>,
  options?: RequestInit & { timeoutMs?: number }
): Promise<ApiResponse<T>> {
  const { timeoutMs = 10_000, ...fetchOptions } = options ?? {};
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...fetchOptions,
      signal: controller.signal,
    });

    if (!response.ok) {
      return {
        data: null,
        error: `HTTP error ${response.status}`,
        errorKind: "http",
        status: response.status,
      };
    }

    const contentType = response.headers.get("content-type");
    if (contentType !== null && !contentType.includes("application/json")) {
      return {
        data: null,
        error: "Non-JSON response",
        errorKind: "http",
        status: response.status,
      };
    }

    let json: unknown;
    try {
      json = await response.json();
    } catch {
      return {
        data: null,
        error: "Failed to parse response as JSON",
        errorKind: "json_parse",
        status: response.status,
      };
    }

    const parseResult = schema.safeParse(json);
    if (!parseResult.success) {
      const summary = parseResult.error.issues
        .map((e) => `${e.path.join(".")}: ${e.message}`)
        .join("; ");
      return {
        data: null,
        error: `Validation failed: ${summary}`,
        errorKind: "validation",
        status: response.status,
      };
    }

    return { data: parseResult.data, error: null, errorKind: null, status: response.status };
  } catch (err) {
    if (err instanceof Error && err.name === "AbortError") {
      return {
        data: null,
        error: `Request timed out after ${timeoutMs}ms`,
        errorKind: "network",
        status: 0,
      };
    }
    if (err instanceof Error) {
      return { data: null, error: err.message, errorKind: "network", status: 0 };
    }
    return { data: null, error: "Unknown error", errorKind: "unknown", status: 0 };
  } finally {
    clearTimeout(timeoutId);
  }
}


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

type User = z.infer<typeof UserSchema>;


const result = await typedFetch("/api/users/1", UserSchema);

if (result.data) {
  console.log(result.data.name); 
}


if (result.errorKind === "validation") {
  console.error("API returned unexpected shape:", result.error);
} else if (result.errorKind === "network") {
  console.error("Network failure:", result.error);
}

No site da chamada, o desenvolvedor passa apenas a URL e o esquema. O genérico T resolve automaticamente de z.ZodTypeo que significa que o parâmetro type nunca aparece na chamada do código. O ApiResponse interface da seção de atualização estrutura cada valor de retorno uniformemente.

Nota: o status campo em ApiResponse reflete o status HTTP quando um foi recebido, ou 0 quando a solicitação falhou no nível da rede (incluindo tempos limite) antes que uma resposta HTTP estivesse disponível. O errorKind campo distingue a categoria de falha sem exigir que os chamadores analisem o error corda.

Onde reside o limite de tipo

Os genéricos impõem contratos em tempo de compilação: eles garantem que o código que manipula a resposta trata data como o tipo correto. Zod impõe o contrato de tempo de execução: garante que os bytes reais da rede estejam em conformidade com o formato esperado. Estes são mecanismos complementares, não alternativos.

Os dados de qualquer fonte externa, seja uma API REST, um WebSocket ou um SDK de terceiros, ultrapassam um limite de confiança. Nessa borda, o aplicativo deve validar e analisar os dados recebidos. Uma vez validado, o resultado analisado carrega um tipo inferido por Zod que os genéricos se propagam para dentro do aplicativo. O código downstream do limite pode contar com o compilador sem verificações adicionais de tempo de execução.

Padrão 2: Validador de Formulário com Inferência de Esquema

Definindo um Gancho de Formulário Genérico

Um genérico useForm hook aceita um esquema Zod digitado para z.ZodTypegarantindo que o tipo de saída do esquema corresponda T e manter o estado, os manipuladores e a validação sincronizados. O parâmetro de tipo T flui por todas as partes da API do gancho: os valores atuais, o mapa de erros, o manipulador de alterações e o manipulador de envio.

Restringindo nomes de campos com keyof T

O handleChange função usa uma restrição genérica aninhada: (field: K, value: T(K)). Isso garante que o nome do campo seja uma chave real do tipo de formulário e que o valor corresponda ao tipo desse campo. Compare isso com a alternativa não digitada, (field: string, value: any)que aceita qualquer string como nome de campo e qualquer valor, independentemente do tipo. A versão digitada captura nomes de campos com erros ortográficos e tipos de valores incorretos em tempo de compilação.

import { useState, useCallback, useRef } from "react";
import { z } from "zod";

function useForm<T extends Record<string, unknown>>(
  schema: z.ZodType<T>,
  initialValues: T,
  onSuccess: (data: T) => void
) {
  const (values, setValues) = useState<T>(initialValues);
  const (errors, setErrors) = useState<Partial<Record<keyof T, string>>>({});
  const onSuccessRef = useRef(onSuccess);
  onSuccessRef.current = onSuccess;

  function handleChange<K extends keyof T>(field: K, value: T(K)) {
    setValues((prev) => ({ ...prev, (field): value }));
    setErrors((prev) => {
      const next = { ...prev };
      delete next(field);
      return next;
    });
  }

  const handleSubmit = useCallback(
    (e?: React.FormEvent) => {
      e?.preventDefault();
      const result = schema.safeParse(values);
      if (result.success) {
        setErrors({});
        onSuccessRef.current(result.data);
      } else {
        const fieldErrors: Partial<Record<keyof T, string>> = {};
        result.error.issues.forEach((err) => {
          const topLevelField = err.path(0);
          if (topLevelField !== undefined && typeof topLevelField === "string") {
            if (!fieldErrors(topLevelField as keyof T)) {
              fieldErrors(topLevelField as keyof T) =
                err.path.length > 1
                  ? `${err.path.slice(1).join(".")}: ${err.message}`
                  : err.message;
            }
          }
        });
        setErrors(fieldErrors);
      }
    },
    (schema, values)
  );

  return { values, errors, handleChange, handleSubmit };
}


const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

type LoginForm = z.infer<typeof LoginSchema>;

function LoginPage() {
  const { values, errors, handleChange, handleSubmit } = useForm<LoginForm>(
    LoginSchema,
    { email: "", password: "" },
    (data) => {
      
      
    }
  );

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={values.email}
        onChange={(e) => handleChange("email", e.target.value)}
      />
      {errors.email && <span>{errors.email}</span>}
      <input
        type="password"
        value={values.password}
        onChange={(e) => handleChange("password", e.target.value)}
      />
      {errors.password && <span>{errors.password}</span>}
      <button type="submit">Log in</button>
    </form>
  );
}

Passagem "emal" (escrito incorretamente) para handleChange desencadeia um erro em tempo de compilação. Passando um number onde um string é esperado que faça o mesmo. A restrição genérica transforma o manipulador de formulário em um contrato com verificação de tipo entre o esquema e a UI.

O onSuccess callback é passado como um parâmetro de gancho e armazenado em uma ref para que handleSubmit mantém uma identidade estável entre renderizações. Isso permite handleSubmit para ser embrulhado useCallback e passado diretamente para o formulário onSubmit sem causar novas renderizações desnecessárias.

Mapeamento de erros com tipos mapeados

O tipo de estado de erro, Partial>é um tipo mapeado derivado do genérico. Record cria um objeto com cada chave de T mapeado para string. Partial torna todas as propriedades opcionais, refletindo a realidade de que nem todos os campos estão em estado de erro simultaneamente. Quando o esquema é alterado, esse tipo é atualizado automaticamente, portanto você nunca sincroniza o tipo de erro manualmente.

Quando um erro de campo é eliminado por handleChangea chave é excluída do objeto de erros em vez de definida como undefined. Isso garante que as verificações usando "field" in errors ou Object.keys(errors) refletir com precisão quais campos atualmente apresentam erros.

Padrão 3: Adereços de componentes de reação polimórfica

O as Padrão de adereços

Os sistemas de design frequentemente precisam de um único componente, como



Source link