TypeScript

TypeScript

Tutorial TypeScript: Genéricos con clases e interfaces

Aprende a usar genéricos con clases e interfaces en TypeScript para crear código flexible y seguro con ejemplos prácticos y avanzados.

Aprende TypeScript y certifícate

Clases genéricas

Las clases genéricas en TypeScript permiten crear componentes reutilizables que pueden trabajar con diferentes tipos de datos, manteniendo la seguridad de tipos. Esta característica es fundamental para escribir código flexible y reutilizable sin sacrificar la verificación de tipos en tiempo de compilación.

Una clase genérica se define utilizando el parámetro de tipo entre símbolos de menor y mayor <T>, donde T es un nombre de tipo que actúa como un marcador de posición. Este marcador será reemplazado por un tipo real cuando se instancie la clase.

Sintaxis básica

La sintaxis para definir una clase genérica es la siguiente:

class Contenedor<T> {
  private valor: T;

  constructor(valor: T) {
    this.valor = valor;
  }

  obtenerValor(): T {
    return this.valor;
  }

  establecerValor(nuevoValor: T): void {
    this.valor = nuevoValor;
  }
}

En este ejemplo, T es un parámetro de tipo que puede ser reemplazado por cualquier tipo cuando se crea una instancia de la clase Contenedor. Esto permite crear contenedores para diferentes tipos de datos:

// Contenedor para números
const contenedorNumero = new Contenedor<number>(123);
const numero = contenedorNumero.obtenerValor(); // tipo: number

// Contenedor para cadenas
const contenedorTexto = new Contenedor<string>("Hola mundo");
const texto = contenedorTexto.obtenerValor(); // tipo: string

// Contenedor para objetos personalizados
interface Usuario {
  id: number;
  nombre: string;
}

const usuario: Usuario = { id: 1, nombre: "Ana" };
const contenedorUsuario = new Contenedor<Usuario>(usuario);
const usuarioRecuperado = contenedorUsuario.obtenerValor(); // tipo: Usuario

Múltiples parámetros de tipo

Las clases genéricas pueden utilizar múltiples parámetros de tipo cuando necesitamos trabajar con diferentes tipos en la misma clase:

class Par<K, V> {
  private clave: K;
  private valor: V;

  constructor(clave: K, valor: V) {
    this.clave = clave;
    this.valor = valor;
  }

  getClave(): K {
    return this.clave;
  }

  getValor(): V {
    return this.valor;
  }
}

// Uso con diferentes combinaciones de tipos
const parStringNumber = new Par<string, number>("edad", 30);
const parNumberBoolean = new Par<number, boolean>(1, true);

Esta flexibilidad permite crear estructuras de datos complejas que mantienen la seguridad de tipos para diferentes combinaciones de datos.

Restricciones en tipos genéricos

A veces necesitamos limitar los tipos que pueden utilizarse como argumentos genéricos. Para esto, utilizamos la palabra clave extends para establecer restricciones sobre los parámetros de tipo:

interface ConNombre {
  nombre: string;
}

class RegistroNombres<T extends ConNombre> {
  private elementos: T[] = [];

  agregar(elemento: T): void {
    this.elementos.push(elemento);
  }

  mostrarNombres(): void {
    this.elementos.forEach(elemento => {
      console.log(elemento.nombre);
    });
  }
}

// Tipos válidos que cumplen con la restricción
interface Estudiante extends ConNombre {
  curso: string;
}

interface Empleado extends ConNombre {
  departamento: string;
}

const registroEstudiantes = new RegistroNombres<Estudiante>();
registroEstudiantes.agregar({ nombre: "Carlos", curso: "TypeScript" });

const registroEmpleados = new RegistroNombres<Empleado>();
registroEmpleados.agregar({ nombre: "Laura", departamento: "Desarrollo" });

// Esto generaría un error de compilación
// const registroInvalido = new RegistroNombres<number>();

En este ejemplo, el parámetro T está restringido a tipos que implementen la interfaz ConNombre, lo que garantiza que todos los elementos agregados al registro tengan una propiedad nombre.

Parámetros de tipo con valores predeterminados

TypeScript permite especificar valores predeterminados para los parámetros de tipo genéricos:

class ColeccionConfigurable<T, U = T> {
  private items: T[] = [];
  private defaultValue: U;

  constructor(defaultValue: U) {
    this.defaultValue = defaultValue;
  }

  agregar(item: T): void {
    this.items.push(item);
  }

  obtenerPrimero(): T | U {
    return this.items.length > 0 ? this.items[0] : this.defaultValue;
  }
}

// El segundo tipo toma el valor del primero por defecto
const coleccionNumeros = new ColeccionConfigurable<number>(0);

// Especificando ambos tipos explícitamente
const coleccionTextos = new ColeccionConfigurable<string, string>("(vacío)");

// Usando tipos diferentes
const coleccionConDefault = new ColeccionConfigurable<string, boolean>(false);

En este ejemplo, si no se proporciona el segundo parámetro de tipo U, tomará el mismo tipo que T.

Métodos genéricos dentro de clases genéricas

Una clase genérica también puede contener métodos genéricos que introduzcan sus propios parámetros de tipo:

class Utilidades<T> {
  private valor: T;

  constructor(valor: T) {
    this.valor = valor;
  }

  // Método genérico dentro de una clase genérica
  intercambiar<U>(otro: U): [U, T] {
    return [otro, this.valor];
  }

  // Otro método genérico con restricción
  combinar<U extends object>(otro: U): T & U {
    return { ...this.valor as object, ...otro } as T & U;
  }
}

const utilString = new Utilidades<string>("hola");
const [num, str] = utilString.intercambiar(42); // num: number, str: string

const utilObj = new Utilidades<{ id: number }>({ id: 1 });
const combinado = utilObj.combinar({ nombre: "Producto" }); 
// combinado tiene tipo: { id: number } & { nombre: string }

Esta técnica permite crear métodos muy flexibles que pueden trabajar con diferentes tipos, incluso dentro del contexto de una clase genérica.

Ejemplo práctico: implementación de una cola genérica

Veamos un ejemplo más completo de una clase genérica que implementa una estructura de datos de cola (FIFO - First In, First Out):

class Cola<T> {
  private elementos: T[] = [];

  // Añade un elemento al final de la cola
  encolar(elemento: T): void {
    this.elementos.push(elemento);
  }

  // Remueve y devuelve el primer elemento
  desencolar(): T | undefined {
    if (this.estaVacia()) {
      return undefined;
    }
    return this.elementos.shift();
  }

  // Devuelve el primer elemento sin removerlo
  frente(): T | undefined {
    if (this.estaVacia()) {
      return undefined;
    }
    return this.elementos[0];
  }

  // Verifica si la cola está vacía
  estaVacia(): boolean {
    return this.elementos.length === 0;
  }

  // Devuelve el tamaño de la cola
  tamaño(): number {
    return this.elementos.length;
  }

  // Limpia la cola
  limpiar(): void {
    this.elementos = [];
  }
}

// Cola de números
const colaNumeros = new Cola<number>();
colaNumeros.encolar(1);
colaNumeros.encolar(2);
colaNumeros.encolar(3);

console.log(colaNumeros.desencolar()); // 1
console.log(colaNumeros.frente());     // 2
console.log(colaNumeros.tamaño());     // 2

// Cola de objetos personalizados
interface Tarea {
  id: number;
  descripcion: string;
  prioridad: number;
}

const colaTareas = new Cola<Tarea>();
colaTareas.encolar({ id: 1, descripcion: "Completar informe", prioridad: 2 });
colaTareas.encolar({ id: 2, descripcion: "Revisar código", prioridad: 1 });

const siguienteTarea = colaTareas.desencolar();
if (siguienteTarea) {
  console.log(`Tarea a realizar: ${siguienteTarea.descripcion}`);
}

Esta implementación de cola puede trabajar con cualquier tipo de datos, desde tipos primitivos hasta objetos complejos, manteniendo la seguridad de tipos en todo momento.

Clases genéricas con propiedades estáticas

Es importante entender que los miembros estáticos de una clase genérica no pueden usar los parámetros de tipo de la clase, ya que estos miembros son compartidos entre todas las instancias:

class FactoriaGenerica<T> {
  // Error: Los miembros estáticos no pueden referenciar parámetros de tipo
  // static crearInstancia(): T {
  //   return new T();
  // }

  // Esto es válido: método de instancia usando el tipo genérico
  crearCopia(original: T): T {
    return { ...original as object } as T;
  }

  // Los miembros estáticos pueden tener sus propios parámetros genéricos
  static crear<U>(valor: U): U {
    return valor;
  }
}

const factoria = new FactoriaGenerica<string>();
const copia = factoria.crearCopia("original");

// Usando el método estático genérico
const nuevoValor = FactoriaGenerica.crear({ id: 1, nombre: "Producto" });

Esta limitación existe porque los parámetros de tipo genérico solo están disponibles cuando se crea una instancia de la clase.

Interfaces genéricas

Las interfaces genéricas en TypeScript representan una herramienta fundamental para definir contratos que pueden trabajar con diferentes tipos de datos. A diferencia de las clases genéricas, las interfaces genéricas se centran exclusivamente en la definición de estructuras y comportamientos sin implementación.

La sintaxis para crear una interfaz genérica es similar a la de las clases genéricas, utilizando los símbolos <> para declarar los parámetros de tipo:

interface Respuesta<T> {
  datos: T;
  estado: number;
  mensaje: string;
}

Esta interfaz Respuesta<T> puede adaptarse a diferentes tipos de datos según las necesidades de la aplicación:

// Respuesta con datos de usuario
interface Usuario {
  id: number;
  nombre: string;
  email: string;
}

const respuestaUsuario: Respuesta<Usuario> = {
  datos: { id: 1, nombre: "Ana García", email: "ana@ejemplo.com" },
  estado: 200,
  mensaje: "Usuario recuperado correctamente"
};

// Respuesta con lista de productos
interface Producto {
  id: string;
  nombre: string;
  precio: number;
}

const respuestaProductos: Respuesta<Producto[]> = {
  datos: [
    { id: "p1", nombre: "Teclado", precio: 49.99 },
    { id: "p2", nombre: "Ratón", precio: 29.99 }
  ],
  estado: 200,
  mensaje: "Productos recuperados correctamente"
};

Interfaces genéricas para funciones

Una característica particularmente útil es la capacidad de definir interfaces para funciones con parámetros genéricos:

interface Transformador<T, U> {
  (entrada: T): U;
}

// Implementación de la interfaz genérica de función
const convertirAString: Transformador<number, string> = (numero) => {
  return numero.toString();
};

const numeroComoTexto = convertirAString(42); // "42"

// Otra implementación con diferentes tipos
const obtenerLongitud: Transformador<any[], number> = (array) => {
  return array.length;
};

const longitud = obtenerLongitud([1, 2, 3, 4]); // 4

Esta capacidad permite definir contratos para funciones que mantienen la seguridad de tipos mientras proporcionan flexibilidad en los tipos de entrada y salida.

Interfaces genéricas para métodos

Las interfaces genéricas también pueden definir métodos que utilicen parámetros de tipo:

interface Coleccion<T> {
  agregar(item: T): void;
  obtener(indice: number): T;
  buscar(condicion: (item: T) => boolean): T | undefined;
  tamaño(): number;
}

// Implementación de la interfaz genérica
class ListaArray<T> implements Coleccion<T> {
  private items: T[] = [];

  agregar(item: T): void {
    this.items.push(item);
  }

  obtener(indice: number): T {
    if (indice < 0 || indice >= this.items.length) {
      throw new Error("Índice fuera de rango");
    }
    return this.items[indice];
  }

  buscar(condicion: (item: T) => boolean): T | undefined {
    return this.items.find(condicion);
  }

  tamaño(): number {
    return this.items.length;
  }
}

// Uso de la implementación
const listaNumeros = new ListaArray<number>();
listaNumeros.agregar(10);
listaNumeros.agregar(20);
listaNumeros.agregar(30);

const numeroEncontrado = listaNumeros.buscar(num => num > 15); // 20

En este ejemplo, la interfaz Coleccion<T> define un contrato para estructuras de datos que pueden almacenar y manipular elementos de cualquier tipo, y la clase ListaArray<T> proporciona una implementación concreta.

Interfaces genéricas con múltiples parámetros

Al igual que las clases, las interfaces genéricas pueden utilizar múltiples parámetros de tipo para representar relaciones más complejas:

interface Diccionario<K extends string | number | symbol, V> {
  establecer(clave: K, valor: V): void;
  obtener(clave: K): V | undefined;
  eliminar(clave: K): boolean;
  contieneClave(clave: K): boolean;
  obtenerClaves(): K[];
}

class MapaDiccionario<K extends string | number | symbol, V> implements Diccionario<K, V> {
  private elementos = new Map<K, V>();

  establecer(clave: K, valor: V): void {
    this.elementos.set(clave, valor);
  }

  obtener(clave: K): V | undefined {
    return this.elementos.get(clave);
  }

  eliminar(clave: K): boolean {
    return this.elementos.delete(clave);
  }

  contieneClave(clave: K): boolean {
    return this.elementos.has(clave);
  }

  obtenerClaves(): K[] {
    return Array.from(this.elementos.keys());
  }
}

// Uso con diferentes tipos
const diccionarioStringNumero = new MapaDiccionario<string, number>();
diccionarioStringNumero.establecer("uno", 1);
diccionarioStringNumero.establecer("dos", 2);
console.log(diccionarioStringNumero.obtener("uno")); // 1

const diccionarioNumeroString = new MapaDiccionario<number, string>();
diccionarioNumeroString.establecer(1, "uno");
diccionarioNumeroString.establecer(2, "dos");
console.log(diccionarioNumeroString.obtener(1)); // "uno"

En este ejemplo, la interfaz Diccionario<K, V> utiliza dos parámetros de tipo: K para las claves (restringido a tipos que pueden ser claves de objetos) y V para los valores.

Interfaces genéricas con restricciones

Las interfaces genéricas pueden utilizar restricciones para limitar los tipos que pueden utilizarse como argumentos:

interface ConIdentificador {
  id: number | string;
}

interface Repositorio<T extends ConIdentificador> {
  guardar(item: T): T;
  obtenerPorId(id: number | string): T | null;
  actualizar(item: T): boolean;
  eliminar(id: number | string): boolean;
  listarTodos(): T[];
}

// Implementación para un tipo específico
interface Empleado extends ConIdentificador {
  id: number;
  nombre: string;
  departamento: string;
}

class RepositorioEmpleados implements Repositorio<Empleado> {
  private empleados: Empleado[] = [];

  guardar(empleado: Empleado): Empleado {
    this.empleados.push({...empleado});
    return empleado;
  }

  obtenerPorId(id: number | string): Empleado | null {
    const empleado = this.empleados.find(e => e.id === id);
    return empleado ? {...empleado} : null;
  }

  actualizar(empleado: Empleado): boolean {
    const indice = this.empleados.findIndex(e => e.id === empleado.id);
    if (indice >= 0) {
      this.empleados[indice] = {...empleado};
      return true;
    }
    return false;
  }

  eliminar(id: number | string): boolean {
    const indiceInicial = this.empleados.length;
    this.empleados = this.empleados.filter(e => e.id !== id);
    return this.empleados.length < indiceInicial;
  }

  listarTodos(): Empleado[] {
    return [...this.empleados];
  }
}

En este ejemplo, la interfaz Repositorio<T> está restringida a tipos que implementen la interfaz ConIdentificador, garantizando que todos los elementos tengan una propiedad id.

Interfaces genéricas con tipos indexados

Las interfaces genéricas pueden combinarse con tipos indexados para crear estructuras de datos flexibles:

interface ObjetoIndexado<T> {
  [clave: string]: T;
}

function obtenerValores<T>(objeto: ObjetoIndexado<T>): T[] {
  return Object.values(objeto);
}

const datosNumericos: ObjetoIndexado<number> = {
  a: 1,
  b: 2,
  c: 3
};

const valoresNumericos = obtenerValores(datosNumericos); // [1, 2, 3]

const datosTexto: ObjetoIndexado<string> = {
  nombre: "Juan",
  apellido: "Pérez",
  profesion: "Desarrollador"
};

const valoresTexto = obtenerValores(datosTexto); // ["Juan", "Pérez", "Desarrollador"]

Esta técnica es especialmente útil para trabajar con objetos dinámicos donde las claves no se conocen de antemano.

Interfaces genéricas anidadas

Las interfaces genéricas pueden anidarse para representar estructuras de datos más complejas:

interface Resultado<T> {
  exito: boolean;
  datos?: T;
  error?: string;
}

interface Paginado<T> {
  items: T[];
  total: number;
  pagina: number;
  porPagina: number;
}

// Combinando interfaces genéricas
interface RespuestaPaginada<T> extends Resultado<Paginado<T>> {
  tiempoRespuesta: number;
}

// Uso con un tipo específico
interface Articulo {
  id: number;
  titulo: string;
  contenido: string;
}

const respuestaArticulos: RespuestaPaginada<Articulo> = {
  exito: true,
  datos: {
    items: [
      { id: 1, titulo: "Introducción a TypeScript", contenido: "TypeScript es un superconjunto de JavaScript..." },
      { id: 2, titulo: "Interfaces genéricas", contenido: "Las interfaces genéricas permiten..." }
    ],
    total: 45,
    pagina: 1,
    porPagina: 2
  },
  tiempoRespuesta: 120 // milisegundos
};

Esta capacidad de anidar interfaces genéricas permite crear estructuras de datos complejas y reutilizables que mantienen la seguridad de tipos.

Interfaces genéricas para patrones de diseño

Las interfaces genéricas son particularmente útiles para implementar patrones de diseño como el patrón Observer:

interface Observable<T> {
  suscribir(observador: Observador<T>): void;
  desuscribir(observador: Observador<T>): void;
  notificar(datos: T): void;
}

interface Observador<T> {
  actualizar(datos: T): void;
}

// Implementación del patrón Observer
class Publicador<T> implements Observable<T> {
  private observadores: Observador<T>[] = [];

  suscribir(observador: Observador<T>): void {
    if (!this.observadores.includes(observador)) {
      this.observadores.push(observador);
    }
  }

  desuscribir(observador: Observador<T>): void {
    const indice = this.observadores.indexOf(observador);
    if (indice !== -1) {
      this.observadores.splice(indice, 1);
    }
  }

  notificar(datos: T): void {
    for (const observador of this.observadores) {
      observador.actualizar(datos);
    }
  }
}

// Implementación de un observador concreto
class SuscriptorConsola<T> implements Observador<T> {
  private nombre: string;

  constructor(nombre: string) {
    this.nombre = nombre;
  }

  actualizar(datos: T): void {
    console.log(`${this.nombre} recibió:`, datos);
  }
}

// Uso del patrón
const publicadorNumeros = new Publicador<number>();
const suscriptor1 = new SuscriptorConsola<number>("Observador 1");
const suscriptor2 = new SuscriptorConsola<number>("Observador 2");

publicadorNumeros.suscribir(suscriptor1);
publicadorNumeros.suscribir(suscriptor2);
publicadorNumeros.notificar(42);
// Salida:
// Observador 1 recibió: 42
// Observador 2 recibió: 42

Este ejemplo muestra cómo las interfaces genéricas pueden utilizarse para implementar patrones de diseño de manera flexible y con seguridad de tipos.

Herencia genérica

La herencia genérica en TypeScript permite combinar los beneficios de la programación orientada a objetos con la flexibilidad de los tipos genéricos. Esta característica nos permite crear jerarquías de clases que preservan la información de tipos a través de la cadena de herencia, manteniendo la seguridad de tipos en todo momento.

Cuando trabajamos con herencia genérica, podemos crear clases base que utilicen parámetros de tipo y luego extenderlas con clases derivadas que pueden mantener, especializar o añadir nuevos parámetros de tipo.

Herencia básica con genéricos

La forma más simple de herencia genérica es cuando una clase derivada hereda los parámetros de tipo de su clase base:

class EntidadBase<T> {
  protected id: T;

  constructor(id: T) {
    this.id = id;
  }

  obtenerId(): T {
    return this.id;
  }
}

class Usuario extends EntidadBase<number> {
  private nombre: string;

  constructor(id: number, nombre: string) {
    super(id);
    this.nombre = nombre;
  }

  obtenerNombre(): string {
    return this.nombre;
  }
}

const usuario = new Usuario(1, "Ana Martínez");
console.log(usuario.obtenerId()); // 1 (tipo number)
console.log(usuario.obtenerNombre()); // "Ana Martínez"

En este ejemplo, Usuario extiende EntidadBase especificando que el tipo genérico T será number. La clase derivada hereda todos los métodos y propiedades de la clase base, manteniendo la información de tipos.

Propagación de parámetros genéricos

Una clase derivada puede propagar los parámetros genéricos de su clase base, permitiendo que el tipo se especifique al instanciar la clase derivada:

class Repositorio<T> {
  protected elementos: T[] = [];

  agregar(elemento: T): void {
    this.elementos.push(elemento);
  }

  obtenerTodos(): T[] {
    return [...this.elementos];
  }
}

class RepositorioConBusqueda<T> extends Repositorio<T> {
  buscar(predicado: (elemento: T) => boolean): T[] {
    return this.elementos.filter(predicado);
  }
}

interface Producto {
  id: number;
  nombre: string;
  precio: number;
}

// El tipo genérico se especifica al instanciar la clase derivada
const repoProductos = new RepositorioConBusqueda<Producto>();
repoProductos.agregar({ id: 1, nombre: "Laptop", precio: 999 });
repoProductos.agregar({ id: 2, nombre: "Teléfono", precio: 699 });

// Usando el método heredado
const todosProductos = repoProductos.obtenerTodos();

// Usando el método de la clase derivada
const productosCaros = repoProductos.buscar(p => p.precio > 800);
console.log(productosCaros); // [{ id: 1, nombre: "Laptop", precio: 999 }]

En este caso, RepositorioConBusqueda<T> extiende Repositorio<T> manteniendo el parámetro genérico. Esto permite que el tipo se especifique al crear una instancia de la clase derivada.

Especialización de tipos genéricos

Una clase derivada puede especializar el tipo genérico de su clase base, estableciendo restricciones adicionales:

interface Identificable {
  id: number | string;
}

class RepositorioBase<T> {
  protected items: T[] = [];

  agregar(item: T): void {
    this.items.push(item);
  }

  obtenerTodos(): T[] {
    return [...this.items];
  }
}

class RepositorioIdentificable<T extends Identificable> extends RepositorioBase<T> {
  obtenerPorId(id: number | string): T | undefined {
    return this.items.find(item => item.id === id);
  }

  actualizar(item: T): boolean {
    const indice = this.items.findIndex(i => i.id === item.id);
    if (indice !== -1) {
      this.items[indice] = { ...item };
      return true;
    }
    return false;
  }
}

interface Cliente extends Identificable {
  id: number;
  nombre: string;
  email: string;
}

const repoClientes = new RepositorioIdentificable<Cliente>();
repoClientes.agregar({ id: 1, nombre: "Juan Pérez", email: "juan@ejemplo.com" });

// Método específico de la clase derivada que requiere Identificable
const cliente = repoClientes.obtenerPorId(1);
console.log(cliente?.nombre); // "Juan Pérez"

En este ejemplo, RepositorioIdentificable extiende RepositorioBase pero añade una restricción adicional: el tipo T debe implementar la interfaz Identificable. Esto permite que la clase derivada implemente métodos que dependen de la existencia de una propiedad id.

Añadir nuevos parámetros genéricos

Una clase derivada puede añadir nuevos parámetros genéricos además de los heredados de la clase base:

class Contenedor<T> {
  protected valor: T;

  constructor(valor: T) {
    this.valor = valor;
  }

  obtenerValor(): T {
    return this.valor;
  }
}

class ContenedorConTransformacion<T, U> extends Contenedor<T> {
  private transformador: (valor: T) => U;

  constructor(valor: T, transformador: (valor: T) => U) {
    super(valor);
    this.transformador = transformador;
  }

  obtenerTransformado(): U {
    return this.transformador(this.valor);
  }
}

// Uso con diferentes tipos
const contenedorNumero = new ContenedorConTransformacion<number, string>(
  42,
  (num) => `El número es: ${num}`
);

console.log(contenedorNumero.obtenerValor()); // 42
console.log(contenedorNumero.obtenerTransformado()); // "El número es: 42"

// Otro ejemplo con tipos más complejos
interface Persona {
  nombre: string;
  edad: number;
}

interface ResumenPersona {
  nombreCompleto: string;
  esAdulto: boolean;
}

const transformarPersona = (p: Persona): ResumenPersona => ({
  nombreCompleto: p.nombre,
  esAdulto: p.edad >= 18
});

const contenedorPersona = new ContenedorConTransformacion<Persona, ResumenPersona>(
  { nombre: "Carlos Ruiz", edad: 25 },
  transformarPersona
);

console.log(contenedorPersona.obtenerTransformado()); 
// { nombreCompleto: "Carlos Ruiz", esAdulto: true }

En este ejemplo, ContenedorConTransformacion<T, U> extiende Contenedor<T> y añade un nuevo parámetro de tipo U para representar el tipo del valor transformado.

Herencia con interfaces genéricas

La herencia genérica también se aplica cuando las clases implementan interfaces genéricas:

interface Coleccionable<T> {
  agregar(item: T): void;
  contiene(item: T): boolean;
}

interface ColeccionOrdenable<T> extends Coleccionable<T> {
  ordenar(comparador: (a: T, b: T) => number): void;
}

class ListaOrdenada<T> implements ColeccionOrdenable<T> {
  private items: T[] = [];

  agregar(item: T): void {
    this.items.push(item);
  }

  contiene(item: T): boolean {
    return this.items.some(i => i === item);
  }

  ordenar(comparador: (a: T, b: T) => number): void {
    this.items.sort(comparador);
  }

  obtenerItems(): T[] {
    return [...this.items];
  }
}

// Uso con números
const listaNumeros = new ListaOrdenada<number>();
listaNumeros.agregar(3);
listaNumeros.agregar(1);
listaNumeros.agregar(2);

listaNumeros.ordenar((a, b) => a - b);
console.log(listaNumeros.obtenerItems()); // [1, 2, 3]

// Uso con objetos
interface Tarea {
  id: number;
  titulo: string;
  prioridad: number;
}

const listaTareas = new ListaOrdenada<Tarea>();
listaTareas.agregar({ id: 1, titulo: "Completar informe", prioridad: 2 });
listaTareas.agregar({ id: 2, titulo: "Revisar código", prioridad: 1 });
listaTareas.agregar({ id: 3, titulo: "Reunión con cliente", prioridad: 3 });

listaTareas.ordenar((a, b) => a.prioridad - b.prioridad);
console.log(listaTareas.obtenerItems());
// Tareas ordenadas por prioridad ascendente

En este ejemplo, ColeccionOrdenable<T> extiende Coleccionable<T>, y ListaOrdenada<T> implementa ColeccionOrdenable<T>. La cadena de herencia mantiene la información de tipos en cada nivel.

Herencia mixta con clases abstractas genéricas

Las clases abstractas genéricas proporcionan una forma poderosa de definir comportamientos comunes mientras permiten que las clases derivadas especifiquen los tipos concretos:

abstract class ServicioApi<T, ID> {
  abstract obtenerUrl(): string;

  async obtenerTodos(): Promise<T[]> {
    const respuesta = await fetch(this.obtenerUrl());
    return respuesta.json();
  }

  async obtenerPorId(id: ID): Promise<T> {
    const respuesta = await fetch(`${this.obtenerUrl()}/${id}`);
    return respuesta.json();
  }

  async crear(item: Omit<T, 'id'>): Promise<T> {
    const respuesta = await fetch(this.obtenerUrl(), {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item)
    });
    return respuesta.json();
  }
}

interface Usuario {
  id: number;
  nombre: string;
  email: string;
}

class ServicioUsuarios extends ServicioApi<Usuario, number> {
  obtenerUrl(): string {
    return 'https://api.ejemplo.com/usuarios';
  }

  // Métodos específicos para usuarios
  async buscarPorEmail(email: string): Promise<Usuario | undefined> {
    const usuarios = await this.obtenerTodos();
    return usuarios.find(u => u.email === email);
  }
}

// Uso
const servicioUsuarios = new ServicioUsuarios();
// Todos los métodos heredados mantienen los tipos correctos
servicioUsuarios.obtenerPorId(1).then(usuario => {
  console.log(usuario.nombre); // Acceso seguro a la propiedad nombre
});

En este ejemplo, ServicioApi<T, ID> es una clase abstracta genérica que define operaciones CRUD comunes. La clase ServicioUsuarios extiende esta clase base especificando los tipos concretos Usuario y number (para el ID).

Patrones avanzados de herencia genérica

La herencia genérica permite implementar patrones de diseño avanzados como el patrón Método de Plantilla (Template Method):

abstract class ProcesadorDatos<T, R> {
  // Método plantilla que define el algoritmo
  procesar(datos: T[]): R[] {
    const datosFiltrados = this.filtrar(datos);
    const datosTransformados = datosFiltrados.map(dato => this.transformar(dato));
    return this.ordenar(datosTransformados);
  }

  // Métodos abstractos que deben implementar las subclases
  protected abstract filtrar(datos: T[]): T[];
  protected abstract transformar(dato: T): R;
  protected abstract ordenar(datos: R[]): R[];
}

interface DatoNumerico {
  valor: number;
  etiqueta: string;
  activo: boolean;
}

interface ResultadoEstadistico {
  etiqueta: string;
  valorNormalizado: number;
}

class ProcesadorEstadistico extends ProcesadorDatos<DatoNumerico, ResultadoEstadistico> {
  protected filtrar(datos: DatoNumerico[]): DatoNumerico[] {
    return datos.filter(dato => dato.activo);
  }

  protected transformar(dato: DatoNumerico): ResultadoEstadistico {
    return {
      etiqueta: dato.etiqueta,
      valorNormalizado: dato.valor / 100
    };
  }

  protected ordenar(datos: ResultadoEstadistico[]): ResultadoEstadistico[] {
    return [...datos].sort((a, b) => b.valorNormalizado - a.valorNormalizado);
  }
}

// Uso del procesador
const datosEntrada: DatoNumerico[] = [
  { valor: 120, etiqueta: "A", activo: true },
  { valor: 80, etiqueta: "B", activo: false },
  { valor: 160, etiqueta: "C", activo: true }
];

const procesador = new ProcesadorEstadistico();
const resultados = procesador.procesar(datosEntrada);
console.log(resultados);
// [
//   { etiqueta: "C", valorNormalizado: 1.6 },
//   { etiqueta: "A", valorNormalizado: 1.2 }
// ]

Este patrón permite definir el esqueleto de un algoritmo en la clase base mientras delega los pasos específicos a las subclases, todo ello manteniendo la seguridad de tipos.

Consideraciones y mejores prácticas

Al trabajar con herencia genérica en TypeScript, es importante tener en cuenta algunas consideraciones:

  • Evita jerarquías profundas: Las cadenas de herencia demasiado profundas pueden hacer que el código sea difícil de entender y mantener.

  • Usa restricciones adecuadas: Utiliza extends para restringir los tipos genéricos cuando sea necesario, lo que proporciona mejor autocompletado y detección de errores.

  • Considera la composición: En algunos casos, la composición puede ser una alternativa más flexible que la herencia.

  • Documenta los parámetros de tipo: Especialmente en jerarquías complejas, es útil documentar el propósito de cada parámetro de tipo.

/**
 * Representa un servicio que gestiona entidades con identificadores.
 * @template T - El tipo de entidad gestionada
 * @template ID - El tipo de identificador de la entidad
 */
abstract class Servicio<T, ID> {
  // Implementación...
}

La herencia genérica es una herramienta poderosa que, cuando se utiliza correctamente, permite crear abstracciones flexibles y reutilizables que mantienen la seguridad de tipos en toda la jerarquía de clases.

Instanciación de genéricos

La instanciación de genéricos es el proceso mediante el cual se proporciona un tipo concreto a una estructura genérica en TypeScript. Este paso es fundamental para transformar una plantilla genérica en un componente específico y utilizable con un tipo determinado.

Cuando instanciamos un genérico, estamos "rellenando" los parámetros de tipo con tipos concretos, lo que permite al compilador realizar comprobaciones de tipo adecuadas y proporcionar autocompletado específico para ese tipo.

Sintaxis de instanciación

La instanciación de genéricos se realiza proporcionando los tipos concretos entre corchetes angulares <>. Existen varias formas de instanciar genéricos dependiendo del contexto:

// Instanciación explícita de una clase genérica
const listaNombres = new Lista<string>();

// Instanciación explícita al llamar a una función genérica
const resultado = convertir<string, number>("42", parseInt);

// Instanciación explícita al crear un objeto que implementa una interfaz genérica
const respuesta: Respuesta<Usuario> = {
  datos: { id: 1, nombre: "Elena" },
  exito: true
};

Inferencia de tipos en genéricos

TypeScript puede inferir automáticamente los tipos genéricos en muchos casos, lo que hace que la instanciación explícita sea opcional:

// Inferencia automática basada en los argumentos
function identidad<T>(valor: T): T {
  return valor;
}

// TypeScript infiere que T es number
const num = identidad(42);

// TypeScript infiere que T es string
const texto = identidad("hola");

// TypeScript infiere tipos complejos también
const usuario = identidad({ id: 1, nombre: "Carlos" });

Sin embargo, hay situaciones donde la inferencia no es posible y se requiere una instanciación explícita:

// Sin argumentos, TypeScript no puede inferir el tipo
function crearArray<T>(): T[] {
  return [];
}

// Instanciación explícita necesaria
const numeros = crearArray<number>();
const textos = crearArray<string>();

Instanciación con valores predeterminados

TypeScript permite definir valores predeterminados para parámetros de tipo, lo que puede simplificar la instanciación:

// Clase con valor predeterminado para el parámetro de tipo
class Almacen<T = string> {
  private elementos: T[] = [];
  
  agregar(elemento: T): void {
    this.elementos.push(elemento);
  }
  
  obtenerTodos(): T[] {
    return [...this.elementos];
  }
}

// Instanciación sin especificar tipo (usa el predeterminado: string)
const almacenTextos = new Almacen();
almacenTextos.agregar("uno");
almacenTextos.agregar("dos");

// Instanciación explícita con otro tipo
const almacenNumeros = new Almacen<number>();
almacenNumeros.agregar(1);
almacenNumeros.agregar(2);

Instanciación con tipos condicionales

La instanciación puede volverse más sofisticada con tipos condicionales, permitiendo que el tipo resultante dependa de condiciones:

// Tipo condicional
type ElementoArray<T> = T extends Array<infer E> ? E : never;

// Instanciación con diferentes tipos
type TipoItem1 = ElementoArray<string[]>; // string
type TipoItem2 = ElementoArray<number[]>; // number
type TipoItem3 = ElementoArray<boolean>; // never (no es un array)

// Uso práctico en una función
function primerElemento<T>(array: T[]): ElementoArray<T[]> {
  return array[0];
}

const primero = primerElemento([1, 2, 3]); // tipo: number
const primeroTexto = primerElemento(["a", "b", "c"]); // tipo: string

Instanciación parcial de genéricos

En algunos casos, podemos realizar una instanciación parcial de genéricos, especificando solo algunos de los parámetros de tipo:

// Clase con múltiples parámetros de tipo
class Diccionario<K, V> {
  private items: Map<K, V> = new Map();
  
  establecer(clave: K, valor: V): void {
    this.items.set(clave, valor);
  }
  
  obtener(clave: K): V | undefined {
    return this.items.get(clave);
  }
}

// Creación de un tipo parcialmente instanciado
type DiccionarioDeStrings<K> = Diccionario<K, string>;

// Uso del tipo parcialmente instanciado
const diccionario = new DiccionarioDeStrings<number>();
diccionario.establecer(1, "uno");
diccionario.establecer(2, "dos");

const valor = diccionario.obtener(1); // tipo: string | undefined

Instanciación con restricciones

Cuando trabajamos con genéricos que tienen restricciones, debemos asegurarnos de que los tipos proporcionados cumplan con esas restricciones:

// Interfaz para la restricción
interface ConNombre {
  nombre: string;
}

// Función con restricción genérica
function imprimirNombre<T extends ConNombre>(entidad: T): void {
  console.log(entidad.nombre);
}

// Instanciación correcta (cumple con la restricción)
imprimirNombre({ nombre: "Ana", edad: 30 });

// Error: el tipo no cumple con la restricción
// imprimirNombre({ edad: 25 });

// Clase con restricción genérica
class Registro<T extends ConNombre> {
  private entidades: T[] = [];
  
  agregar(entidad: T): void {
    this.entidades.push(entidad);
  }
  
  buscarPorNombre(nombre: string): T | undefined {
    return this.entidades.find(e => e.nombre === nombre);
  }
}

// Instanciación con un tipo que cumple la restricción
interface Empleado extends ConNombre {
  id: number;
  departamento: string;
}

const registroEmpleados = new Registro<Empleado>();
registroEmpleados.agregar({ id: 1, nombre: "Juan", departamento: "IT" });

Instanciación con tipos mapeados

Los tipos mapeados ofrecen una forma poderosa de transformar tipos existentes, y pueden combinarse con la instanciación de genéricos:

// Tipo mapeado para hacer todas las propiedades opcionales
type Parcial<T> = {
  [P in keyof T]?: T[P];
};

interface Producto {
  id: number;
  nombre: string;
  precio: number;
  stock: number;
}

// Instanciación del tipo mapeado
type ProductoParcial = Parcial<Producto>;

// Uso del tipo instanciado
const actualizacionProducto: ProductoParcial = {
  precio: 29.99,
  stock: 100
};

// Función que utiliza el tipo mapeado
function actualizarProducto(id: number, datos: Parcial<Producto>): void {
  // Implementación...
}

actualizarProducto(1, { precio: 19.99 });

Instanciación con genéricos anidados

La instanciación puede volverse más compleja con genéricos anidados, donde un tipo genérico contiene otro:

// Tipos genéricos anidados
interface Resultado<T> {
  datos: T;
  errores: string[];
}

interface ListaPaginada<T> {
  items: T[];
  total: number;
  pagina: number;
}

// Instanciación con genéricos anidados
type ResultadoUsuarios = Resultado<ListaPaginada<Usuario>>;

// Uso del tipo instanciado
const respuestaAPI: ResultadoUsuarios = {
  datos: {
    items: [
      { id: 1, nombre: "Ana" },
      { id: 2, nombre: "Luis" }
    ],
    total: 50,
    pagina: 1
  },
  errores: []
};

// Función que trabaja con tipos anidados
function procesarResultado<T>(resultado: Resultado<ListaPaginada<T>>): T[] {
  if (resultado.errores.length > 0) {
    throw new Error("Error en la respuesta");
  }
  return resultado.datos.items;
}

const usuarios = procesarResultado(respuestaAPI);

Instanciación dinámica con genéricos

En algunos casos, podemos necesitar una instanciación dinámica basada en condiciones en tiempo de ejecución:

// Función factory que crea instancias de diferentes tipos
function crearServicio<T>(tipo: string): T {
  if (tipo === "usuarios") {
    return new ServicioUsuarios() as unknown as T;
  } else if (tipo === "productos") {
    return new ServicioProductos() as unknown as T;
  } else {
    throw new Error(`Tipo de servicio desconocido: ${tipo}`);
  }
}

// Interfaces para los diferentes servicios
interface ServicioUsuarios {
  obtenerUsuarios(): Promise<Usuario[]>;
  obtenerPorId(id: number): Promise<Usuario>;
}

interface ServicioProductos {
  listarProductos(): Promise<Producto[]>;
  buscarPorNombre(nombre: string): Promise<Producto[]>;
}

// Instanciación dinámica basada en una condición
const tipoServicio = obtenerConfiguracion().tipoServicio;
const servicio = crearServicio<ServicioUsuarios | ServicioProductos>(tipoServicio);

// Uso con comprobación de tipo
if ("obtenerUsuarios" in servicio) {
  // Es un ServicioUsuarios
  servicio.obtenerUsuarios().then(usuarios => {
    console.log(usuarios);
  });
} else {
  // Es un ServicioProductos
  servicio.listarProductos().then(productos => {
    console.log(productos);
  });
}

Consideraciones prácticas

Al instanciar genéricos en TypeScript, es importante tener en cuenta algunas consideraciones prácticas:

  • Legibilidad vs. inferencia: Aunque TypeScript puede inferir muchos tipos, a veces es mejor ser explícito para mejorar la legibilidad del código.
// Menos legible (depende de la inferencia)
const resultado = obtenerDatos(api).then(procesar);

// Más legible (tipo explícito)
const resultado = obtenerDatos<Usuario>(api).then(procesar);
  • Evitar la sobre-especificación: No es necesario especificar tipos que TypeScript puede inferir fácilmente.
// Innecesariamente verboso
const lista = new Array<number>();

// Preferible (inferencia automática)
const lista = [1, 2, 3];
  • Uso de alias de tipo: Para genéricos complejos, considera crear alias de tipo para mejorar la legibilidad.
// Difícil de leer
function procesar(datos: Resultado<ListaPaginada<Entidad<Usuario>>>): void {
  // Implementación...
}

// Más legible con alias de tipo
type ResultadoPaginadoUsuarios = Resultado<ListaPaginada<Entidad<Usuario>>>;

function procesar(datos: ResultadoPaginadoUsuarios): void {
  // Implementación...
}
  • Compatibilidad con versiones anteriores: Al actualizar código existente para usar genéricos, asegúrate de mantener la compatibilidad con el código que lo consume.

La instanciación de genéricos es una técnica poderosa que permite crear código flexible y reutilizable sin sacrificar la seguridad de tipos. Dominar esta característica es esencial para aprovechar al máximo el sistema de tipos de TypeScript.

Aprende TypeScript online

Otros ejercicios de programación de TypeScript

Evalúa tus conocimientos de esta lección Genéricos con clases e interfaces con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

Funciones

TypeScript
Test

Reto composición de funciones

TypeScript
Código

Reto tipos especiales

TypeScript
Código

Reto tipos genéricos

TypeScript
Código

Módulos

TypeScript
Test

Polimorfismo

TypeScript
Código

Funciones TypeScript

TypeScript
Código

Interfaces

TypeScript
Puzzle

Funciones puras

TypeScript
Puzzle

Reto namespaces

TypeScript
Código

Funciones flecha

TypeScript
Puzzle

Polimorfismo

TypeScript
Test

Operadores

TypeScript
Test

Conversor de unidades

TypeScript
Proyecto

Funciones flecha

TypeScript
Test

Control de flujo

TypeScript
Código

Herencia

TypeScript
Puzzle

Clases

TypeScript
Puzzle

Proyecto validación de tipado

TypeScript
Proyecto

Clases y objetos

TypeScript
Código

Encapsulación

TypeScript
Test

Herencia

TypeScript
Test

Proyecto sistema de votación

TypeScript
Proyecto

Reto genéricos con clases

TypeScript
Código

Inmutabilidad

TypeScript
Puzzle

Interfaces

TypeScript
Test

Funciones de alto orden

TypeScript
Test

Reto map y filter

TypeScript
Código

Control de flujo

TypeScript
Test

Interfaces

TypeScript
Código

Reto funciones orden superior

TypeScript
Código

Herencia y clases abstractas

TypeScript
Código

Reto tipos mapped

TypeScript
Código

Herencia de clases

TypeScript
Código

Reto funciones puras

TypeScript
Código

Variables y constantes

TypeScript
Puzzle

Introducción a TypeScript

TypeScript
Test

Reto testing unitario

TypeScript
Código

Funciones de primera clase

TypeScript
Puzzle

Clases

TypeScript
Test

OOP y CRUD en TypeScript

TypeScript
Proyecto

Interfaces y su implementación

TypeScript
Código

Tipos genéricos

TypeScript
Test

Namespaces

TypeScript
Test

Operadores y expresiones

TypeScript
Código

Proyecto generador de contraseñas

TypeScript
Proyecto

Reto unión e intersección

TypeScript
Código

Encapsulación

TypeScript
Puzzle

Tipos de unión e intersección

TypeScript
Test

Tipos de unión e intersección

TypeScript
Puzzle

Reto hola mundo en TS

TypeScript
Código

Variables y constantes

TypeScript
Código

Funciones puras

TypeScript
Test

Control de flujo

TypeScript
Código

Introducción a TypeScript

TypeScript
Código

Resolución de módulos

TypeScript
Test

Control de flujo

TypeScript
Puzzle

Reto tipos de utilidad

TypeScript
Código

Reto tipos literales y condicionales

TypeScript
Código

Reto exportar e importar

TypeScript
Código

Propiedades y métodos

TypeScript
Código

Tipos de utilidad

TypeScript
Test

Clases y objetos

TypeScript
Código

Tipos de datos, variables y constantes

TypeScript
Código

Proyecto Minigestor de tareas

TypeScript
Proyecto

Operadores

TypeScript
Puzzle

Funciones flecha y contexto

TypeScript
Código

Proyecto Inventario de productos

TypeScript
Proyecto

Funciones

TypeScript
Puzzle

Reto type aliases

TypeScript
Código

Funciones de alto orden

TypeScript
Puzzle

Funciones y parámetros tipados

TypeScript
Código

Tipos literales

TypeScript
Puzzle

Reto enums

TypeScript
Código

Tipos de utilidad

TypeScript
Puzzle

Modificadores de acceso y encapsulación

TypeScript
Código

Polimorfismo

TypeScript
Puzzle

Tipos genéricos

TypeScript
Puzzle

Reto módulos

TypeScript
Código

Tipos literales

TypeScript
Test

Inmutabilidad

TypeScript
Test

Proyecto Generator de datos

TypeScript
Proyecto

Variables y constantes

TypeScript
Test

Funciones de primera clase

TypeScript
Test

Todas las lecciones de TypeScript

Accede a todas las lecciones de TypeScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Introducción A Typescript

TypeScript

Introducción Y Entorno

Instalación Y Configuración De Typescript

TypeScript

Introducción Y Entorno

Tipos De Datos, Variables Y Constantes

TypeScript

Sintaxis

Operadores Y Expresiones

TypeScript

Sintaxis

Control De Flujo

TypeScript

Sintaxis

Funciones Y Parámetros Tipados

TypeScript

Sintaxis

Funciones Flecha Y Contexto

TypeScript

Sintaxis

Enums

TypeScript

Sintaxis

Type Aliases Y Aserciones De Tipo

TypeScript

Sintaxis

Clases Y Objetos

TypeScript

Programación Orientada A Objetos

Interfaces Y Su Implementación

TypeScript

Programación Orientada A Objetos

Modificadores De Acceso Y Encapsulación

TypeScript

Programación Orientada A Objetos

Herencia Y Clases Abstractas

TypeScript

Programación Orientada A Objetos

Polimorfismo

TypeScript

Programación Orientada A Objetos

Decoradores Básicos

TypeScript

Programación Orientada A Objetos

Propiedades Y Métodos

TypeScript

Programación Orientada A Objetos

Inmutabilidad

TypeScript

Programación Funcional

Funciones Puras Y Efectos Secundarios

TypeScript

Programación Funcional

Funciones De Primera Clase

TypeScript

Programación Funcional

Funciones De Alto Orden

TypeScript

Programación Funcional

Conceptos Básicos E Inmutabilidad

TypeScript

Programación Funcional

Funciones De Primera Clase Y Orden Superior

TypeScript

Programación Funcional

Composición De Funciones

TypeScript

Programación Funcional

Métodos Funcionales De Arrays (Map, Filter, Reduce)

TypeScript

Programación Funcional

Tipos Literales Y Tipos Condicionales

TypeScript

Tipos Intermedios Y Avanzados

Tipos Genéricos Básicos

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Unión E Intersección

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Utilidad (Partial, Required, Pick, Etc)

TypeScript

Tipos Intermedios Y Avanzados

Unknown, Never Y Tipos Especiales

TypeScript

Tipos Intermedios Y Avanzados

Tipos Mapped

TypeScript

Tipos Intermedios Y Avanzados

Genéricos Con Clases E Interfaces

TypeScript

Tipos Intermedios Y Avanzados

Módulos

TypeScript

Namespaces Y Módulos

Namespaces

TypeScript

Namespaces Y Módulos

Resolución De Módulos

TypeScript

Namespaces Y Módulos

Exportación E Importación De Módulos

TypeScript

Namespaces Y Módulos

Introducción A Módulos

TypeScript

Namespaces Y Módulos

Testing Unitario En Typescript

TypeScript

Testing

Accede GRATIS a TypeScript y certifícate

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender la definición y uso de clases genéricas para manejar diferentes tipos de datos.
  • Aprender a aplicar interfaces genéricas para definir contratos flexibles y seguros.
  • Entender cómo funciona la herencia genérica y cómo extender clases e interfaces genéricas.
  • Conocer la instanciación de genéricos, incluyendo inferencia, restricciones y valores predeterminados.
  • Aplicar genéricos en patrones de diseño y estructuras de datos comunes manteniendo la seguridad de tipos.