🇺🇸 EN
Angular
·6 min read·↗·🇺🇸 EN

Entendiendo composición vs. herencia en Angular

Portada principal del artículo: Entendiendo composición vs. herencia en Angular

⚡ ¿Usas Angular moderno (v17+)? Salta directamente a Solución 1: Funciones componibles con inject() para ver cómo la composición moderna elimina el código repetitivo del constructor con super().

Al construir aplicaciones empresariales en Angular, es muy común encontrar lógica duplicada en varios componentes: manejo del envío de formularios, visualización de errores, eventos de analítica o apertura de modales.

Como desarrolladores, nuestro primer instinto suele ser recurrir a la herencia de clases (extends BaseComponent).

Al principio, la herencia parece una solución rápida. Sin embargo, a medida que el proyecto crece, terminas cayendo en el "Constructor Hell": cada componente hijo se ve obligado a inyectar y pasar cinco o seis servicios solo para satisfacer la llamada a super().

En esta guía, te mostraré por qué la herencia falla en Angular y cómo Angular moderno nos ofrece dos alternativas superiores:

  1. Utilidades componibles con inject() y Signals
  2. La Directive Composition API (hostDirectives)

Empecemos analizando la clásica trampa de la herencia.


La trampa de la herencia: duplicación de lógica en formularios 🛑

Imagina que estamos construyendo dos formularios distintos: un componente para la suscripción al newsletter y otro para una lista de espera beta.

Ambos componentes comparten el mismo comportamiento:

  • Un único campo de correo electrónico.
  • Validación de campo requerido y formato de correo.
  • Extracción de estados de error.
  • Un método para guardar o enviar los datos.

El enfoque ingenuo con herencia (extends BaseFormComponent)

Para no repetir la validación ni el manejo de errores, solemos crear una clase base:

// ⚠️ base-form.component.ts
import { Directive } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
 
@Directive()
export abstract class BaseFormComponent {
  errors: string[] = [];
  abstract form: FormGroup;
 
  constructor(
    protected fb: FormBuilder,
    protected logger: LoggerService,
    protected analytics: AnalyticsService
  ) {}
 
  save() {
    if (this.form.invalid) {
      this.extractErrors();
    } else {
      this.errors = [];
      this.submitData();
    }
  }
 
  protected abstract submitData(): void;
 
  private extractErrors() {
    const emailErrors = this.form.get('email')?.errors;
    this.errors = emailErrors ? Object.keys(emailErrors) : [];
  }
}

Ahora veamos qué pasa cuando el componente NewsletterComponent hereda de esta clase base:

// ⚠️ newsletter.component.ts
@Component({
  selector: 'app-newsletter',
  templateUrl: './newsletter.component.html'
})
export class NewsletterComponent extends BaseFormComponent {
  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]]
  });
 
  constructor(
    fb: FormBuilder,
    logger: LoggerService,
    analytics: AnalyticsService,
    private newsletterService: NewsletterService // Dependencia propia del componente hijo
  ) {
    super(fb, logger, analytics); // 😫 Código repetitivo en el constructor!
  }
 
  protected submitData() {
    this.newsletterService.subscribe(this.form.value.email);
  }
}

Por qué esto se convierte en una pesadilla de mantenimiento:

  1. Constructor Hell: Si BaseFormComponent necesita un nuevo servicio (por ejemplo, NotificationService), debes actualizar absolutamente todos los componentes hijos de tu aplicación para pasar esa dependencia a super().
  2. No hay herencia de plantillas ni estilos: Los componentes de Angular NO heredan las plantillas HTML ni los estilos CSS de las clases padre.
  3. Jerarquía rígida: En TypeScript no existe la herencia múltiple, por lo que no puedes extender más de una clase base.

Veamos ahora las soluciones modernas y desacopladas.


Solución 1: Funciones componibles con inject() 🧩

En Angular moderno, podemos usar la función inject() en inicializadores directos para extraer comportamientos reusables sin recurrir a la herencia de clases:

// use-email-form.ts
import { inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
 
export function createEmailForm(onSubmit: (email: string) => void) {
  const fb = inject(FormBuilder);
  const analytics = inject(AnalyticsService);
 
  const form = fb.group({
    email: ['', [Validators.required, Validators.email]]
  });
 
  const handleSubmit = () => {
    if (form.valid) {
      analytics.track('form_submitted');
      onSubmit(form.value.email!);
    }
  };
 
  return { form, handleSubmit };
}

En el componente:

@Component({
  selector: 'app-newsletter',
  template: `
    <form (ngSubmit)="emailForm.handleSubmit()">
      <input type="email" [formControl]="emailForm.form.controls.email" />
      <button type="submit">Subscribe</button>
    </form>
  `
})
export class NewsletterComponent {
  private newsletterService = inject(NewsletterService);
 
  emailForm = createEmailForm((email) => {
    this.newsletterService.subscribe(email);
  });
}

Cero argumentos en el constructor, cero llamadas a super() y una lógica completamente componible en cualquier componente.

Ahora revisemos una herramienta clave para componer comportamientos en la interfaz de usuario: la Directive Composition API.


Solución 2: La Directive Composition API (hostDirectives) 🚀

¿Qué pasa cuando necesitas reutilizar comportamientos visuales o del DOM (como atajos de teclado, tooltips, estados de validación o efectos visuales) en varios componentes sin usar herencia?

Para estos casos, Angular ofrece hostDirectives:

// 1. Definimos una directiva standalone con el comportamiento
@Directive({
  selector: '[appFormValidator]',
  standalone: true
})
export class FormValidatorDirective {
  // Encapsula estilos de validación y mensajes de error
}
 
// 2. La componemos directamente en el host del componente
@Component({
  selector: 'app-waiting-list',
  standalone: true,
  templateUrl: './waiting-list.component.html',
  hostDirectives: [
    FormValidatorDirective,
    {
      directive: AnalyticsTrackingDirective,
      inputs: ['eventName: trackingEvent'] // Exponemos los inputs de la directiva
    }
  ]
})
export class WaitingListComponent {
  // Hereda todo el comportamiento del DOM de FormValidatorDirective y AnalyticsTrackingDirective
}

Ventajas clave de hostDirectives:

  • Composición múltiple: Un componente puede componer cinco directivas diferentes en su elemento host.
  • Contratos de API explícitos: Puedes renombrar o exponer propiedades específicas de @Input() y @Output() de la directiva host.
  • Aisladas y con soporte para tree-shaking: No hay estado mutable compartido entre clases.

Comparación: herencia vs. composición 📊

CriterioHerencia de clases (extends)Composición con directivas (hostDirectives e inject())
Inyección de dependenciasFrágil paso de argumentos con super(a, b, c) en el constructorLlamadas limpias y locales con inject()
Múltiples comportamientos❌ Solo herencia simple✅ Permite componer múltiples directivas y utilidades
Seguridad al refactorizarBaja (cambiar la clase base rompe todos los hijos)Alta (comportamientos aislados y modulares)
Comportamiento en plantilla / DOM❌ No se heredan plantillas ni elementos host✅ Vinculación directa al elemento host del DOM

Resumen 🛠️

En el desarrollo moderno con Angular:

  1. Prioriza la composición sobre la herencia. Considera la herencia solo como último recurso.
  2. Usa funciones auxiliares componibles con inject() para compartir lógica de negocio, configuración de formularios y flujos de estado.
  3. Aprovecha la Directive Composition API (hostDirectives) para asociar comportamientos interactivos directamente a tus componentes.

Si quieres conocer más patrones de arquitectura moderna en Angular, revisa mis artículos sobre preguntas esenciales de Angular para entrevistas y cómo combinar async pipes en Angular.

Parte de la serie Angular

These are my experiences learning and facing my daily challenges working with Angular.

Ver serie completa

Preguntas Frecuentes

¿Por qué la herencia genera problemas en los componentes de Angular?

La herencia crea un acoplamiento fuerte y 'constructor hell' (pasar múltiples dependencias inyectadas a super()), dificulta el refactorizado y no hereda el marcado de las plantillas ni los estilos CSS.

¿Qué es la Directive Composition API (hostDirectives) en Angular?

La Directive Composition API permite que componentes y directivas apliquen otras directivas a su elemento host de forma programática mediante la propiedad hostDirectives en el decorador @Component o @Directive, sin herencia.

¿Cómo mejora la función inject() la composición frente a la herencia?

La función inject() permite crear funciones auxiliares reusables y componibles que obtienen sus propias dependencias del contexto de inyección activo, sin obligar a las clases hijas a pasar parámetros mediante el constructor.

¿Es posible componer estado usando Signals en lugar de clases base en Angular?

Sí, las funciones componibles y los servicios basados en Signals proporcionan un estado reactivo modular (signals, computeds y effects) que cualquier componente Standalone puede consumir mediante inject() sin jerarquías de clases.

Artículos Relacionados

Compartir este artículo

Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.


Software real. Lecciones reales.

Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.

User avatar
User avatar
User avatar
User avatar
+13K

Únete a 13,800+ desarrolladores y lectores.

Cero spam. Cancela tu suscripción en cualquier momento.