Herencia y clases abstractas en TypeScript

Intermedio
TypeScript
TypeScript
Actualizado: 18/04/2026

Clases abstractas y métodos abstractos

Una clase abstracta es una clase que no puede instanciarse directamente. Su propósito es servir como base para otras clases que implementen los miembros abstractos que define. En TypeScript se declaran con la palabra clave abstract:

Herencia y clases abstractas en TypeScript

abstract class Figura {
  abstract calcularArea(): number
  abstract calcularPerimetro(): number

  describir(): string {
    return `Area: ${this.calcularArea().toFixed(2)}, Perimetro: ${this.calcularPerimetro().toFixed(2)}`
  }
}

// const figura = new Figura() // Error: Cannot create an instance of an abstract class

Los métodos abstractos no tienen cuerpo de implementación. Cada subclase concreta debe proporcionar su propia implementación:

class Circulo extends Figura {
  constructor(private radio: number) {
    super()
  }

  calcularArea(): number {
    return Math.PI * this.radio ** 2
  }

  calcularPerimetro(): number {
    return 2 * Math.PI * this.radio
  }
}

class Rectangulo extends Figura {
  constructor(
    private ancho: number,
    private alto: number
  ) {
    super()
  }

  calcularArea(): number {
    return this.ancho * this.alto
  }

  calcularPerimetro(): number {
    return 2 * (this.ancho + this.alto)
  }
}

const circulo = new Circulo(5)
console.log(circulo.describir()) // "Area: 78.54, Perimetro: 31.42"

const rectangulo = new Rectangulo(4, 6)
console.log(rectangulo.describir()) // "Area: 24.00, Perimetro: 20.00"

Las clases abstractas pueden contener propiedades abstractas además de métodos abstractos:

abstract class Notificacion {
  abstract readonly tipo: string
  abstract readonly prioridad: number

  abstract enviar(destinatario: string): boolean

  registrar(destinatario: string): string {
    return `[${this.tipo}] Notificacion con prioridad ${this.prioridad} para ${destinatario}`
  }
}

class NotificacionEmail extends Notificacion {
  readonly tipo = "email"
  readonly prioridad = 2

  constructor(private asunto: string, private cuerpo: string) {
    super()
  }

  enviar(destinatario: string): boolean {
    console.log(`Enviando email a ${destinatario}: ${this.asunto}`)
    return true
  }
}

class NotificacionSMS extends Notificacion {
  readonly tipo = "sms"
  readonly prioridad = 3

  constructor(private mensaje: string) {
    super()
  }

  enviar(destinatario: string): boolean {
    console.log(`Enviando SMS a ${destinatario}: ${this.mensaje}`)
    return true
  }
}

Si una subclase no implementa todos los miembros abstractos, TypeScript genera un error de compilación. Esto garantiza que el contrato definido por la clase abstracta se cumple siempre.

Cuándo usar abstract class frente a interface

Las clases abstractas e interfaces resuelven problemas diferentes. Las interfaces definen contratos puros sin implementación. Las clases abstractas permiten compartir lógica entre subclases:

// Interface: contrato puro
interface Serializable {
  serializar(): string
  deserializar(datos: string): void
}

// Clase abstracta: contrato con lógica compartida
abstract class EntidadBase {
  readonly creadoEn: Date = new Date()
  abstract readonly id: string

  abstract validar(): boolean

  esReciente(): boolean {
    const ahora = new Date()
    const diferencia = ahora.getTime() - this.creadoEn.getTime()
    return diferencia < 24 * 60 * 60 * 1000 // menos de 24 horas
  }

  toString(): string {
    return `Entidad ${this.id} creada el ${this.creadoEn.toISOString()}`
  }
}

Usa interface cuando solo necesites definir la forma de un objeto sin compartir implementación. Usa abstract class cuando necesites combinar un contrato con lógica reutilizable que las subclases hereden.

Una clase puede implementar interfaces y extender una clase abstracta simultáneamente:

interface Auditable {
  obtenerHistorial(): string[]
}

interface Exportable {
  exportarJSON(): string
}

abstract class Documento {
  abstract readonly titulo: string
  abstract obtenerContenido(): string

  obtenerResumen(): string {
    const contenido = this.obtenerContenido()
    return contenido.substring(0, 100)
  }
}

class Informe extends Documento implements Auditable, Exportable {
  readonly titulo: string
  private historial: string[] = []
  private secciones: string[]

  constructor(titulo: string, secciones: string[]) {
    super()
    this.titulo = titulo
    this.secciones = secciones
    this.historial.push(`Creado: ${titulo}`)
  }

  obtenerContenido(): string {
    return this.secciones.join("\n")
  }

  obtenerHistorial(): string[] {
    return [...this.historial]
  }

  exportarJSON(): string {
    return JSON.stringify({ titulo: this.titulo, secciones: this.secciones })
  }
}

Override y visibilidad en herencia

Palabra clave override

La palabra clave override indica explícitamente que un método sobreescribe a uno de la clase padre. TypeScript verifica que el método efectivamente exista en la clase base, lo que previene errores por nombres mal escritos:

class Animal {
  nombre: string

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

  emitirSonido(): string {
    return "..."
  }

  mover(distancia: number): string {
    return `${this.nombre} se movio ${distancia} metros`
  }
}

class Perro extends Animal {
  constructor(nombre: string, private raza: string) {
    super(nombre)
  }

  override emitirSonido(): string {
    return "Guau!"
  }

  override mover(distancia: number): string {
    return `${this.nombre} (${this.raza}) corrio ${distancia} metros`
  }

  // override nadar(): string { } // Error: This member cannot have an 'override' modifier
  // because it is not declared in the base class 'Animal'
}

const perro = new Perro("Rex", "Pastor Aleman")
console.log(perro.emitirSonido()) // "Guau!"
console.log(perro.mover(50)) // "Rex (Pastor Aleman) corrio 50 metros"

Se puede activar la opción noImplicitOverride en tsconfig.json para obligar a usar override en toda sobreescritura. De esta forma, si se añade un método en la subclase que coincide con uno de la clase padre sin override, TypeScript emite un error:

// Con noImplicitOverride: true en tsconfig.json
class Gato extends Animal {
  emitirSonido(): string { // Error: This member must have an 'override' modifier
    return "Miau!"         // because it overrides a member in the base class 'Animal'
  }
}

Visibilidad en herencia

Los modificadores de visibilidad se heredan y pueden ampliarse (de protected a public) pero no restringirse (de public a private):

class ComponenteBase {
  protected estado: string = "inactivo"

  protected cambiarEstado(nuevoEstado: string): void {
    this.estado = nuevoEstado
  }

  public obtenerEstado(): string {
    return this.estado
  }
}

class Boton extends ComponenteBase {
  // Ampliamos la visibilidad de protected a public
  public override cambiarEstado(nuevoEstado: string): void {
    super.cambiarEstado(nuevoEstado)
    console.log(`Boton cambio a: ${nuevoEstado}`)
  }

  hacer_click(): void {
    this.cambiarEstado("activo")
  }
}

const boton = new Boton()
boton.hacer_click()
console.log(boton.obtenerEstado()) // "activo"
boton.cambiarEstado("deshabilitado") // Ahora es público

Los miembros private de la clase padre son completamente invisibles para las subclases:

class Servicio {
  private apiKey: string
  protected baseUrl: string

  constructor(apiKey: string, baseUrl: string) {
    this.apiKey = apiKey
    this.baseUrl = baseUrl
  }

  private construirHeaders(): Record<string, string> {
    return { "Authorization": `Bearer ${this.apiKey}` }
  }

  protected realizarPeticion(ruta: string): string {
    const headers = this.construirHeaders()
    return `${this.baseUrl}${ruta} con headers: ${JSON.stringify(headers)}`
  }
}

class ServicioUsuarios extends Servicio {
  constructor(apiKey: string) {
    super(apiKey, "https://api.ejemplo.com")
  }

  obtenerUsuarios(): string {
    // Podemos acceder a baseUrl (protected) y realizarPeticion (protected)
    return this.realizarPeticion("/usuarios")
    // this.apiKey // Error: Property 'apiKey' is private
    // this.construirHeaders() // Error: Property 'construirHeaders' is private
  }
}

Clases genéricas

Las clases genéricas permiten parametrizar el tipo de datos que manejan, creando componentes reutilizables que mantienen la seguridad de tipos:

class Pila<T> {
  private elementos: T[] = []

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

  pop(): T | undefined {
    return this.elementos.pop()
  }

  peek(): T | undefined {
    return this.elementos[this.elementos.length - 1]
  }

  estaVacia(): boolean {
    return this.elementos.length === 0
  }

  tamanio(): number {
    return this.elementos.length
  }
}

const pilaNumeros = new Pila<number>()
pilaNumeros.push(10)
pilaNumeros.push(20)
console.log(pilaNumeros.pop()) // 20

const pilaTextos = new Pila<string>()
pilaTextos.push("primero")
pilaTextos.push("segundo")
console.log(pilaTextos.peek()) // "segundo"

Restricciones en clases genéricas

Se pueden aplicar restricciones a los parámetros genéricos con extends para garantizar que el tipo cumple ciertos requisitos:

interface ConId {
  id: string | number
}

class Repositorio<T extends ConId> {
  private elementos: Map<string | number, T> = new Map()

  guardar(elemento: T): void {
    this.elementos.set(elemento.id, elemento)
  }

  buscarPorId(id: string | number): T | undefined {
    return this.elementos.get(id)
  }

  eliminar(id: string | number): boolean {
    return this.elementos.delete(id)
  }

  obtenerTodos(): T[] {
    return Array.from(this.elementos.values())
  }

  contar(): number {
    return this.elementos.size
  }
}

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

interface Cliente {
  id: string
  nombre: string
  email: string
}

const repoProductos = new Repositorio<Producto>()
repoProductos.guardar({ id: 1, nombre: "Laptop", precio: 999 })
repoProductos.guardar({ id: 2, nombre: "Teclado", precio: 79 })
console.log(repoProductos.buscarPorId(1)) // { id: 1, nombre: "Laptop", precio: 999 }

const repoClientes = new Repositorio<Cliente>()
repoClientes.guardar({ id: "C001", nombre: "Ana", email: "ana@ejemplo.com" })
console.log(repoClientes.contar()) // 1

Clases genéricas con herencia

Las clases genéricas pueden extenderse preservando o concretando los parámetros de tipo:

abstract class ColeccionBase<T> {
  protected items: T[] = []

  abstract filtrar(predicado: (item: T) => boolean): T[]

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

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

class ColeccionOrdenable<T> extends ColeccionBase<T> {
  filtrar(predicado: (item: T) => boolean): T[] {
    return this.items.filter(predicado)
  }

  ordenar(comparador: (a: T, b: T) => number): T[] {
    return [...this.items].sort(comparador)
  }
}

class ColeccionNumerica extends ColeccionBase<number> {
  filtrar(predicado: (item: number) => boolean): number[] {
    return this.items.filter(predicado)
  }

  sumar(): number {
    return this.items.reduce((acc, val) => acc + val, 0)
  }

  promedio(): number {
    if (this.items.length === 0) return 0
    return this.sumar() / this.items.length
  }
}

const nombres = new ColeccionOrdenable<string>()
nombres.agregar("Carlos")
nombres.agregar("Ana")
nombres.agregar("Beatriz")
const ordenados = nombres.ordenar((a, b) => a.localeCompare(b))
console.log(ordenados) // ["Ana", "Beatriz", "Carlos"]

const números = new ColeccionNumerica()
números.agregar(10)
números.agregar(20)
números.agregar(30)
console.log(números.sumar()) // 60
console.log(números.promedio()) // 20

Un ejemplo que combina clases abstractas, genéricos y herencia tipada:

abstract class Procesador<TEntrada, TSalida> {
  abstract procesar(entrada: TEntrada): TSalida

  procesarLote(entradas: TEntrada[]): TSalida[] {
    return entradas.map(entrada => this.procesar(entrada))
  }
}

class ConversorMayusculas extends Procesador<string, string> {
  procesar(entrada: string): string {
    return entrada.toUpperCase()
  }
}

class ParseadorNumeros extends Procesador<string, number> {
  procesar(entrada: string): number {
    const resultado = parseFloat(entrada)
    if (isNaN(resultado)) {
      throw new Error(`No se puede convertir "${entrada}" a número`)
    }
    return resultado
  }
}

interface DatosUsuario {
  nombre: string
  edad: string
  activo: string
}

interface UsuarioValidado {
  nombre: string
  edad: number
  activo: boolean
}

class ValidadorUsuarios extends Procesador<DatosUsuario, UsuarioValidado> {
  procesar(entrada: DatosUsuario): UsuarioValidado {
    return {
      nombre: entrada.nombre.trim(),
      edad: parseInt(entrada.edad, 10),
      activo: entrada.activo === "true"
    }
  }
}

const conversor = new ConversorMayusculas()
console.log(conversor.procesarLote(["hola", "mundo"])) // ["HOLA", "MUNDO"]

const parseador = new ParseadorNumeros()
console.log(parseador.procesarLote(["3.14", "42", "0.5"])) // [3.14, 42, 0.5]

const validador = new ValidadorUsuarios()
const usuarios = validador.procesarLote([
  { nombre: "Ana", edad: "28", activo: "true" },
  { nombre: "Luis", edad: "35", activo: "false" }
])
console.log(usuarios)
// [{ nombre: "Ana", edad: 28, activo: true }, { nombre: "Luis", edad: 35, activo: false }]
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, TypeScript es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de TypeScript

Explora más contenido relacionado con TypeScript y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Comprender las clases abstractas y los métodos abstractos en TypeScript. Diferenciar entre abstract class e interface y saber cuando usar cada una. Tipar jerarquías de herencia con visibilidad controlada. Aplicar la palabra clave override para sobreescribir métodos. Implementar clases genericas con restricciones de tipo.