TypeScript permite definir tipos genéricos usando una sintaxis con <T>
, lo que funciona como una plantilla de tipo. Esto significa que podemos escribir funciones o componentes que no especifiquen de antemano un tipo fijo, sino que se adapten al tipo real de datos en cada uso. Por ejemplo, la función genérica function identidad<T>(valor: T): T { return valor; }
devuelve lo que recibe, y el compilador infiere el tipo concreto en cada llamada. En palabras de GeeksforGeeks, “los genéricos en TypeScript permiten crear componentes o funciones dinámicos y reutilizables que pueden manejar distintos tipos de datos manteniendo una estricta seguridad de tipos”. Es decir, los genéricos mantienen la seguridad del tipado (evitan errores de tipo en tiempo de ejecución) y al mismo tiempo aumentan la flexibilidad del código. Los genéricos no existen en JavaScript final (solo en tiempo de desarrollo), pero sirven para que el editor y el compilador de TypeScript validen que nuestro código sea correcto. Al definir un tipo genérico T
, podemos referirnos a él como si fuera un parámetro:
function mostrarTipo<T>(dato: T): void {
console.log(typeof dato);
}
Aquí <T>
permite que la función acepte cualquier tipo y que TypeScript infiera el tipo de dato en cada llamada. En resumen, el tipado genérico es una forma de escribir código adaptable a múltiples tipos sin perder la seguridad estática de TypeScript.
¿Por qué es útil en componentes reutilizables como tablas?
Los componentes genéricos evitan duplicar código y mejoran la mantenibilidad. Por ejemplo, en vez de crear un componente UserTable
, otro ProductTable
, etc., podemos tener un único TableComponent<T>
que trabaje con cualquier tipo de datos. Esto tiene varias ventajas:
- Reusabilidad: Al adaptar un componente genérico a distintos tipos, podemos usar la misma tabla con diferentes datos sin repetir código.
- Mantenibilidad: Si cambia la definición de un tipo (por ejemplo, se agrega una propiedad al tipo User), no hay que modificar la lógica de la tabla; sólo se actualizan los tipos usados.
- Seguridad de tipo: TypeScript validará que las propiedades usadas existan en los datos; esto evita errores típicos en tiempo de ejecución.
-
Flexibilidad: Podemos reutilizar el mismo componente en contextos distintos (por ejemplo,
Product
uOrder
) sin tocar su implementación.
Como destaca el documento original, “el enfoque genérico evita duplicar código… mantiene el componente reutilizable y extensible”. En el contexto de tablas, esto significa que diseñamos una vez el componente de tabla y luego lo adaptamos a cualquier estructura de datos, en lugar de crear versiones separadas para cada entidad. Esto reduce la carga de trabajo y el riesgo de inconsistencias entre tablas.
Implementando un componente de tabla genérica en React
Para crear una tabla genérica, definimos primero la estructura de props usando un tipo genérico T
. Por convención, T
representará el tipo de cada objeto de datos que muestra la tabla (por ejemplo, User
, Product
, etc.). Un ejemplo de interfaz sería:
interface TableProps<T> {
columns: TableColumn<T>[];
data: T[];
}
export const TableComponent = <T extends object>({
columns,
data,
}: TableProps<T>) => {
return (
<TableContainer component={Paper}>
{/* Cabecera de la tabla */}
<Table sx={{ minWidth: 650 }} size="small" aria-label="Tabla genérica">
<TableHead>
<TableRow>
{columns.map(({ key, align, title }) => (
<TableCell key={String(key)} align={align}>
{title}
</TableCell>
))}
</TableRow>
</TableHead>
{/* Cuerpo de la tabla */}
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
{columns.map((col) =>
col.type === "actions" ? (
<TableCell align={col.align} key={String(col.key)}>
{col.render(row)} {/* Columna de acciones */}
</TableCell>
) : (
<TableCell align={col.align} key={String(col.key)}>
{col.render!(row[col.key as keyof T], row)} {/* Columna de datos */}
</TableCell>
)
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
Figura: Ejemplo de salida de un componente de tabla genérica renderizando varios elementos. Cada fila proviene de un elemento en data
, y cada columna se corresponde con una definición en columns
.
En este código usamos <T extends object>
para indicar que T
puede ser cualquier tipo de objeto. Así, TableComponent
no está ligado a un tipo específico, sino que acepta datos de cualquier forma. La interfaz TableProps<T>
especifica que columns
es un arreglo de TableColumn<T>
(ver más abajo) y que data es un arreglo de T
. Al iterar sobre data.map((row) => ...)
, cada row
es de tipo T
, y las columnas usan la propiedad genérica col.key para acceder a row[col.key]
. Gracias a este enlace genérico, TypeScript verifica que col.key
exista en T
y que el valor row[col.key]
tenga el tipo adecuado. Si, por ejemplo, usamos <TableComponent<User> ...>
, el compilador exige que data sea User[]
y que las claves en las columnas realmente pertenezcan a User
. Por ejemplo, supongamos:
type User = { id: number; name: string; email: string };
const userColumns: TableColumn<User>[] = [
{ title: "ID", key: "id", render: (value) => value },
{ title: "Nombre",key: "name", render: (value) => value },
{
title: "Acciones",
key: "actions",
type: "actions",
render: (user) => <button onClick={() => alert(user.email)}>Ver</button>
},
];
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
// Uso del componente genérico:
<TableComponent<User>
columns={userColumns} // Debe ser TableColumn<User>[]
data={users} // Debe ser User[]
/>;
Aquí <TableComponent<User> ... />
fuerza que userColumns
y users
cumplan con los tipos esperados para User
. Si, por ejemplo, intentamos usar una clave que no existe en User
, TypeScript arrojará un error en tiempo de compilación. En cambio, con un enfoque no genérico ese error solo se detectaría en ejecución.
Tipos BaseColumn, DataColumn, ActionColumn y TableColumn
Para definir las columnas de forma genérica, podemos usar los siguientes tipos:
- BaseColumn<T, K>
type BaseColumn<T, K extends keyof T | "actions" = keyof T | "actions"> = {
title: string;
align?: "left" | "center" | "right";
key: K;
};
BaseColumn
define las propiedades comunes a toda columna: un title
(etiqueta) y opcionalmente un align
. El tipo genérico T
representa el objeto de datos (p.ej. User
), y K
es el tipo de la clave usada en la columna. La restricción K extends keyof T | "actions"
indica que key
puede ser cualquier propiedad de T
o la literal especial "actions"
. De esta forma aseguramos que key
siempre sea válido. El enfoque centraliza los campos comunes y “obliga que key
sea válido para el tipo de dato”, lo que facilita reutilizar la definición de columnas para distintos tipos de datos.
- DataColumn<T, K>
type DataColumn<T, K extends keyof T = keyof T> = BaseColumn<T, K> & {
type?: "data";
render: (value: T[K], row: T) => React.ReactNode;
};
DataColumn
extiende BaseColumn
para columnas de datos normales. Aquí K
está restringido solo a las claves de T
(no incluye "actions"
). Se añade una propiedad opcional type: "data"
(usada como discriminador) y una función render que recibe el value correspondiente a row[K]
y la fila completa row
de tipo T
. Esto significa que, por ejemplo, si K
es "name"
y T
es User
, entonces value: string
. Gracias a esta definición, TypeScript garantiza que el valor pasado coincida con el tipo real de la propiedad. También logra que el editor sugiera las claves válidas al escribir la columna, reforzando la ayuda de autocompletado.
- ActionColumn<T>
export type TableColumn<T> = DataColumn<T> | ActionColumn<T>;
TableColumn
es simplemente la unión de ambos casos. Esto significa que el arreglo columns: TableColumn<T>[]
puede contener tanto DataColumn<T>
como ActionColumn<T>
. TypeScript distinguirá el tipo concreto de cada columna usando la propiedad type
. De este modo se tiene flexibilidad para mezclar columnas de datos y acciones manteniendo el tipado seguro.
Ejemplo claro con un tipo User
Para ilustrar con un ejemplo completo, definamos el tipo de datos User
y veamos cómo usarlo:
type 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" },
];
const userColumns: TableColumn<User>[] = [
{ title: "ID", key: "id", render: (value) => value.toString() },
{ title: "Nombre", key: "name", render: (value) => value },
{
title: "Acciones",
key: "actions",
type: "actions",
render: (user) => <button onClick={() => alert(user.email)}>Ver</button>
},
];
// Uso del componente genérico:
<TableComponent<User>
columns={userColumns} // TypeScript exige TableColumn<User>[]
data={users} // TypeScript exige User[]
/>;
En este ejemplo, al usar <TableComponent<User>>
, se vincula el tipo T = User
. Por ello, las claves id
y name
provienen de User
, y el arreglo users
debe ser User[]
. TypeScript verificará todo esto: por ejemplo, si escribimos key: "emailAddress"
(propiedad que no existe en User
), el compilador marcará un error. De este modo la tabla genérica se adapta automáticamente al tipo User
sin necesidad de código adicional (no se creó un UserTable
independiente).
Comparativa: enfoque genérico vs específico
Para entender mejor la ventaja, comparemos brevemente el enfoque genérico con uno específico para cada modelo (p.ej. un componente UserTable
dedicado):
-
Nueva entidad (p.ej.
Post
): con el componente genérico<TableComponent<Post>>
podemos usar la tabla inmediatamente pasando datos dePost
. En cambio, con el enfoque específico tendríamos que crear un nuevo componentePostTable.
-
Cambio en la API de datos: en el enfoque genérico, si modificamos la interfaz de
Post
oUser
, TypeScript mostrará errores en los lugares donde usamos las propiedades antiguas. En el enfoque específico esos errores solo se descubrirían en tiempo de ejecución, ya que cada tabla maneja sus datos “por separado”. - Consistencia de la UI: al usar un solo componente genérico aseguramos estilos y comportamientos idénticos entre todas las tablas. Con tablas específicas existe el riesgo de divergencia (cada desarrollador podría implementar la tabla de forma diferente).
-
Complejidad inicial: el componente genérico requiere diseñar correctamente los tipos (
<T>
, las columnas, etc.), por lo que es más complejo de entender al principio. Sin embargo, este esfuerzo inicial paga dividendos cuando crecen las necesidades del proyecto. El enfoque específico es más simple al inicio, pero se vuelve más difícil de mantener cuando hay muchas entidades y tablas.
En resumen, el enfoque genérico invierte algo más de esfuerzo en el diseño de tipos, pero ofrece mayor escalabilidad y seguridad a largo plazo. Por ejemplo, para una nueva entidad solo hay que definir los datos y columnas, sin tocar el componente de tabla en sí. Esta comparación resalta la gran ventaja de los genéricos: facilitan la extensibilidad y consistencia en aplicaciones con muchas tablas.
Beneficios tangibles: seguridad de tipos, mantenimiento y autocompletado
El uso de tipos genéricos trae beneficios concretos en el día a día del desarrollo:
- Seguridad de tipos: TypeScript valida que cada valor y clave concuerde con el tipo declarado. Esto previene bugs comunes (por ejemplo, acceder a una propiedad que no existe) atrapándolos en compilación.
-
Autocompletado y documentación: como los tipos están explícitos, el editor (VSCode u otro) ofrece autocompletado inteligente. Al escribir
key:
en la definición de columnas, el IDE sugerirá sólo las propiedades válidas del tipoT
. En general, los tipos sirven como documentación clara de qué datos y propiedades se manejan, reduciendo la curva de aprendizaje para otros desarrolladores. - Menor duplicación y mejor mantenimiento: al centralizar la lógica en un componente genérico, cualquier mejora o corrección se aplica a todas las tablas a la vez. Esto hace el código más mantenible y evita código repetido. Si necesitamos cambiar el estilo de la tabla, la lógica de filtrado o cualquier detalle, lo hacemos en un lugar en lugar de en 10 componentes distintos.
Estas ventajas prácticas se traducen en mayor confianza al escribir y refactorizar código. Como dice el documento original, dominar estos patrones genéricos significa tener un “sistema de tipos como documentación” y “prevención proactiva de errores” en el desarrollo.
Conclusión
El tipado genérico en TypeScript es una herramienta poderosa para crear componentes React reutilizables como tablas de datos. Aunque introduce conceptos avanzados, permite escribir un código más flexible, seguro y limpio. Al entender cómo definir tipos como BaseColumn<T>
, DataColumn<T>
, ActionColumn<T>
y TableColumn<T>
y cómo usarlos en un componente <TableComponent<T>>
, podemos construir tablas que acepten cualquier tipo de dato sin duplicar esfuerzo. En definitiva, el componente genérico de tabla demuestra que genéricos y flexibilidad pueden ir de la mano sin sacrificar seguridad de tipos. Como concluye la fuente original, al dominar estos patrones “los desarrolladores pueden construir componentes poderosamente flexibles y rigurosamente seguros”. Para un principiante, este es un excelente ejemplo de cómo TypeScript ayuda a escribir código de alta calidad. ¡Con práctica y paciencia, estos conceptos potenciarán tus aplicaciones de manera significativa y eleva tu nivel como desarrollador frontend!
Repositorio de ejemplo: Codesandbox
Top comments (0)