TS Generics: Flexibilidad estructural

6 de agosto de 2025

Person holding black paint brush while painting black text on white paper
Photo by Niketh Vellanki on Unsplash

Te apuesto a que conoces esta situación: “Tenemos el mismo componente de tabla en tres lugares diferentes del código, cada uno hace prácticamente lo mismo pero procesa datos ligeramente distintos”.

Este problema se repite constantemente. Los equipos tienen prisa, encuentran un componente similar a lo que necesitan, lo copian, lo modifican un poco, y listo. Problema resuelto… hasta que llega el momento de agregar una nueva feature o corregir un bug en todos ellos. Terminas haciendo un refactor que luego tienes que aplicar en todos los lugares, además de todas las pruebas que tenías que hacer y que tienes en todos lados. Tu tarea de 1 punto ahora se volvió en un 3.

Esto lo he estado viendo mucho últimamente en otros proyectos, sobre todo en los que he tenido que trabajar en Sistemas de Diseño o Sistemas de Componentes reutilizables para una organización.

#.El origen del problema

La historia siempre es la misma. Encuentras un componente que casi hace lo que necesitas. Tal vez espera una propiedad name pero tu objeto tiene fullName. O necesita un id (number) pero el tuyo es un string. O espera un boolean pero tu objeto tiene un string con el valor "true" o "false".

¿La solución rápida? “Lo copio y lo adapto”.

Y así terminas con:

  • Tres versiones del mismo componente tabla
  • Props tipadas con any porque “es más flexible”
  • Ese componente de modal que nadie sabe cuál es el “oficial” (En Slack: “muchachos, cuál de los AutoComplete uso?“)

La ironía es que TypeScript nos da una herramienta perfecta para esto, pero muchos la ignoran: los generics.

#.Ejemplo real: Un Autocomplete para gobernarlos a todos

En lugar de crear tres componentes separados, podríamos haber tenido uno solo (historia real):

Lo que teníamos (❌ Múltiples componentes)

// CustomerAutocomplete.tsx
interface CustomerAutocompleteProps {
  customers: Customer[];
  onSelect: (customer: Customer) => void;
}

// ProductAutocomplete.tsx
interface ProductAutocompleteProps {
  products: Product[];
  onSelect: (product: Product) => void;
}

// LocationAutocomplete.tsx... y la lista sigue

Lo que deberíamos tener (✅ Un componente genérico)

interface AutocompleteProps<T> {
  items: T[];
  onSelect: (item: T) => void;
  getLabel: (item: T) => string;
  placeholder?: string;
}

function Autocomplete<T>({
  items,
  onSelect,
  getLabel,
  placeholder = "Search..."
}: AutocompleteProps<T>) {
  const [search, setSearch] = useState("");
  const [isOpen, setIsOpen] = useState(false);

  const filtered = items.filter(item =>
    getLabel(item).toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div className="autocomplete">
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        onFocus={() => setIsOpen(true)}
        placeholder={placeholder}
      />
      {isOpen && filtered.length > 0 && (
        <ul>
          {filtered.map((item, i) => (
            <li
              key={i}
              onClick={() => {
                onSelect(item);
                setSearch(getLabel(item));
                setIsOpen(false);
              }}
            >
              {getLabel(item)}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

#.La magia está en cómo lo usas

Aquí es donde brilla la sintaxis <T>. Mira qué limpio queda:

// Define tus tipos
interface Customer {
  id: number;
  fullName: string;
  email: string;
}

interface Product {
  sku: string;
  name: string;
  price: number;
}

interface Location {
  code: string;
  city: string;
  country: string;
}

// Usa el componente con type safety completo
function MyComponent() {
  return (
    <>
      {/* TypeScript infiere el tipo Customer */}
      <Autocomplete<Customer>
        items={customers}
        onSelect={(customer) => {
          // customer es tipo Customer, no any!
          console.log(customer.email);
        }}
        getLabel={(customer) => customer.fullName}
      />

      {/* TypeScript infiere el tipo Product */}
      <Autocomplete<Product>
        items={products}
        onSelect={(product) => {
          // product es tipo Product
          console.log(product.price);
        }}
        getLabel={(product) => `${product.name} - $${product.price}`}
      />

      {/* TypeScript infiere el tipo Location */}
      <Autocomplete<Location>
        items={locations}
        onSelect={(location) => {
          // location es tipo Location
          console.log(location.code);
        }}
        getLabel={(location) => `${location.city}, ${location.country}`}
      />
    </>
  );
}

¿Ves la diferencia? Un solo componente, type safety completo, y TypeScript te ayuda con el autocomplete (el del IDE, no el componente 😄).

#.Ejemplo 2: Tabla de datos genérica

Otro patrón súper común:

interface Column<T> {
  key: keyof T;
  header: string;
  render?: (value: T[keyof T], item: T) => React.ReactNode;
}

interface DataTableProps<T> {
  data: T[];
  columns: Column<T>[];
  onRowClick?: (item: T) => void;
}

function DataTable<T>({
  data,
  columns,
  onRowClick
}: DataTableProps<T>) {
  return (
    <table>
      <thead>
        <tr>
          {columns.map(col => (
            <th key={String(col.key)}>{col.header}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, i) => (
          <tr
            key={i}
            onClick={() => onRowClick?.(item)}
            style={{ cursor: onRowClick ? 'pointer' : 'default' }}
          >
            {columns.map(col => (
              <td key={String(col.key)}>
                {col.render
                  ? col.render(item[col.key], item)
                  : String(item[col.key])
                }
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Y ahora el uso con esa sintaxis clara de generics:

// Para una tabla de usuarios
<DataTable<User>
  data={users}
  columns={[
    { key: 'name', header: 'Name' },
    { key: 'email', header: 'Email' },
    {
      key: 'role',
      header: 'Role',
      render: (role) => <Badge>{role}</Badge>
    }
  ]}
  onRowClick={(user) => navigate(`/users/${user.id}`)}
/>

// Para una tabla de órdenes
<DataTable<Order>
  data={orders}
  columns={[
    { key: 'id', header: 'Order #' },
    {
      key: 'total',
      header: 'Total',
      render: (total) => `$${total.toFixed(2)}`
    },
    {
      key: 'status',
      header: 'Status',
      render: (status) => (
        <StatusIndicator status={status} />
      )
    }
  ]}
/>

#.Patrones avanzados que uso todo el tiempo

#.1. Constraints con extends

Cuando necesitas que tu tipo tenga ciertas propiedades:

interface HasId {
  id: string | number;
}

// Este componente solo acepta items que tengan un id
function SelectableList<T extends HasId>({
  items,
  selectedId,
  onSelect
}: {
  items: T[];
  selectedId: T['id'];
  onSelect: (item: T) => void;
}) {
  return (
    <ul>
      {items.map(item => (
        <li
          key={item.id}
          className={item.id === selectedId ? 'selected' : ''}
          onClick={() => onSelect(item)}
        >
          {/* Render item */}
        </li>
      ))}
    </ul>
  );
}

// Uso
<SelectableList<User>
  items={users}
  selectedId={currentUserId}
  onSelect={setCurrentUser}
/>

#.2. Múltiples generics relacionados

Para casos más complejos:

interface FormFieldProps<TValue, TError = string> {
  value: TValue;
  onChange: (value: TValue) => void;
  error?: TError;
  validate?: (value: TValue) => TError | undefined;
}

// Campo de texto normal
<FormField<string>
  value={email}
  onChange={setEmail}
  validate={(val) => !val.includes('@') ? 'Invalid email' : undefined}
/>

// Campo numérico con errores custom
<FormField<number, { code: string; message: string }>
  value={age}
  onChange={setAge}
  validate={(val) =>
    val < 18
      ? { code: 'TOO_YOUNG', message: 'Must be 18+' }
      : undefined
  }
/>

#.3. Inferencia automática

A veces ni siquiera necesitas especificar el tipo (esto es útil para cuando estás trabajando con TanStack Query, por ejemplo):

function useFilter<T>(
  items: T[],
  predicate: (item: T) => boolean
): T[] {
  return items.filter(predicate);
}

// TypeScript infiere que T es User
const activeUsers = useFilter(
  users,
  user => user.isActive // user es tipo User automáticamente!
);

// TypeScript infiere que T es Product
const expensiveProducts = useFilter(
  products,
  product => product.price > 100 // product es tipo Product
);

#.Tips para que no seas como yo

#.1. No todo necesita ser genérico

Si solo lo vas a usar con un tipo, no lo hagas genérico. Es tentador, pero agrega complejidad innecesaria (YAGNI).

// ❌ Demasiado genérico
function UserAvatar<T extends { avatar?: string; name: string }>({ user }: { user: T }) {
  // ...
}

// ✅ Simple y claro
function UserAvatar({ user }: { user: User }) {
  // ...
}

#.2. Nombres descriptivos para generics complejos

Cuando tienes múltiples generics, usa nombres descriptivos:

// ❌ Confuso
function transform<T, U, V>(
  data: T[],
  mapper: (item: T) => U,
  filter: (item: U) => V
): V[]

// ✅ Claro
function transform<TInput, TMapped, TFiltered>(
  data: TInput[],
  mapper: (item: TInput) => TMapped,
  filter: (item: TMapped) => TFiltered
): TFiltered[]

#.3. Documenta con ejemplos

Un buen ejemplo vale más que mil palabras:

/**
 * Generic select component with search functionality
 *
 * @example
 * <SearchableSelect<User>
 *   options={users}
 *   getLabel={user => user.name}
 *   getValue={user => user.id}
 *   onSelect={handleUserSelect}
 * />
 */
function SearchableSelect<T>({ /* ... */ }) {
  // ...
}

También es útil, si estás utilizando Storybook, crear un componente de ejemplo que muestre el uso del componente genérico.

#.Para empezar mañana

  1. Busca duplicados: Abre tu editor y busca componentes con nombres como UserTable, ProductTable, OrderTable.
  2. Empieza simple: Convierte uno a genérico. No intentes hacer el componente perfecto.
  3. Usa la sintaxis explícita: Al principio, siempre especifica el tipo: <Component<Type>>. Es más claro.
  4. Comparte conocimiento: Haz un PR con un componente genérico y explica los beneficios en la descripción.

La próxima vez que estés a punto de copiar ese componente “porque solo necesito cambiar esta cosita”, piensa si puedes usar un <T>.

Cuando veas <DataTable<Customer>> en lugar de CustomerDataTable, <DataTable<Product>> en lugar de ProductDataTable, y todo con type safety completo… no hay vuelta atrás.

#.Referencias