Every time I've had to render a table, select dropdown, or list in production, I've wrestled with the same problem: how do I make the component flexible enough for any data shape without losing type safety? Generic components in TypeScript solve this by letting you define once and use everywhere, with full autocomplete and zero any types. Today we're building a Table<T> that genuinely knows your row type, and the pattern extends to selects, lists, and every other data-driven component you'll ever need.
Why generic components matter in real applications
I started building production platforms where the same UI pattern appeared across multiple domains. We had user tables, product tables, order tables, transaction tables. Each had different columns, different row shapes, different validation rules. Writing a separate UserTable, ProductTable, and OrderTable component meant tripling maintenance burden and guaranteeing inconsistency creep.
Generic components let you codify the pattern once. A table is fundamentally a function of rows and column metadata. The fact that those rows are users, products, or orders is just a type parameter. When you embrace this, you get:
- A single source of truth for table behavior
- Autocomplete on properties that actually exist in your data
- Compile-time errors when you reference a column that doesn't exist
- The ability to share complex logic (sorting, filtering, pagination) across all consuming components
I'll show you exactly how to build this, because the pattern is the same whether you're in a trading platform rendering positions, a CRM rendering leads, or a travel app rendering bookings.
Infer T from the data prop
The foundation is inference. TypeScript can look at the data you pass in and work backwards to figure out your type. You don't have to write <Table<User>> every time—TypeScript will infer it from the rows array.
interface TableProps<T> {
rows: T[];
columns: { key: keyof T; label: string }[];
}
export function Table<T>({ rows, columns }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td key={String(col.key)}>
{String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}When you call this:
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
<Table
rows={users}
columns={[
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "email", label: "Email" },
]}
/>TypeScript infers T = User from the rows array. No generic syntax needed in the JSX. This is the power of inference—less boilerplate, same safety.
Column definitions typed against T
The real magic happens in the columns array. The type constraint keyof T means you can only reference properties that actually exist on T. If someone tries to pass { key: "age", label: "Age" } when the User interface doesn't have an age property, TypeScript catches it immediately.
// This is fine
{ key: "name", label: "Name" }
// This throws a compile error
{ key: "nonexistent", label: "Oops" }
// Type '"nonexistent"' is not assignable to type 'keyof User'But inference alone isn't enough for real applications. You often need to customize how data renders. Maybe you want to format dates, truncate long strings, or render a status badge. You can extend the column definition to include a render function:
interface ColumnDef<T, K extends keyof T> {
key: K;
label: string;
render?: (value: T[K], row: T) => React.ReactNode;
}
interface TableProps<T> {
rows: T[];
columns: ColumnDef<T, keyof T>[];
}
export function Table<T>({ rows, columns }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}Now you can customize rendering per column while staying type-safe:
interface Order {
id: number;
total: number;
createdAt: Date;
status: "pending" | "completed" | "cancelled";
}
<Table
rows={orders}
columns={[
{ key: "id", label: "Order ID" },
{
key: "total",
label: "Total",
render: (value) => `$${value.toFixed(2)}`,
},
{
key: "createdAt",
label: "Date",
render: (value) => value.toLocaleDateString(),
},
{
key: "status",
label: "Status",
render: (value) => <StatusBadge status={value} />,
},
]}
/>The render function receives both the cell value T[K] and the full row T. This is crucial because sometimes you need context from other columns—like conditionally styling based on another field.
No any, full autocomplete
This is where it clicks. Because T is properly constrained throughout, your editor knows exactly what you're working with. When you hover over a column definition or access row[col.key], the type is known and concrete.
| Without generics | With generics |
|---|---|
row[key] as any or row[key] (unknown type) |
row[col.key] (exactly string | number | Date, depending on the column) |
| No autocomplete on column names | Autocomplete shows only valid properties |
| Runtime errors when you typo a column name | Compile-time error |
| Refactoring a property requires grep and hope | TypeScript finds every reference |
I've built this pattern into a CRM where we had Leads, Accounts, and Contacts. Each has dozens of fields. The generic Table meant that when I renamed a field on the Lead interface, the table columns threw a compile error. I fixed it in seconds. Without generics, I'd have found that bug in production after a customer reported a missing column.
Great for tables, selects and lists
The pattern extends beyond tables. Any component that renders a collection with metadata can use generics.
A typed Select
interface SelectProps<T> {
items: T[];
getLabel: (item: T) => string;
getValue: (item: T) => string | number;
onSelect: (item: T) => void;
}
export function Select<T>({
items,
getLabel,
getValue,
onSelect,
}: SelectProps<T>) {
return (
<select onChange={(e) => {
const item = items.find(
(i) => String(getValue(i)) === e.target.value
);
if (item) onSelect(item);
}}>
{items.map((item) => (
<option key={String(getValue(item))} value={String(getValue(item))}>
{getLabel(item)}
</option>
))}
</select>
);
}Usage:
interface Region {
id: string;
name: string;
}
const regions: Region[] = [
{ id: "us-east", name: "US East" },
{ id: "eu-west", name: "EU West" },
];
<Select
items={regions}
getLabel={(region) => region.name}
getValue={(region) => region.id}
onSelect={(selected) => setSelectedRegion(selected)}
/>A typed List with actions
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
onDelete?: (item: T) => void;
onEdit?: (item: T) => void;
}
export function List<T extends { id: string | number }>({
items,
renderItem,
onDelete,
onEdit,
}: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{renderItem(item)}
{onEdit && (
<button onClick={() => onEdit(item)}>Edit</button>
)}
{onDelete && (
<button onClick={() => onDelete(item)}>Delete</button>
)}
</li>
))}
</ul>
);
}Notice the constraint T extends { id: string | number }. This ensures that every item has an id, which is essential for React keys and identifying which item was clicked. If you try to use it with a type that lacks an id, TypeScript catches it.
I've used this exact pattern across multiple products. In a ride-hailing app, it rendered drivers and trips. In a travel platform, it rendered bookings and itineraries. The component stayed identical; only the type changed.
Common mistakes
Using any to "fix" type errors. If TypeScript complains about a type mismatch in your columns array, don't cast to any. That defeats the entire purpose. Fix the underlying type. If you need flexibility, use a union or a constraint.
Over-constraining the generic. Don't write T extends object unless you have a specific reason. Start broad, add constraints only when necessary. If your table needs to access id for keys, that's when you add T extends { id: unknown }.
Forgetting to constrain the column key. The constraint key: keyof T is what makes autocomplete work. Without it, the column definition accepts any string, and you lose type safety entirely.
Making the render function too rigid. If you write render: (value: string) => ..., you've hardcoded the value type. Make it render: (value: T[K]) => ... so it adapts to whatever type that column actually holds.
Key takeaways
- Generic components in TypeScript let you define flexible, reusable patterns while maintaining full type safety across your entire codebase.
- Inference means you don't write
<Table<User>>in JSX—TypeScript figures out the type from your data, reducing boilerplate. - Column definitions constrained with
keyof Tensure you can only reference properties that actually exist, catching mistakes at compile time. - The render function pattern gives you flexibility to customize how any column displays without breaking type safety.
- This pattern scales beautifully to selects, lists, dropdowns, and any collection-based component—I've deployed it in production across CRMs, e-commerce, trading platforms, and travel apps.
- Always prefer proper constraints over
any. The tiny overhead of typing pays enormous dividends in maintainability and refactoring safety.






Comments
Loading comments…