TypeScript Generics: Structural Flexibility

In medium and large organizations, effective component reuse can make the difference between teams that move quickly and teams that constantly reinvent the wheel.

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

I bet you know this situation: “We have the same table component in three different places in the code, each doing pretty much the same thing but processing slightly different data.”

This problem is constantly repeated. Teams are in a hurry, find a component similar to what they need, copy it, modify it a little, and that’s it. Problem solved… until it’s time to add a new feature or fix a bug in all of them. You end up doing a refactor that you then have to apply everywhere, in addition to all the tests that you had to do and that you have everywhere. Your 1 point assignment has now become a 3.

I have been seeing this a lot lately in other projects, especially in those where I have had to work on Design Systems or Reusable Component Systems for an organization.

#.The origin of the problem

The story is always the same. You find a component that almost does what you need. Maybe you expect a name property but your object has fullName. Or it needs a id (number) but yours is a string. Or it expects a boolean but your object has a string with the value "true" or "false".

The quick fix? “I copy it and adapt it.”

And so you end up with:

  • Three versions of the same table component
  • Props typed with any because “it’s more flexible”
  • That modal component that no one knows which one is the “official” one (On Slack: “guys, which AutoComplete do I use?“)

The irony is that TypeScript gives us a perfect tool for this, but many ignore it: generics.

#.Real Example: One Autocomplete to Rule Them All

Instead of creating three separate components, we could have had just one (true story):

What we had (❌ Multiple components)

// 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

What we should have (✅ A generic component)

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>
  );
}

##The magic is in how you use it

This is where the <T> syntax shines. Look how clean it is:

// 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}`}
      />
    </>
  );
}

Do you see the difference? A single component, type safety complete, and TypeScript helps you with autocomplete (the one from the IDE, not the component 😄).

#.Example 2: Generic data table

Another super common pattern:

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>
  );
}

And now use it with that clear generics syntax:

// 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} />
      )
    }
  ]}
/>

#.Advanced patterns that I use all the time

#.1. Constraints with extends

When you need your type to have certain properties:```typescript interface HasId { id: string | number; }

// Este componente solo acepta items que tengan un id function SelectableList({ items, selectedId, onSelect }: { items: T[]; selectedId: T[‘id’]; onSelect: (item: T) => void; }) { return (

    {items.map(item => ( <li key={item.id} className={item.id === selectedId ? ‘selected’ : ”} onClick={() => onSelect(item)} > {/* Render item */} ))}
); }

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


### 2. Multiple related generics

For more complex cases:

```typescript
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. Automatic inference

Sometimes you don’t even need to specify the type (this is useful for when you are working with TanStack Query, for example):

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 so that you are not like me

#.1. Not everything needs to be generic

If you’re only going to use it with one type, don’t make it generic. It’s tempting, but it adds unnecessary complexity (YAGNI).

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

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

#.2. Friendly names for complex generics

When you have multiple generics, use descriptive names:

// ❌ 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. Document with examples

A good example is worth a thousand words:

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

It is also useful, if you are using Storybook, to create an example component that shows the use of the generic component.

#.To start tomorrow

  1. Check for duplicates: Open your editor and look for components with names like UserTable, ProductTable, OrderTable.
  2. Start simple: Convert one to generic. Don’t try to make the component perfect.
  3. Use explicit syntax: At the beginning, always specify the type: <Component<Type>>. It’s clearer.
  4. Share knowledge: Make a PR with a generic component and explain the benefits in the description.

The next time you’re about to copy that component “because I just need to change this little thing”, think about whether you can use a <T>.

When you see <DataTable<Customer>> instead of CustomerDataTable, <DataTable<Product>> instead of ProductDataTable, and all with full type safety… there’s no turning back.

#.References