Módulo 07: Directivas y pipes

Objetivos del módulo

  • Distinguir los tres tipos de directiva y saber qué aporta cada uno
  • Escribir directivas de atributo con @Directive, input(), output() y exportAs
  • Enlazar propiedades, clases, estilos y eventos del elemento con la opción host
  • Tocar el DOM con ElementRef y Renderer2 solo cuando haga falta, y limpiar lo que crees
  • Componer comportamiento con hostDirectives, exponiendo entradas y salidas con el nombre que quieras
  • Entender qué hace realmente el asterisco de *appAlgo y escribir directivas estructurales con TemplateRef y ViewContainerRef
  • Dar tipos al contexto de una plantilla con ngTemplateContextGuard
  • Usar los pipes integrados con sus parámetros y conocer sus trampas
  • Escribir pipes propios, puros e impuros, y decidir entre un pipe y un computed
  • Trabajar con AsyncPipe sin duplicar peticiones
  • Configurar LOCALE_ID, la moneda y la zona horaria, y apoyarte en Intl para lo que Angular no trae
  • Diagnosticar los errores más habituales con directivas y pipes

1. Qué es una directiva

Una directiva es una clase que Angular conecta con un elemento de la plantilla cuando ese elemento coincide con un selector CSS. Una vez conectada, puede cambiar el aspecto del elemento, reaccionar a sus eventos o decidir si ese trozo de plantilla se dibuja.

Ya has usado directivas aunque no las llamaras así. Un componente es una directiva con plantilla propia. RouterLink, que verás en el módulo 10, es una directiva sobre <a>. NgTemplateOutlet, del módulo 04, también lo es.

Tipo Tiene plantilla Se usa Para qué sirve Ejemplo
Componente Sí <app-boton> Una pieza de interfaz con su propio HTML App, Saludo
Directiva de atributo No <p appResaltar> Añadir comportamiento o aspecto a un elemento que ya existe NgClass, RouterLink
Directiva estructural No <li *appSiRol="'ADMIN'"> Decidir si un trozo de plantilla se crea, y cuántas veces NgTemplateOutlet, NgComponentOutlet

Tres tipos de directiva: el componente aporta su propia plantilla, la directiva de atributo se engancha a un elemento existente y la directiva estructural trabaja sobre un ng-template

Figura 1. Los tres tipos de directiva y sobre qué actúa cada uno.

¿Por qué no resolverlo todo con componentes? Porque un componente es dueño de su HTML, y muchas veces no quieres otro elemento, sino enseñar algo nuevo a uno que ya tienes. Si quieres que cualquier cosa muestre un tooltip, envolverlo todo en <app-con-tooltip> ensucia el árbol, rompe selectores CSS como ul > li y obliga a reenviar atributos. Una directiva se pone encima y listo.

📘 Concepto: Un componente es algo en la pantalla. Una directiva añade algo a lo que ya está en la pantalla. Un elemento puede tener como mucho un componente, pero todas las directivas que quieras.

Selectores de directiva

El selector es un selector CSS, aunque Angular solo admite un subconjunto: elemento, atributo, clase, :not() y listas separadas por comas. No admite combinadores como div > p ni pseudoclases como :hover.

Selector Coincide con Cuándo usarlo
[appResaltar] Cualquier elemento con ese atributo El caso habitual
input[appSoloNumeros] Solo <input> con ese atributo La directiva solo tiene sentido en un tipo de elemento
input:not([type=checkbox])[appLimpio] Inputs que no sean casillas Excluir casos
a[appExterno], button[appExterno] Cualquiera de los dos Admitir varios elementos

El prefijo app evita choques con atributos del navegador o de librerías. No uses el prefijo ng, reservado para Angular. Un selector como [title], sin prefijo, se activaría en todos los elementos con title: a veces es lo que se busca, pero casi nunca al empezar.

Igual que un componente, una directiva solo existe en una plantilla si la importas en imports. Si olvidas importarla, Angular trata appResaltar como un atributo HTML normal y no da ningún error, solo no pasa nada. La sección 12 explica cómo detectarlo.

Generarlas con el CLI

ng generate directive directivas/resaltar   # src/app/directivas/resaltar.ts, clase Resaltar
ng generate pipe pipes/iniciales            # src/app/pipes/iniciales-pipe.ts, clase InicialesPipe

Fíjate en la convención: las directivas van sin sufijo, como los componentes del módulo 03, pero el CLI sí añade Pipe a los pipes y -pipe a su fichero. Mantendremos esos nombres porque son los que verás en cualquier proyecto actual.


2. Tu primera directiva de atributo

Vamos a escribir appResaltar, que colorea un elemento cuando el ratón pasa por encima. Primero, la versión que escribiría alguien que viene de JavaScript sin framework:

// Versión imperativa, para comparar. No la uses.
import { Directive, ElementRef, inject } from '@angular/core';

@Directive({ selector: '[appResaltar]' })
export class ResaltarImperativo {
  constructor() {
    const elemento = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
    elemento.addEventListener('mouseenter', () => {
      elemento.style.backgroundColor = '#fff59d';
    });
    elemento.addEventListener('mouseleave', () => {
      elemento.style.backgroundColor = '';
    });
  }
}

ElementRef es un envoltorio del elemento donde está la directiva, y nativeElement es el elemento real del DOM. El código funciona, pero tiene pegas: los detectores de eventos los gestionas tú, el color no se puede cambiar desde fuera y la lógica vive dentro del constructor. Además, en el servidor (SSR, módulo 24) no hay un DOM de navegador completo.

La versión idiomática declara qué quiere y deja que Angular lo haga:

// src/app/directivas/resaltar.ts
import { Directive, input, signal } from '@angular/core';

@Directive({
  selector: '[appResaltar]',
  host: {
    '[style.backgroundColor]': 'encima() ? color() : null',
    '(mouseenter)': 'encima.set(true)',
    '(mouseleave)': 'encima.set(false)',
  },
})
export class Resaltar {
  // El atributo vacío llega como '': en ese caso usamos el color por defecto
  readonly color = input('#fff59d', {
    alias: 'appResaltar',
    transform: (valor: string) => valor || '#fff59d',
  });

  protected readonly encima = signal(false);
}

Y se usa así:

<p appResaltar>Color por defecto</p>
<p appResaltar="#b3e5fc">Color fijo</p>
<p [appResaltar]="colorElegido()">Color dinámico</p>

Aquí hay tres ideas:

  • host enlaza propiedades y eventos del elemento anfitrión, igual que harías en una plantilla. Las expresiones son texto y se comprueban en compilación.
  • El alias permite que la entrada se llame como el selector, de modo que el mismo atributo activa la directiva y le pasa el valor. Dentro de la clase sigue siendo color.
  • transform resuelve una trampa: <p appResaltar>, sin valor, pasa la cadena vacía, no undefined. Sin el transform, el valor por defecto del input() no se aplicaría nunca y el fondo sería ''.

Los miembros que usa host pueden ser protected, como los que usa una plantilla. Las señales se comportan igual que en un componente: si encima cambia, Angular actualiza solo ese enlace. Puedes repasar input() y sus transformaciones en el módulo 06.

Cómo lo conecta Angular

El compilador sabe, por los imports del componente, qué directivas pueden aparecer en su plantilla. Al compilar, comprueba cada elemento contra sus selectores y genera el código que crea la instancia. Por eso cada elemento que coincide tiene su propia instancia de la directiva: tres párrafos con appResaltar son tres objetos, con tres señales encima distintas.

Y por eso las directivas se resuelven en compilación. Un atributo añadido después con setAttribute('appResaltar', '') no activa nada: la coincidencia no se calcula sobre el DOM real, sino sobre la plantilla.

⚠️ Importante: Una directiva no se enciende por añadir el atributo en tiempo de ejecución. Tampoco se aplica al HTML que insertas con [innerHTML], porque ese HTML no pasa por el compilador.


3. La opción host a fondo

host admite todo lo que admite un elemento en una plantilla:

Clave en host Efecto Ejemplo
class Clases fijas class: 'tarjeta'
[class.x] Clase condicional '[class.activa]': 'activa()'
[class] Varias clases dinámicas '[class]': 'clases()'
[style.x] Estilo dinámico '[style.width.px]': 'ancho()'
[attr.x] Atributo HTML '[attr.aria-expanded]': 'abierto()'
[propiedad] Propiedad del DOM '[disabled]': 'bloqueado()'
(evento) Evento del anfitrión '(click)': 'pulsar($event)'
(keydown.enter) Evento de teclado filtrado '(keydown.enter)': 'pulsar($event)'
(document:click) Evento global '(document:click)': 'cerrar($event)'
(window:resize) Evento global '(window:resize)': 'medir()'
atributo Atributo estático role: 'button'

Fíjate en que un enlace [attr.x] con null o undefined elimina el atributo, y lo mismo hace null con un estilo. Así se quita algo que antes estaba puesto. Con false el atributo se queda escrito como aria-disabled="false".

💡 Consejo: En código antiguo verás @HostBinding('class.activa') y @HostListener('click'). Siguen funcionando, pero la opción host es la forma recomendada: las expresiones se comprueban con el mismo rigor que una plantilla y todo se lee en un sitio. El módulo 28 trata el código clásico.

Quién gana cuando dos enlaces pisan el mismo estilo

Si la plantilla y una directiva escriben el mismo estilo, no gana el último que se ejecuta. Angular sigue un orden fijo, de más a menos prioridad:

  1. El enlace de la plantilla sobre esa propiedad: [style.background-color]="...".
  2. Los enlaces host de las directivas.
  3. Los enlaces host del componente del elemento.
<!-- Se ve rojo claro aunque pases el ratón: la plantilla gana a la directiva -->
<p appResaltar [style.background-color]="'#ffcdd2'">Con estilo propio</p>

Esta regla te permite que un consumidor corrija lo que hace tu directiva sin tocarla, y es la razón de que no debas usar ElementRef para estilos: lo que escribes a mano no entra en este orden.

output(): una directiva que avisa

Una directiva también puede emitir eventos. Esta detecta los clics fuera del elemento, que es lo que necesita un menú desplegable para cerrarse:

// src/app/directivas/clic-fuera.ts
import { Directive, ElementRef, inject, output } from '@angular/core';

@Directive({
  selector: '[appClicFuera]',
  host: { '(document:click)': 'comprobar($event)' },
})
export class ClicFuera {
  readonly appClicFuera = output<void>();

  private readonly anfitrion = inject<ElementRef<HTMLElement>>(ElementRef);

  protected comprobar(evento: MouseEvent) {
    if (!this.anfitrion.nativeElement.contains(evento.target as Node)) {
      this.appClicFuera.emit();
    }
  }
}

Aquí ElementRef está justificado: solo lee, y lo que quieres saber depende del DOM real. El detector de document lo retira Angular al destruir la directiva.

exportAs: la plantilla toma la directiva

A veces la plantilla necesita hablar con la directiva. exportAs da un nombre que se usa en una variable de plantilla:

// src/app/directivas/alternar.ts
import { Directive, signal } from '@angular/core';

@Directive({
  selector: '[appAlternar]',
  exportAs: 'appAlternar',
  host: {
    type: 'button',
    '[attr.aria-expanded]': 'abierto()',
    '(click)': 'alternar()',
  },
})
export class Alternar {
  readonly abierto = signal(false);

  alternar() {
    this.abierto.update((valor) => !valor);
  }

  cerrar() {
    this.abierto.set(false);
  }
}
<div class="menu" (appClicFuera)="opciones.cerrar()">
  <button appAlternar #opciones="appAlternar">Acciones</button>
  @if (opciones.abierto()) {
    <ul>
      <li>Editar</li>
      <li>Duplicar</li>
    </ul>
  }
</div>

Sin ="appAlternar", #opciones apuntaría al <button>, no a la directiva. Fíjate también en dónde está appClicFuera: en el contenedor que incluye el botón. Si lo pones en la lista, el mismo clic que abre el menú llega a document, cae “fuera” de la lista y cierra el menú en el acto, o según el orden puedes tener un parpadeo difícil de depurar.

Autofoco: esperar a que el elemento esté listo

// src/app/directivas/autofoco.ts
import { Directive, ElementRef, afterNextRender, inject } from '@angular/core';

@Directive({ selector: 'input[appAutofoco], textarea[appAutofoco]' })
export class Autofoco {
  constructor() {
    const campo = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
    afterNextRender(() => campo.focus());
  }
}

En el constructor, el elemento existe pero todavía no está en el documento. focus() no haría nada. afterNextRender, del módulo 03, espera al primer dibujado y además no se ejecuta en el servidor. El atributo HTML autofocus no sirve aquí: el navegador lo aplica al cargar la página, no cuando Angular inserta un elemento más tarde.

Renderer2: crear nodos y limpiar

Para escribir estilos y clases, host es mejor. Para crear elementos necesitas otra cosa. Renderer2 es la capa de Angular sobre el DOM: crea nodos sin tocar document y funciona también en el servidor. Este tooltip crea su caja al entrar el ratón, y la retira al salir o al destruirse la directiva:

// src/app/directivas/tooltip.ts
import { Directive, DOCUMENT, DestroyRef, ElementRef, Renderer2, inject, input } from '@angular/core';

@Directive({
  selector: '[appTooltip]',
  host: {
    '(mouseenter)': 'mostrar()',
    '(mouseleave)': 'ocultar()',
    '(focusin)': 'mostrar()',
    '(focusout)': 'ocultar()',
  },
})
export class Tooltip {
  readonly appTooltip = input.required<string>();

  private readonly renderer = inject(Renderer2);
  private readonly documento = inject(DOCUMENT);
  private readonly anfitrion = inject<ElementRef<HTMLElement>>(ElementRef);
  private caja: HTMLElement | null = null;

  constructor() {
    inject(DestroyRef).onDestroy(() => this.ocultar());
  }

  protected mostrar() {
    if (this.caja) {
      return;
    }
    const caja: HTMLElement = this.renderer.createElement('div');
    this.renderer.addClass(caja, 'tooltip');
    this.renderer.setAttribute(caja, 'role', 'tooltip');
    this.renderer.appendChild(caja, this.renderer.createText(this.appTooltip()));

    const rect = this.anfitrion.nativeElement.getBoundingClientRect();
    this.renderer.setStyle(caja, 'left', `${rect.left + window.scrollX}px`);
    this.renderer.setStyle(caja, 'top', `${rect.bottom + window.scrollY + 6}px`);

    this.renderer.appendChild(this.documento.body, caja);
    this.caja = caja;
  }

  protected ocultar() {
    if (this.caja) {
      this.renderer.removeChild(this.documento.body, this.caja);
      this.caja = null;
    }
  }
}

La caja se añade a <body>, fuera del componente, así que sus estilos deben ser globales, ya que la encapsulación del módulo 03 no llega a ella:

/* src/styles.css */
.tooltip {
  position: absolute;
  padding: 4px 8px;
  border-radius: 4px;
  background: #263238;
  color: #fff;
  font-size: 13px;
}

ocultar() en DestroyRef no es un detalle: si un @if elimina el botón mientras el ratón está encima, nunca llegará el mouseleave, y sin esa línea la caja quedaría huérfana en la página.

💡 Consejo: Antes de escribir tu propio tooltip para producción, mira MatTooltip y el overlay del CDK (módulo 20), que resuelven posición, desbordamiento y accesibilidad. El ejemplo es para aprender el patrón crear-y-limpiar.

Demostración con un párrafo resaltado en verde, otro en rojo por su estilo propio, un menú desplegado y un tooltip visible bajo un botón

Figura 2. Las directivas de atributo en funcionamiento. Se ve un párrafo resaltado, el que manda su color por [appResaltar], el estilo de la plantilla que gana a la directiva, el menú abierto con exportAs y el tooltip creado con Renderer2.


4. Composición con hostDirectives

Imagina que varios componentes de EMPRESA deben comportarse como botones: enfocarse con el teclado, pulsarse con Intro o Espacio y admitir el estado deshabilitado. Con herencia acabarías con una clase base gigante, y TypeScript solo permite una. Con la opción hostDirectives, cada comportamiento es una directiva pequeña y un componente se las pone encima en su definición, sin que quien lo usa las escriba.

Empezamos por las piezas:

// src/app/directivas/enfocable.ts
import { Directive, signal } from '@angular/core';

@Directive({
  selector: '[appEnfocable]',
  host: {
    '[class.con-foco]': 'enfocado()',
    '(focus)': 'enfocado.set(true)',
    '(blur)': 'enfocado.set(false)',
  },
})
export class Enfocable {
  readonly enfocado = signal(false);
}
// src/app/directivas/deshabilitable.ts
import { Directive, booleanAttribute, input } from '@angular/core';

@Directive({
  selector: '[appDeshabilitable]',
  host: {
    '[attr.aria-disabled]': 'deshabilitado() ? "true" : null',
    '[attr.tabindex]': 'deshabilitado() ? -1 : 0',
    '[class.deshabilitado]': 'deshabilitado()',
  },
})
export class Deshabilitable {
  readonly deshabilitado = input(false, { transform: booleanAttribute });
}
// src/app/directivas/pulsable.ts
import { Directive, inject, output } from '@angular/core';
import { Deshabilitable } from './deshabilitable';

@Directive({
  selector: '[appPulsable]',
  host: {
    '(click)': 'pulsar()',
    '(keydown.enter)': 'pulsar()',
    '(keydown.space)': 'pulsar(); $event.preventDefault()',
  },
})
export class Pulsable {
  readonly pulsado = output<void>();

  // Puede estar o no en el mismo elemento
  private readonly deshabilitable = inject(Deshabilitable, { optional: true, self: true });

  protected pulsar() {
    if (!this.deshabilitable?.deshabilitado()) {
      this.pulsado.emit();
    }
  }
}

Pulsable muestra cómo cooperan dos directivas del mismo elemento: una inyecta a la otra. self: true limita la búsqueda a este elemento, para no encontrar por error la de un antepasado. optional: true permite usar Pulsable sola.

Ahora las componemos en un componente:

// src/app/componentes/boton.ts
import { Component } from '@angular/core';
import { Deshabilitable } from '../directivas/deshabilitable';
import { Enfocable } from '../directivas/enfocable';
import { Pulsable } from '../directivas/pulsable';

@Component({
  selector: 'app-boton',
  template: `<ng-content />`,
  hostDirectives: [
    Enfocable,
    { directive: Deshabilitable, inputs: ['deshabilitado'] },
    { directive: Pulsable, outputs: ['pulsado'] },
  ],
  host: { role: 'button' },
  styles: `
    :host {
      display: inline-block;
      padding: 0.5rem 1rem;
      border-radius: 6px;
      background: #3949ab;
      color: #fff;
      cursor: pointer;
      user-select: none;
    }
    :host(.con-foco) {
      outline: 3px solid #ff7043;
      outline-offset: 2px;
    }
    :host(.deshabilitado) {
      background: #b0bec5;
      cursor: not-allowed;
    }
  `,
})
export class Boton {}
<app-boton (pulsado)="guardar()">Guardar</app-boton>
<app-boton deshabilitado (pulsado)="guardar()">Sin permiso</app-boton>

Quien usa <app-boton> no sabe que hay tres directivas detrás. Solo ve la entrada deshabilitado y la salida pulsado.

Componente Boton con tres directivas anfitrionas: Enfocable sin exponer nada, Deshabilitable que expone su entrada y Pulsable que expone su salida; la plantilla padre solo ve esas dos

Figura 3. Lo que queda dentro y lo que se expone. Las entradas y salidas de una directiva anfitriona son privadas salvo que las publiques.

Las reglas de hostDirectives

  • Lo no expuesto no existe para el consumidor. Si quitas inputs: ['deshabilitado'], escribir [deshabilitado]="x" da error de compilación.
  • Se pueden renombrar: inputs: ['deshabilitado: bloqueado'] publica la entrada como bloqueado, y outputs: ['pulsado: accion'] igual con la salida. Así evitas un nombre que choque con el de tu componente.
  • El selector de la directiva se ignora. La directiva se aplica siempre, tenga o no el elemento ese atributo.
  • Las directivas se crean antes que su anfitrión. Por eso el componente puede hacer inject(Pulsable) en su constructor, y por eso sus enlaces host tienen menos prioridad que los del componente.
  • Solo directivas standalone, no componentes. Todo lo escrito sin NgModule ya lo es.
  • Se resuelven en compilación. La lista es estática: no se puede añadir una directiva según una condición.

Componer directivas con directivas

Una directiva también puede tener hostDirectives. Así agrupas un conjunto para reutilizarlo sobre cualquier elemento:

// src/app/directivas/interactivo.ts
import { Directive } from '@angular/core';
import { Deshabilitable } from './deshabilitable';
import { Enfocable } from './enfocable';
import { Pulsable } from './pulsable';

@Directive({
  selector: '[appInteractivo]',
  hostDirectives: [
    Enfocable,
    { directive: Deshabilitable, inputs: ['deshabilitado'] },
    { directive: Pulsable, outputs: ['pulsado'] },
  ],
  host: { role: 'button' },
})
export class Interactivo {}
<div appInteractivo [deshabilitado]="bloqueado()" (pulsado)="abrirFicha()">Ver ficha</div>

El coste es real pero pequeño: cada directiva es un objeto por elemento. En una fila de tabla repetida 10 000 veces, cuatro directivas son 40 000 instancias. Para una interfaz normal da igual, pero no compongas diez niveles “por si acaso”.

Dos botones app-boton, uno activo con el contorno de foco y otro gris deshabilitado, junto a un contador de pulsaciones

Figura 4. Boton con foco de teclado y deshabilitado. Ambos estados salen de directivas anfitrionas.

La misma directiva por dos caminos 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21): allí, una directiva que llega dos veces al mismo elemento da en ejecución NG0309: Directive matches multiple times on the same element. Si usas la LTS: asegúrate de que cada directiva llega por un solo camino, por ejemplo exponiendo sus entradas en una directiva agrupadora y no volviendo a aplicarla.

Con piezas reutilizables es fácil que se repita una directiva. Interactivo incluye Enfocable, y alguien puede escribir esto:

<!-- Enfocable llega por la plantilla y como directiva anfitriona de Interactivo -->
<div appInteractivo appEnfocable>Doble camino</div>

Desde Angular 22 no es un error. Solo se crea una instancia, según estas reglas:

  1. Si coincide en la plantilla y además como directiva anfitriona, manda la de la plantilla, y se descartan las anfitrionas. La idea es que una directiva anfitriona es una versión parcial, y la de la plantilla es la completa.
  2. Si coincide varias veces como directiva anfitriona, se fusionan: la instancia es una y sus entradas y salidas expuestas se suman.
  3. Si al fusionar la misma entrada queda publicada con dos nombres distintos, es un error, porque no se sabría cuál usar. Lo veremos en la sección 12.

5. Las directivas integradas que siguen vigentes

@if, @for y @switch no son directivas, sino sintaxis del lenguaje de plantillas. Las directivas *ngIf, *ngFor y *ngSwitch están obsoletas desde Angular 20: las verás en código antiguo, y el módulo 28 explica cómo leerlas. Estas otras siguen en uso:

Directiva Para qué sirve Alternativa
NgClass Clases desde un objeto, array o cadena [class]="objeto" hace lo mismo sin importar nada
NgStyle Estilos desde un objeto [style]="objeto"
NgTemplateOutlet Pintar un ng-template en otro sitio, con contexto No tiene
NgComponentOutlet Pintar un componente elegido en ejecución ViewContainerRef.createComponent
NgPlural Elegir plantilla según un número @switch o i18nPlural

Para el trabajo diario, prefiere los enlaces nativos:

<!-- Con enlaces nativos no hace falta importar nada -->
<tr [class.inactivo]="!empleado.activo" [class]="clasesExtra()">...</tr>
<div [style]="{ width: ancho() + 'px', 'background-color': color() }"></div>

NgTemplateOutlet lo presentamos en el módulo 04 y NgComponentOutlet en el módulo 03. Ambos son directivas estructurales normales, y con lo que viene a continuación entenderás cómo funcionan.


6. Directivas estructurales

Qué hace el asterisco

Una directiva estructural no trabaja sobre un elemento, sino sobre una plantilla sin dibujar. El asterisco es una abreviatura: el compilador envuelve el elemento en un <ng-template> y pasa los valores como entradas de esa plantilla.

<!-- Lo que escribes -->
<li *appSiRol="'ADMIN'; sino: denegado">Borrar empleado</li>

<!-- Lo que el compilador entiende -->
<ng-template [appSiRol]="'ADMIN'" [appSiRolSino]="denegado">
  <li>Borrar empleado</li>
</ng-template>

Un <ng-template> no dibuja nada, como viste en el módulo 04. Lo que dibuja es el código de la directiva. Y lo hace con dos piezas que recibe por inyección:

  • TemplateRef: la plantilla, como un molde que todavía no ha producido nada.
  • ViewContainerRef: un punto de la página donde se pueden colocar copias de ese molde. Cada copia es una vista embebida.

El elemento con asterisco se transforma en un ng-template, la directiva recibe TemplateRef y ViewContainerRef y crea vistas embebidas en el contenedor

Figura 5. De *appSiRol a la vista creada. La abreviatura pasa por ng-template, y la directiva decide cuántas copias crear.

La parte tras el asterisco se llama microsintaxis, y sigue unas reglas concretas:

Fragmento Significa
*appSiRol="'ADMIN'" Primera expresión: entrada appSiRol
sino: denegado Entrada appSiRolSino, es decir, selector + clave con mayúscula inicial
let lista Variable con el valor $implicit del contexto
let i = indice Variable con la propiedad indice del contexto
as lista Variable con el valor del contexto cuya clave es el nombre de la directiva

El prefijo es lo que se suele olvidar. La clave sino no se conecta con una entrada sino, sino con appSiRolSino. Si declaras mal la entrada, el compilador te avisa.

*appSiRol: mostrar según el permiso

De momento guardamos el rol en una señal exportada. En el módulo 08 verás que ese sitio le corresponde a un servicio.

// src/app/directivas/sesion.ts
import { signal } from '@angular/core';

export type Rol = 'ADMIN' | 'USER' | 'INVITADO';

export const rolActual = signal<Rol>('USER');
// src/app/directivas/si-rol.ts
import { Directive, TemplateRef, ViewContainerRef, computed, effect, inject, input } from '@angular/core';
import { Rol, rolActual } from './sesion';

@Directive({ selector: '[appSiRol]' })
export class SiRol {
  readonly appSiRol = input.required<Rol | Rol[]>();
  readonly appSiRolSino = input<TemplateRef<unknown> | null>(null);

  private readonly plantilla = inject(TemplateRef);
  private readonly contenedor = inject(ViewContainerRef);

  private readonly permitido = computed(() => {
    const roles = this.appSiRol();
    return (Array.isArray(roles) ? roles : [roles]).includes(rolActual());
  });

  // Qué plantilla toca: la propia, la alternativa o ninguna
  private readonly elegida = computed(() =>
    this.permitido() ? this.plantilla : this.appSiRolSino(),
  );

  constructor() {
    effect(() => {
      const plantilla = this.elegida();
      this.contenedor.clear();
      if (plantilla) {
        this.contenedor.createEmbeddedView(plantilla);
      }
    });
  }
}
<button *appSiRol="'ADMIN'; sino: sinPermiso">Borrar empleado</button>
<ng-template #sinPermiso><em>Necesitas ser administrador</em></ng-template>

<!-- Varios roles -->
<a *appSiRol="['ADMIN', 'USER']" href="#">Editar</a>

¿Por qué dos computed antes del effect? Porque clear() destruye la vista, con todo su estado: lo escrito en un <input>, el foco, las instancias de componentes. Con elegida, el effect solo se ejecuta cuando cambia la plantilla que toca. Si el rol pasa de USER a ADMIN y ambos están permitidos, elegida devuelve lo mismo y no se destruye nada. Es la igualdad de los computed del módulo 05 trabajando para ti.

⚠️ Importante: Ocultar un botón en el navegador no es seguridad. Cualquiera con las herramientas de desarrollo puede saltarse la directiva, porque el código y las rutas están en su ordenador. La comprobación que vale es la del servidor. La API EMPRESA responde 403 si tu rol no tiene permiso, y esta directiva solo evita enseñar botones inútiles.

*appRepetir: varias vistas y contexto

createEmbeddedView recibe un segundo argumento, el contexto, cuyas propiedades se convierten en variables de la plantilla. $implicit es la que se recoge con un simple let x.

// src/app/directivas/repetir.ts
import { Directive, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';

export interface ContextoRepetir {
  $implicit: number;
  indice: number;
  ultimo: boolean;
}

@Directive({ selector: '[appRepetir]' })
export class Repetir {
  readonly appRepetir = input.required<number>();

  private readonly plantilla = inject<TemplateRef<ContextoRepetir>>(TemplateRef);
  private readonly contenedor = inject(ViewContainerRef);

  // Le dice al compilador de plantillas qué tipo tiene el contexto
  static ngTemplateContextGuard(_dir: Repetir, _ctx: unknown): _ctx is ContextoRepetir {
    return true;
  }

  constructor() {
    effect(() => {
      const veces = this.appRepetir();
      this.contenedor.clear();
      for (let i = 0; i < veces; i++) {
        this.contenedor.createEmbeddedView(this.plantilla, {
          $implicit: i + 1,
          indice: i,
          ultimo: i === veces - 1,
        });
      }
    });
  }
}
<span *appRepetir="3; let n; let u = ultimo">Fila {{ n }}@if (!u) {, }</span>

Esta directiva es útil para pintar filas vacías mientras cargan los datos, como veremos en el caso práctico. Para recorrer datos reales usa siempre @for, que reutiliza vistas con track: aquí se destruyen todas en cada cambio.

ngTemplateContextGuard: tipos dentro de la plantilla

Sin el método estático, la plantilla no sabe qué tiene el contexto. n valdría any y escribir let u = ultmo compilaría sin quejarse. Este método estático no se ejecuta nunca: es una pista para el compilador. Devuelve _ctx is ContextoRepetir, un predicado de tipo del módulo 02, que dice “dentro de esta plantilla, el contexto es de este tipo”.

Se ve mejor con una directiva genérica. Esta muestra una plantilla alternativa mientras no hay datos y entrega los datos con su tipo real:

// src/app/directivas/con-datos.ts
import { Directive, EmbeddedViewRef, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';

export interface ContextoConDatos<T> {
  $implicit: T;
}

@Directive({ selector: '[appConDatos]' })
export class ConDatos<T> {
  readonly appConDatos = input.required<T | null | undefined>();
  readonly appConDatosCargando = input<TemplateRef<unknown> | null>(null);

  private readonly plantilla = inject<TemplateRef<ContextoConDatos<T>>>(TemplateRef);
  private readonly contenedor = inject(ViewContainerRef);
  private vista: EmbeddedViewRef<ContextoConDatos<T>> | null = null;

  static ngTemplateContextGuard<T>(
    _dir: ConDatos<T>,
    _ctx: unknown,
  ): _ctx is ContextoConDatos<T> {
    return true;
  }

  constructor() {
    effect(() => {
      const datos = this.appConDatos();
      const cargando = this.appConDatosCargando();

      if (datos === null || datos === undefined) {
        this.vista = null;
        this.contenedor.clear();
        if (cargando) {
          this.contenedor.createEmbeddedView(cargando);
        }
      } else if (this.vista) {
        // Ya hay vista: se actualiza el contexto sin destruir nada
        this.vista.context.$implicit = datos;
        this.vista.markForCheck();
      } else {
        this.contenedor.clear();
        this.vista = this.contenedor.createEmbeddedView(this.plantilla, { $implicit: datos });
      }
    });
  }
}
<ul *appConDatos="empleados(); let lista; cargando: esqueleto">
  @for (e of lista; track e.empId) {
    <li>{{ e.nombre }}</li>
  }
</ul>
<ng-template #esqueleto><p>Cargando...</p></ng-template>

lista es aquí Empleado[], no Empleado[] | null: T se deduce de la entrada, y null no forma parte de T. Si escribes e.nombr, la compilación falla.

markForCheck() es necesario porque context es un objeto normal, no una señal. Si solo cambias su propiedad, Angular no tiene motivo para volver a revisar esa vista. El módulo 09 explica este mecanismo.

Dos estructurales en un elemento

Un elemento solo admite un asterisco, porque cada uno lo envolvería en una plantilla y el orden sería ambiguo. La solución es <ng-container>, que agrupa sin añadir nada al DOM:

<ng-container *appSiRol="'ADMIN'">
  <span *appRepetir="2; let n">Copia {{ n }} </span>
</ng-container>

Con el control de flujo de bloques este problema no aparece: @if y @for se anidan de forma natural.

Demostración de directivas estructurales: selector de rol, botón de borrar o aviso sin permiso, filas repetidas y lista cargada con plantilla alternativa

Figura 6. *appSiRol, *appRepetir y *appConDatos. Al cambiar el rol, la vista cambia sin tocar la plantilla.


7. Pipes integrados

Un pipe es una función con nombre que transforma un valor al mostrarlo: {{ fecha | date }}. El dato original no cambia, igual que en una tubería de Unix, y se pueden encadenar.

Por qué no llamar a un método desde la plantilla, {{ formatear(fecha) }}: porque un pipe es reutilizable, se declara una vez, y si es puro Angular evita recalcularlo. Un método se llama en cada revisión de la vista.

Todos los pipes están en @angular/common y hay que importarlos uno a uno en imports.

// src/app/demos/demo-pipes.ts
import {
  CurrencyPipe,
  DatePipe,
  DecimalPipe,
  I18nPluralPipe,
  I18nSelectPipe,
  JsonPipe,
  KeyValuePipe,
  LowerCasePipe,
  PercentPipe,
  SlicePipe,
  TitleCasePipe,
  UpperCasePipe,
} from '@angular/common';
import { Component } from '@angular/core';

@Component({
  selector: 'app-demo-pipes',
  imports: [
    CurrencyPipe, DatePipe, DecimalPipe, I18nPluralPipe, I18nSelectPipe, JsonPipe,
    KeyValuePipe, LowerCasePipe, PercentPipe, SlicePipe, TitleCasePipe, UpperCasePipe,
  ],
  templateUrl: './demo-pipes.html',
})
export class DemoPipes {
  protected readonly salario = 2450.5;
  protected readonly comision = 0.0825;
  protected readonly alta = '2010-01-15';
  protected readonly momento = new Date(2026, 10, 2, 9, 5);
  protected readonly nombre = 'ana garcía lópez';
  protected readonly oficios = ['Analista', 'Vendedor', 'Director', 'Empleado'];
  protected readonly departamento = { deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia' };
  protected readonly pedidos = 1;
  protected readonly genero = 'f';

  protected readonly mapaPedidos = {
    '=0': 'sin pedidos',
    '=1': 'un pedido',
    other: '# pedidos',
  };
  protected readonly mapaGenero = { f: 'empleada', m: 'empleado', other: 'persona empleada' };
}
<!-- src/app/demos/demo-pipes.html -->
<table>
  <tr><th>Expresión</th><th>Resultado</th></tr>
  <tr><td><code>date</code></td><td>{{ alta | date }}</td></tr>
  <tr><td><code>date:'fullDate'</code></td><td>{{ alta | date: 'fullDate' }}</td></tr>
  <tr><td><code>date:'dd/MM/yyyy HH:mm'</code></td><td>{{ momento | date: 'dd/MM/yyyy HH:mm' }}</td></tr>
  <tr><td><code>date:'EEEE, d MMMM'</code></td><td>{{ momento | date: 'EEEE, d MMMM' }}</td></tr>
  <tr><td><code>number</code></td><td>{{ salario | number }}</td></tr>
  <tr><td><code>number:'1.2-2'</code></td><td>{{ salario | number: '1.2-2' }}</td></tr>
  <tr><td><code>number:'6.0-0'</code></td><td>{{ 42 | number: '6.0-0' }}</td></tr>
  <tr><td><code>currency</code></td><td>{{ salario | currency }}</td></tr>
  <tr><td><code>currency:'USD':'code'</code></td><td>{{ salario | currency: 'USD' : 'code' }}</td></tr>
  <tr><td><code>currency:'EUR':'symbol':'1.0-0'</code></td><td>{{ salario | currency: 'EUR' : 'symbol' : '1.0-0' }}</td></tr>
  <tr><td><code>percent</code></td><td>{{ comision | percent }}</td></tr>
  <tr><td><code>percent:'1.1-2'</code></td><td>{{ comision | percent: '1.1-2' }}</td></tr>
  <tr><td><code>uppercase</code></td><td>{{ nombre | uppercase }}</td></tr>
  <tr><td><code>lowercase</code></td><td>{{ 'INFORMÁTICA' | lowercase }}</td></tr>
  <tr><td><code>titlecase</code></td><td>{{ nombre | titlecase }}</td></tr>
  <tr><td><code>slice:0:2</code></td><td>{{ oficios | slice: 0 : 2 }}</td></tr>
  <tr><td><code>slice:-5</code> en texto</td><td>{{ nombre | slice: -5 }}</td></tr>
  <tr><td><code>json</code></td><td><code>{{ departamento | json }}</code></td></tr>
  <tr>
    <td><code>keyvalue</code></td>
    <td>
      @for (par of departamento | keyvalue; track par.key) {
        <span>{{ par.key }}={{ par.value }}; </span>
      }
    </td>
  </tr>
  <tr><td><code>i18nPlural</code></td><td>{{ pedidos | i18nPlural: mapaPedidos }}</td></tr>
  <tr><td><code>i18nPlural</code> con 7</td><td>{{ 7 | i18nPlural: mapaPedidos }}</td></tr>
  <tr><td><code>i18nSelect</code></td><td>{{ genero | i18nSelect: mapaGenero }}</td></tr>
</table>

Tabla con cada pipe integrado aplicado a datos de EMPRESA y su resultado en español

Figura 7. Resultado real de los pipes integrados con LOCALE_ID en es, configuración que verás en la sección 10.

Parámetros y encadenado

Los parámetros van tras dos puntos, y los pipes se encadenan de izquierda a derecha:

{{ empleado.fechaAlta | date: 'MMMM y' | uppercase }}

La cadena digitsInfo de number, currency y percent merece memorizarse, porque se usa a diario. Tiene la forma minEnteros.minDecimales-maxDecimales:

Valor Significa 2450.5 queda
'1.0-3' Por defecto en number 2.450,5
'1.2-2' Exactamente dos decimales 2.450,50
'1.0-0' Sin decimales, redondeando 2.451
'6.0-0' Rellena con ceros a la izquierda 002.451

Precedencia del pipe

El pipe tiene menos prioridad que los operadores aritméticos y lógicos, y más que el ternario:

{{ precio * 2 | currency }}          <!-- (precio * 2) | currency -->
{{ activo ? 'sí' : 'no' | uppercase }}  <!-- solo afecta a 'no' -->
{{ (activo ? 'sí' : 'no') | uppercase }} <!-- afecta al resultado -->

La segunda línea es un error típico. Si dudas, pon paréntesis.

Valores nulos y errores

Todos los pipes de formato devuelven null con null o undefined, que en interpolación se ve como nada. Así, {{ empleado.comision | currency }} no muestra nada si no hay comisión, y no da error. Si quieres un texto: {{ (empleado.comision | currency) ?? 'Sin comisión' }}.

En cambio, un valor del tipo equivocado lanza una excepción y detiene el dibujado de esa vista:

Expresión Resultado
{{ 'hola' \| date }} Error NG02100: InvalidPipeArgument: 'NG02311: Unable to convert "hola" into a date'
{{ 'hola' \| number }} Error NG02100: InvalidPipeArgument: 'NG02309: hola is not a number'
{{ '42' \| number }} 42: las cadenas numéricas se aceptan

Por dentro: date y las fechas sin hora

La API EMPRESA devuelve fechas como "2010-01-15", y aquí JavaScript tiene una trampa: new Date('2010-01-15') la interpreta como medianoche en UTC. En América, donde la hora local va por detrás, eso ya es el día 14. DatePipe lo evita: las cadenas con solo fecha las interpreta como fecha local. Lo comprobamos con el navegador en la zona de Nueva York: new Date('2010-01-15').toLocaleDateString() muestra el día 14, y {{ '2010-01-15' | date: 'dd/MM/yyyy' }} da 15/01/2010. En Tokio y en Madrid el pipe da lo mismo.

Por eso conviene pasar la cadena tal cual al pipe. Si conviertes antes con new Date(...), pierdes esa protección.


8. Pipes propios

Un pipe es una clase con el decorador @Pipe y un método transform:

// src/app/pipes/iniciales-pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'iniciales' })
export class InicialesPipe implements PipeTransform {
  transform(nombre: string | null | undefined, maximo = 2): string {
    if (!nombre) {
      return '';
    }
    return nombre
      .trim()
      .split(/\s+/)
      .slice(0, maximo)
      .map((parte) => parte[0].toUpperCase())
      .join('');
  }
}
{{ 'ana garcía lópez' | iniciales }}      <!-- AG -->
{{ 'ana garcía lópez' | iniciales: 3 }}   <!-- AGL -->

El primer argumento de transform es lo que hay a la izquierda del |, y los demás son los parámetros de después. Como transform es un método tipado, la plantilla se comprueba como cualquier llamada: pasarle un número da error de compilación.

Acepta siempre null y undefined si tu pipe se va a usar con datos que puedan faltar. Un empleado()?.nombre | iniciales produce el tipo string | undefined, y un pipe declarado con string | null no compila (TS2345). Si además el pipe se encadena tras async, que devuelve T | null, necesitarás aceptar null.

Un pipe que traduce códigos a texto es muy habitual. Evita repetir @switch en toda la aplicación:

// src/app/pipes/estado-pedido-pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';

const TEXTOS: Record<EstadoPedido, string> = {
  PENDIENTE: 'Pendiente de envío',
  ENVIADO: 'En camino',
  ENTREGADO: 'Entregado',
  CANCELADO: 'Cancelado',
};

@Pipe({ name: 'estadoPedido' })
export class EstadoPedidoPipe implements PipeTransform {
  transform(estado: EstadoPedido | null | undefined): string {
    return estado ? TEXTOS[estado] : '';
  }
}

Un pipe también admite inyección con inject(), en un campo o en el constructor, como cualquier clase creada por Angular. Lo aprovecharemos en la sección 10 con LOCALE_ID. Y si necesita liberar algo, puede implementar ngOnDestroy: Angular crea una instancia del pipe por cada uso en la plantilla y la destruye con la vista.

Puros e impuros

Por defecto, un pipe es puro. Angular guarda el último resultado y solo vuelve a llamar a transform si cambia la identidad de alguno de sus argumentos. Para números y cadenas eso es cambiar el valor. Para objetos y arrays es que sea otro objeto: si haces push en el mismo array, el pipe no se entera.

Un pipe impuro, marcado con pure: false, se llama en cada revisión de la vista que lo contiene.

// src/app/pipes/filtro-pruebas.ts
import { Pipe, PipeTransform } from '@angular/core';

function filtrar(lista: string[], texto: string) {
  return lista.filter((nombre) => nombre.toLowerCase().includes(texto.toLowerCase()));
}

@Pipe({ name: 'filtroPuro' })
export class FiltroPuroPipe implements PipeTransform {
  transform(lista: string[], texto: string) {
    console.log('pipe puro');
    return filtrar(lista, texto);
  }
}

@Pipe({ name: 'filtroImpuro', pure: false })
export class FiltroImpuroPipe implements PipeTransform {
  transform(lista: string[], texto: string) {
    console.log('pipe impuro');
    return filtrar(lista, texto);
  }
}
// src/app/demos/demo-pureza.ts
import { Component, signal } from '@angular/core';
import { FiltroImpuroPipe, FiltroPuroPipe } from '../pipes/filtro-pruebas';

@Component({
  selector: 'app-demo-pureza',
  imports: [FiltroPuroPipe, FiltroImpuroPipe],
  template: `
    <input #caja (input)="texto.set(caja.value)" placeholder="Filtrar" />
    <button type="button" id="mutar" (click)="mutar()">push (muta el array)</button>
    <button type="button" id="sustituir" (click)="sustituir()">Array nuevo</button>
    <button type="button" id="otro" (click)="otro.set(otro() + 1)">Otro cambio: {{ otro() }}</button>
    <p id="puro">Puro: {{ (nombres | filtroPuro: texto()).join(', ') }}</p>
    <p id="impuro">Impuro: {{ (nombres | filtroImpuro: texto()).join(', ') }}</p>
  `,
})
export class DemoPureza {
  protected nombres = ['Ana', 'Luis', 'Marta'];
  protected readonly texto = signal('');
  protected readonly otro = signal(0);

  protected mutar() {
    this.nombres.push('Nuevo');
    this.otro.update((n) => n + 1); // fuerza una revisión de la vista
  }

  protected sustituir() {
    this.nombres = [...this.nombres, 'Otro'];
    this.otro.update((n) => n + 1);
  }
}

Con la consola abierta y pulsando en este orden, esto es lo que pasa:

Acción Puro Impuro
Carga inicial Se llama, 3 nombres Se llama, 3 nombres
Clic en “Otro cambio” No se llama Se llama de nuevo
Clic en push No se llama, sigue con 3 nombres Se llama, muestra 4
Clic en “Array nuevo” Se llama, se pone al día Se llama
Escribir una letra en el filtro Se llama una vez Se llama una vez

Comparación de pipe puro, que solo se ejecuta si cambia la identidad de sus argumentos, y pipe impuro, que se ejecuta en cada revisión de la vista

Figura 8. Un pipe puro compara argumentos antes de ejecutar nada. Uno impuro se ejecuta siempre que Angular revisa la vista.

La conclusión práctica: si un pipe puro no se actualiza, casi siempre es porque se está mutando un array u objeto. La solución correcta no es hacerlo impuro, sino dejar de mutar. Es la misma regla de inmutabilidad del módulo 05.

En @angular/common hay cuatro pipes impuros: async, json, keyvalue y slice. async debe mirar en cada revisión si llegó un valor nuevo, y los otros tres deben detectar cambios dentro de un objeto o array. El coste de un impuro lo paga la vista entera: si hace algo caro, se repite en cada revisión.

Pipes impuros y OnPush por defecto 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) un componente sin changeDetection usa Eager, y un pipe impuro se ejecuta en cada ciclo de la aplicación aunque la vista no haya cambiado. En 22 el defecto es OnPush y solo se ejecuta cuando esa vista se marca para revisar. Si usas la LTS: añade changeDetection: ChangeDetectionStrategy.OnPush para tener el mismo número de ejecuciones.

“En cada revisión de la vista” significa cosas distintas según la estrategia. Con OnPush, la vista solo se revisa si cambia una señal que lee, si ocurre un evento en ella o si recibe una entrada nueva. Un pipe impuro que lea un dato mutado no se actualizará solo porque otra parte de la página cambie.

Lo medimos con un componente hijo sin changeDetection que solo contiene un pipe impuro, pulsando un botón de otro componente que no le afecta. En Angular 22 el pipe no se ejecutó ninguna vez. En 21, con el mismo código, se ejecutó dos veces por cada clic en una compilación de desarrollo.

Es una mejora de rendimiento, pero puede sorprender en una migración: un pipe impuro que “funcionaba” al recalcularse por cualquier motivo deja de hacerlo. El módulo 09 explica OnPush a fondo.

Señales dentro de un pipe puro

Hay una trampa más sutil. Si transform lee una señal, el pipe puro no se vuelve a ejecutar cuando esta cambia, porque sus argumentos son los mismos:

// src/app/pipes/saludo-pipe.ts
import { Pipe, PipeTransform, signal } from '@angular/core';

export const idioma = signal<'es' | 'en'>('es');

const SALUDOS = { es: 'Hola', en: 'Hello' };

@Pipe({ name: 'saludoMal' })
export class SaludoMalPipe implements PipeTransform {
  transform(nombre: string) {
    return `${SALUDOS[idioma()]}, ${nombre}`; // No se entera del cambio de idioma
  }
}

@Pipe({ name: 'saludoBien' })
export class SaludoBienPipe implements PipeTransform {
  transform(nombre: string, lengua: 'es' | 'en') {
    return `${SALUDOS[lengua]}, ${nombre}`;
  }
}
{{ 'Ana' | saludoMal }}              <!-- Se queda en el primer idioma -->
{{ 'Ana' | saludoBien: idioma() }}   <!-- Se actualiza: la señal se lee en la plantilla -->

Pasa la señal como argumento desde la plantilla. El pipe sigue siendo una función pura de sus parámetros y se puede probar sin montar nada.

¿Pipe o computed?

Los dos transforman datos, y a veces se duda:

Criterio Pipe computed
Vive en La plantilla La clase del componente
Reutilización En cualquier plantilla En ese componente
El resultado se usa en código No directamente Sí: this.filtrados()
Se comparte el resultado Cada uso recalcula Una vez, aunque lo leas diez veces
Uso típico Formato: fechas, moneda, etiquetas Filtrar, ordenar, sumar

La regla: presentación, pipe; lógica, computed. Si filtras una lista y además quieres mostrar cuántos resultados hay, con un pipe filtrarías dos veces. Con computed calculas una vez y lees dos.


9. AsyncPipe

async conecta un Observable o una Promise con la plantilla. Se suscribe, entrega cada valor, avisa a Angular de que hay que revisar la vista, y se desuscribe al destruirse esta.

RxJS se ve a fondo en el módulo 14, y HttpClient en el módulo 12. Para este apartado basta saber que un Observable es una fuente de valores en el tiempo, y que no hace nada hasta que alguien se suscribe. Por eso se suele nombrar con un $ final.

// src/app/demos/demo-async.ts
import { AsyncPipe } from '@angular/common';
import { Component } from '@angular/core';
import { defer, interval, map, of, shareReplay, take, timer } from 'rxjs';

@Component({
  selector: 'app-demo-async',
  imports: [AsyncPipe],
  template: `
    <p id="reloj">Segundos: {{ segundos$ | async }}</p>
    <p id="promesa">Promesa: {{ promesa | async }}</p>

    @if (departamento$ | async; as d) {
      <p id="dep">{{ d.nombre }} ({{ d.localidad }})</p>
    } @else {
      <p id="dep">Cargando departamento...</p>
    }

    <!-- MAL: dos async, dos suscripciones -->
    <p id="doble">{{ (peticionDoble$ | async)?.total }} / {{ (peticionDoble$ | async)?.paginas }}</p>

    <!-- BIEN: una suscripción compartida con @let -->
    @let datos = peticionUnica$ | async;
    <p id="unica">{{ datos?.total }} / {{ datos?.paginas }}</p>
  `,
})
export class DemoAsync {
  protected readonly segundos$ = interval(1000).pipe(take(600));
  protected readonly promesa = Promise.resolve('resuelta');

  protected readonly departamento$ = timer(300).pipe(
    map(() => ({ nombre: 'INFORMÁTICA', localidad: 'Valencia' })),
  );

  // Simulan una petición HTTP: cada suscripción la lanzaría de nuevo
  protected readonly peticionDoble$ = defer(() => {
    console.log('suscripción a peticionDoble$');
    return of({ total: 14, paginas: 2 });
  });

  protected readonly peticionUnica$ = defer(() => {
    console.log('suscripción a peticionUnica$');
    return of({ total: 14, paginas: 2 });
  }).pipe(shareReplay({ bufferSize: 1, refCount: true }));
}

Lo que hay que saber de async

  • Mientras no hay dato, da null. Hasta que llega el primer valor, async devuelve null, y por eso su tipo es siempre T | null, también con una fuente que emite de forma síncrona. Se combina bien con @if, que además da una variable con as.
  • Con @if no distingue “cargando” de “vacío”. @if (x$ | async; as x) trata igual null, 0 y ''. Si 0 es un valor válido, envuelve el valor en un objeto o usa @let.
  • Funciona con OnPush. Cada valor nuevo marca la vista para revisar. Por eso async encaja con el comportamiento por defecto de Angular 22 sin hacer nada más.
  • Se desuscribe solo. Es su mayor ventaja frente a subscribe() manual, porque las fugas por olvidar esa baja son un clásico (módulo 14).

Dos errores que cuestan peticiones

Cada | async es una suscripción, y en un Observable de HttpClient cada suscripción lanza una petición. La demostración anterior simula las peticiones con defer, y la consola muestra dos líneas para peticionDoble$ y una para peticionUnica$. Se arregla con una sola suscripción en la plantilla (@let o @if ... as). shareReplay es una protección añadida si el flujo se usa en más de un sitio.

El segundo error es peor: llamar a un método que crea el Observable.

<!-- MAL: cada llamada devuelve un observable distinto -->
<p>{{ crearObservable() | async }}</p>

async compara la referencia del Observable. Si es otro, se desuscribe del anterior y se suscribe al nuevo. Ese nuevo emite, async marca la vista para revisar, la revisión llama otra vez al método y el ciclo vuelve a empezar. Lo probamos con un of('valor'): en unos segundos hubo más de 3000 suscripciones, y Angular cortó el bucle con este error:

NG0103: Angular could not stabilize because there were endless change notifications
within the browser event loop.

Con una petición HTTP, cada vuelta sería una petición real contra tu servidor. La regla es guardar el Observable en un campo y usar ese campo.

💡 Consejo: En código nuevo basado en señales, muchas veces no necesitarás async. toSignal (módulo 15) convierte un Observable en señal, y httpResource (módulo 13) resuelve las peticiones con estados de carga y error. async sigue siendo la opción directa cuando tienes un Observable y solo quieres pintarlo.


10. Intl y locales

Por defecto, Angular formatea como en Estados Unidos: 1,234.50, $, Jan 15, 2010. Para una aplicación española debes configurarlo.

Configurar la aplicación en español

// src/app/app.config.ts
import { DATE_PIPE_DEFAULT_OPTIONS, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeEs from '@angular/common/locales/es';
import {
  ApplicationConfig,
  DEFAULT_CURRENCY_CODE,
  LOCALE_ID,
  provideBrowserGlobalErrorListeners,
} from '@angular/core';

registerLocaleData(localeEs);
registerLocaleData(localeDe);

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    { provide: LOCALE_ID, useValue: 'es' },
    { provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR' },
    {
      provide: DATE_PIPE_DEFAULT_OPTIONS,
      useValue: { dateFormat: 'dd/MM/yyyy', timezone: 'Europe/Madrid' },
    },
  ],
};

Cada línea hace una cosa:

Pieza Para qué sirve
registerLocaleData Carga los datos del idioma: nombres de meses, separadores, formatos. No van incluidos en el bundle si no los pides
LOCALE_ID El idioma que usan los pipes por defecto. Sin registrar sus datos, da error
DEFAULT_CURRENCY_CODE La moneda de currency sin parámetros. Por defecto, USD, aunque el idioma sea es
DATE_PIPE_DEFAULT_OPTIONS El formato y la zona horaria por defecto de date

LOCALE_ID no determina la moneda. España, México y Argentina comparten idioma y no moneda, así que son datos independientes. Y timezone no es solo un adorno: sin esa opción, date usa la zona del navegador de cada usuario. A veces es lo que quieres, y a veces no, como en un horario de oficina en Madrid que debe verse igual desde Canarias.

Cualquier pipe admite además un idioma puntual en su último parámetro, siempre que hayas registrado sus datos:

{{ salario | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}
{{ fecha | date: 'fullDate' : '' : 'de' }}

en-US viene incluido y no hace falta registrarlo.

LOCALE_ID se lee una vez, y cambiarlo mientras funciona la aplicación no vuelve a formatear lo que ya se ha creado. La traducción completa de una aplicación, con una compilación por idioma, es otra historia que se cuenta en el módulo 25.

Intl: lo que trae el navegador

Intl es la API de internacionalización de JavaScript. No necesita datos en el bundle, porque los trae el navegador. Cubre cosas para las que Angular no tiene pipe:

Objeto Qué hace Ejemplo
Intl.NumberFormat Números, monedas, unidades, notación compacta 1,2 M
Intl.DateTimeFormat Fechas y horas 2 de noviembre de 2026
Intl.RelativeTimeFormat Tiempo relativo hace 3 días, mañana
Intl.ListFormat Listas con la conjunción correcta Ana, Luis y Marta
Intl.PluralRules Categoría plural de un número one, other
Intl.Collator Comparar texto según el idioma Ordenar Ñ tras N

Un pipe es el envoltorio natural de Intl en Angular: inyectas LOCALE_ID y tienes el formateador en una línea. Crear un objeto Intl es costoso comparado con usarlo, así que se crea una vez por instancia del pipe.

// src/app/pipes/tiempo-relativo-pipe.ts
import { LOCALE_ID, Pipe, PipeTransform, inject } from '@angular/core';

const UNIDADES: [Intl.RelativeTimeFormatUnit, number][] = [
  ['year', 31_536_000],
  ['month', 2_592_000],
  ['day', 86_400],
  ['hour', 3_600],
  ['minute', 60],
  ['second', 1],
];

@Pipe({ name: 'tiempoRelativo' })
export class TiempoRelativoPipe implements PipeTransform {
  private readonly formato = new Intl.RelativeTimeFormat(inject(LOCALE_ID), { numeric: 'auto' });

  transform(fecha: string | number | Date | null | undefined, ahora: number = Date.now()): string {
    if (fecha === null || fecha === undefined) {
      return '';
    }
    const segundos = (new Date(fecha).getTime() - ahora) / 1000;
    const [unidad, tamano] =
      UNIDADES.find(([, valor]) => Math.abs(segundos) >= valor) ?? UNIDADES[UNIDADES.length - 1];
    return this.formato.format(Math.round(segundos / tamano), unidad);
  }
}
// src/app/pipes/compacto-pipe.ts
import { LOCALE_ID, Pipe, PipeTransform, inject } from '@angular/core';

@Pipe({ name: 'compacto' })
export class CompactoPipe implements PipeTransform {
  private readonly formato = new Intl.NumberFormat(inject(LOCALE_ID), {
    notation: 'compact',
    maximumFractionDigits: 1,
  });

  transform(valor: number | null | undefined): string {
    return valor === null || valor === undefined ? '' : this.formato.format(valor);
  }
}

TiempoRelativoPipe es un caso instructivo. Depende de la hora, así que en rigor no es una función pura de fecha: el texto “hace 1 minuto” se quedaría congelado, porque Angular no vuelve a ejecutar el pipe si fecha no cambia. Podrías hacerlo impuro, pero la salida correcta es la de la sección 8: pasar la hora como argumento, desde una señal que avance.

// src/app/demos/demo-locales.ts
import { CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { CompactoPipe } from '../pipes/compacto-pipe';
import { TiempoRelativoPipe } from '../pipes/tiempo-relativo-pipe';

@Component({
  selector: 'app-demo-locales',
  imports: [CurrencyPipe, DatePipe, DecimalPipe, CompactoPipe, TiempoRelativoPipe],
  template: `
    <table>
      <tr><th>Dato</th><th>es (LOCALE_ID)</th><th>en-US</th><th>de</th></tr>
      <tr>
        <td>Importe</td>
        <td>{{ importe | currency }}</td>
        <td>{{ importe | currency: 'EUR' : 'symbol' : '1.2-2' : 'en-US' }}</td>
        <td>{{ importe | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</td>
      </tr>
      <tr>
        <td>Número</td>
        <td>{{ importe | number: '1.2-2' }}</td>
        <td>{{ importe | number: '1.2-2' : 'en-US' }}</td>
        <td>{{ importe | number: '1.2-2' : 'de' }}</td>
      </tr>
      <tr>
        <td>Fecha larga</td>
        <td>{{ fecha | date: 'longDate' }}</td>
        <td>{{ fecha | date: 'longDate' : '' : 'en-US' }}</td>
        <td>{{ fecha | date: 'longDate' : '' : 'de' }}</td>
      </tr>
      <tr>
        <td>Fecha corta</td>
        <td>{{ fecha | date }}</td>
        <td>{{ fecha | date: 'shortDate' : '' : 'en-US' }}</td>
        <td>{{ fecha | date: 'shortDate' : '' : 'de' }}</td>
      </tr>
    </table>
    <ul>
      <li id="grande">Compacto: {{ 1250000 | compacto }}</li>
      <li id="pasado">Hace un rato: {{ haceRato | tiempoRelativo: ahora() }}</li>
      <li id="ayer">Ayer: {{ ayer | tiempoRelativo: ahora() }}</li>
      <li id="agrupa">Separador: pipe {{ 2450 | number }}, Intl {{ intlEs.format(2450) }}</li>
    </ul>
  `,
})
export class DemoLocales {
  protected readonly importe = 1234567.891;
  protected readonly fecha = '2026-11-02';
  protected readonly ahora = signal(Date.now());
  protected readonly haceRato = Date.now() - 5 * 60_000;
  protected readonly ayer = Date.now() - 86_400_000;
  protected readonly intlEs = new Intl.NumberFormat('es');
}

Tabla que compara el mismo importe y la misma fecha en español, inglés de Estados Unidos y alemán

Figura 9. El mismo dato con tres idiomas. Cambian separadores, posición del símbolo y orden de la fecha.

Pipes de Angular frente a Intl: una diferencia que sorprende

Los pipes de Angular y Intl usan fuentes distintas: los datos de registerLocaleData y los del navegador. Casi siempre coinciden, pero no siempre. La última línea de la demostración lo enseña con 2450:

Fuente Resultado
{{ 2450 \| number }} 2.450
new Intl.NumberFormat('es').format(2450) 2450

En español, Intl no separa los millares en los números de cuatro cifras: 2450, pero 12.450. El pipe de Angular agrupa siempre. No es un fallo de ninguno, pero conviene saberlo si en una misma pantalla mezclas las dos fuentes, porque un 2.450 junto a un 2450 queda mal. Elige una y úsala en toda la aplicación.

Ordenar y buscar texto en español

Array.prototype.sort() sin más compara códigos de carácter. 'Álvarez' queda detrás de 'Zapata', porque la Á tiene un código mayor. Intl.Collator compara como lo haría un diccionario:

// src/app/pipes/ordenar-por-pipe.ts
import { LOCALE_ID, Pipe, PipeTransform, inject } from '@angular/core';

@Pipe({ name: 'ordenarPor' })
export class OrdenarPorPipe implements PipeTransform {
  private readonly comparador = new Intl.Collator(inject(LOCALE_ID), {
    numeric: true,
    sensitivity: 'base',
  });

  transform<T>(lista: readonly T[] | null, campo: keyof T, descendente = false): T[] {
    const copia = [...(lista ?? [])];
    copia.sort((a, b) => this.comparador.compare(String(a[campo]), String(b[campo])));
    return descendente ? copia.reverse() : copia;
  }
}
// src/app/pipes/filtro-texto-pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

function normalizar(texto: string) {
  return texto
    .normalize('NFD')
    .replace(/\p{Mark}/gu, '')
    .toLowerCase();
}

@Pipe({ name: 'filtroTexto' })
export class FiltroTextoPipe implements PipeTransform {
  transform<T>(lista: readonly T[] | null, texto: string, ...campos: (keyof T)[]): T[] {
    const buscado = normalizar(texto.trim());
    const elementos = lista ?? [];
    if (!buscado) {
      return [...elementos];
    }
    return elementos.filter((elemento) =>
      campos.some((campo) => normalizar(String(elemento[campo])).includes(buscado)),
    );
  }
}

Fíjate en [...(lista ?? [])]: sort modifica el array original, y un pipe no debe cambiar sus entradas. normalize('NFD') separa cada letra de su tilde, y la expresión regular quita las marcas. Así, buscar garcia encuentra García.


11. Caso práctico: panel de empleados de EMPRESA

Juntamos lo anterior en una pantalla real contra la API de ejemplo. Arráncala con node server.mjs, como se explica en la descarga recursos/empresa-api.zip. El panel usa:

  • AsyncPipe para la petición, que todavía no explicamos a fondo (módulo 12).
  • *appConDatos y *appRepetir para las filas de carga.
  • *appSiRol para enseñar el salario solo al administrador.
  • appResaltar en las filas y appTooltip en el correo.
  • filtroTexto, ordenarPor, iniciales, date y currency.
// src/app/modelos.ts
export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  email: string;
  telefono: string;
  fechaAlta: string;
  oficio: string;
  salario: number;
  comision: number | null;
  jefeId: number | null;
  deptId: number;
}

export interface Pagina<T> {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
}
// src/app/demos/panel-empleados.ts
import { AsyncPipe, CurrencyPipe, DatePipe, SlicePipe, TitleCasePipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { map } from 'rxjs';
import { ConDatos } from '../directivas/con-datos';
import { Repetir } from '../directivas/repetir';
import { Resaltar } from '../directivas/resaltar';
import { Rol, rolActual } from '../directivas/sesion';
import { SiRol } from '../directivas/si-rol';
import { Tooltip } from '../directivas/tooltip';
import { Empleado, Pagina } from '../modelos';
import { FiltroTextoPipe } from '../pipes/filtro-texto-pipe';
import { InicialesPipe } from '../pipes/iniciales-pipe';
import { OrdenarPorPipe } from '../pipes/ordenar-por-pipe';

const API = 'http://localhost:3000/api';

@Component({
  selector: 'app-panel-empleados',
  imports: [
    AsyncPipe, CurrencyPipe, DatePipe, SlicePipe, TitleCasePipe,
    ConDatos, Repetir, Resaltar, SiRol, Tooltip,
    FiltroTextoPipe, InicialesPipe, OrdenarPorPipe,
  ],
  templateUrl: './panel-empleados.html',
  styleUrl: './panel-empleados.css',
})
export class PanelEmpleados {
  protected readonly rol = rolActual;
  protected readonly texto = signal('');
  protected readonly campo = signal<keyof Empleado>('apellidos');
  protected readonly descendente = signal(false);

  protected readonly empleados$ = inject(HttpClient)
    .get<Pagina<Empleado>>(`${API}/empleados?tamano=100`)
    .pipe(map((respuesta) => respuesta.contenido));

  protected ordenar(campo: keyof Empleado) {
    if (this.campo() === campo) {
      this.descendente.update((valor) => !valor);
    } else {
      this.campo.set(campo);
      this.descendente.set(false);
    }
  }

  protected cambiarRol(valor: string) {
    this.rol.set(valor as Rol);
  }
}
<!-- src/app/demos/panel-empleados.html -->
<div class="barra">
  <input
    #caja
    placeholder="Buscar por nombre u oficio"
    (input)="texto.set(caja.value)"
  />
  <select #selector (change)="cambiarRol(selector.value)">
    <option value="USER">Rol: USER</option>
    <option value="ADMIN">Rol: ADMIN</option>
  </select>
</div>

<table *appConDatos="empleados$ | async; let empleados; cargando: esqueleto">
  <thead>
    <tr>
      <th></th>
      <th (click)="ordenar('apellidos')">Empleado</th>
      <th (click)="ordenar('oficio')">Oficio</th>
      <th (click)="ordenar('fechaAlta')">Alta</th>
      <th *appSiRol="'ADMIN'" (click)="ordenar('salario')">Salario</th>
    </tr>
  </thead>
  <tbody>
    @let visibles = empleados
      | filtroTexto: texto() : 'nombre' : 'apellidos' : 'oficio'
      | ordenarPor: campo() : descendente();
    @for (e of visibles | slice: 0 : 8; track e.empId) {
      <tr appResaltar="#e8eaf6">
        <td><span class="avatar">{{ e.nombre + ' ' + e.apellidos | iniciales }}</span></td>
        <td [appTooltip]="e.email">{{ e.apellidos }}, {{ e.nombre }}</td>
        <td>{{ e.oficio | titlecase }}</td>
        <td>{{ e.fechaAlta | date }}</td>
        <td *appSiRol="'ADMIN'">{{ e.salario | currency }}</td>
      </tr>
    } @empty {
      <tr><td colspan="5">Ningún empleado coincide con la búsqueda</td></tr>
    }
  </tbody>
</table>
<p class="pie">{{ campo() }} {{ descendente() ? '(descendente)' : '(ascendente)' }}</p>

<ng-template #esqueleto>
  <div *appRepetir="5" class="fila-vacia"></div>
</ng-template>
/* src/app/demos/panel-empleados.css */
.barra {
  display: flex;
  gap: 0.75rem;
  margin-bottom: 0.75rem;
}

input {
  flex: 1;
  padding: 0.4rem 0.6rem;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th {
  text-align: left;
  color: #1a237e;
  cursor: pointer;
  border-bottom: 2px solid #3f51b5;
  padding: 0.4rem;
}

td {
  padding: 0.35rem 0.4rem;
  border-bottom: 1px solid #eceff1;
}

.avatar {
  display: inline-grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  border-radius: 50%;
  background: #c5cae9;
  color: #1a237e;
  font-size: 0.8rem;
  font-weight: 600;
}

.fila-vacia {
  height: 2.2rem;
  margin-bottom: 0.3rem;
  border-radius: 4px;
  background: #eceff1;
}

.pie {
  color: #78909c;
  font-size: 0.85rem;
}

Fíjate en el @let con dos pipes encadenados: la lista solo se filtra y ordena cuando cambia empleados, texto, campo o descendente, porque ambos pipes son puros. Si cambias el rol, no se vuelve a ordenar nada. Y si la tabla tuviera un contador de resultados, leerías visibles.length sin repetir el cálculo.

Panel con buscador, selector de rol ADMIN y tabla de empleados con avatar de iniciales, oficio, fecha de alta y salario en euros

Figura 10. El panel con rol ADMIN. Con rol USER desaparece la columna de salario.


12. Errores frecuentes y depuración

Mensajes del compilador

Mensaje Causa Solución
NG8004: No pipe found with name 'date' Falta importar el pipe Añade DatePipe a imports
NG8002: Can't bind to 'appResaltar' since it isn't a known property of 'p' Falta importar la directiva, o la entrada no existe Añade la directiva a imports
NG8003: No directive found with exportAs 'appAlternar' Falta importar la directiva, o exportAs mal escrito Comprueba imports y el nombre
NG8116: A structural directive 'appRepetir' was used in the template without a corresponding import (aviso) *appRepetir sin importar Añade la directiva a imports
NG5002: Can't have multiple template bindings on one element Dos asteriscos en un elemento Envuelve con <ng-container>
TS2345: Argument of type 'string \| undefined' is not assignable... El pipe no acepta undefined o null Amplía el tipo de transform
TS2339: Property 'noExiste' does not exist on type 'MiDirectiva' Expresión de host que usa un miembro inexistente Corrige la expresión
TS2551: Property 'ultmo' does not exist on type 'ContextoRepetir' Variable de plantilla mal escrita, detectada gracias a ngTemplateContextGuard Corrige el nombre
NG8002: Can't bind to 'deshabilitado' en un componente La entrada de la directiva anfitriona no está expuesta Añádela a inputs en hostDirectives
NG2015: Host directive Boton cannot be a component Has puesto un componente en hostDirectives Solo admite directivas

Hay un caso sin error: una directiva de atributo escrita sin corchetes y sin importar. <p appResaltar> es para el compilador un atributo HTML desconocido y no se queja. Si tu directiva “no hace nada”:

  1. Mira si está en imports.
  2. Comprueba si el selector coincide de verdad, por ejemplo input[appX] en un <textarea>.
  3. Pon un console.log en el constructor para ver si se crea.
  4. Inspecciona el elemento con Angular DevTools: al seleccionarlo, muestra las directivas conectadas (módulo 01).

Errores en ejecución

Código Causa Solución
NG02100: InvalidPipeArgument: 'NG0701: Missing locale data for the locale "fr".' Usas un idioma sin registrar registerLocaleData(localeFr)
NG02100: InvalidPipeArgument date o number reciben un valor imposible Valida el dato, o pasa null

Un fallo que el compilador caza por ti: si lees un input.required() directamente en el constructor de una directiva, la compilación falla con NG8118: 'r' is a required input and does not have a value in this context. Todavía no hay valor. Léelo dentro de un effect o un computed, como hacen las directivas de este módulo.

Dos componentes en un elemento 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) dos componentes sobre el mismo elemento compilan, y fallan al ejecutar con NG0300: Multiple components match node. En 22 falla al compilar con NG8023. Si usas la LTS: el problema es el mismo, pero no lo verás hasta que se dibuje ese elemento, así que tiene que cubrirlo una prueba.

Una directiva admite compañía, un componente no: un elemento solo puede tener un dueño de su contenido. Si app-boton es un componente y otro componente tiene el selector [appDoble], Angular 22 rechaza esto durante la compilación:

NG8023: Multiple components match node with tagname app-boton: 'Boton', 'Doble'.

Publicar dos veces la misma entrada

La fusión de directivas anfitrionas de la sección 4 tiene un límite. Si dos caminos publican la misma entrada con nombres distintos, Angular 22 da un error, porque no hay una respuesta correcta:

@Directive({
  selector: '[appA]',
  hostDirectives: [{ directive: Deshabilitable, inputs: ['deshabilitado: bloqueadoA'] }],
})
export class A {}

@Directive({
  selector: '[appB]',
  hostDirectives: [{ directive: Deshabilitable, inputs: ['deshabilitado: bloqueadoB'] }],
})
export class B {}

// <div appA appB></div> no compila
NG8024: Input declared in Deshabilitable.deshabilitado is exposed under the following
conflicting names: "bloqueadoA", "bloqueadoB". An input can only be exposed under a single name.

Probar un pipe

Un pipe puro es lo más fácil de probar de Angular, porque es una función. Sin TestBed ni plantilla:

// src/app/pipes/iniciales-pipe.spec.ts
import { InicialesPipe } from './iniciales-pipe';

describe('InicialesPipe', () => {
  const pipe = new InicialesPipe();

  it('toma dos iniciales por defecto', () => {
    expect(pipe.transform('ana garcía lópez')).toBe('AG');
  });

  it('respeta el máximo', () => {
    expect(pipe.transform('ana garcía lópez', 3)).toBe('AGL');
  });

  it('tolera datos que faltan', () => {
    expect(pipe.transform(null)).toBe('');
    expect(pipe.transform(undefined)).toBe('');
  });
});

Si el pipe usa inject(), necesita un contexto de inyección, y las directivas se prueban montándolas sobre un componente de prueba. Lo verás en el módulo 26.


13. Ejercicios

Ejercicio 1: Solo números

Escribe una directiva appSoloNumeros para <input>. Debe eliminar lo que no sea un dígito mientras se escribe o se pega texto, y activar el teclado numérico en móviles.

Ver solución
// src/app/ejercicios/solo-numeros.ts
import { Directive, ElementRef, inject } from '@angular/core';

@Directive({
  selector: 'input[appSoloNumeros]',
  host: {
    inputmode: 'numeric',
    '(input)': 'limpiar()',
  },
})
export class SoloNumeros {
  private readonly campo = inject<ElementRef<HTMLInputElement>>(ElementRef).nativeElement;

  protected limpiar() {
    const limpio = this.campo.value.replace(/\D/g, '');
    if (limpio !== this.campo.value) {
      this.campo.value = limpio;
    }
  }
}

Se escucha input y no keydown porque este último no se entera de lo que se pega con el ratón ni del autocompletado. El selector limita la directiva a <input>, así value existe con seguridad. El if evita reescribir el valor sin necesidad, porque asignar value mueve el cursor al final.

Ejercicio 2: Confirmar antes de actuar

Crea appConfirmar, una directiva para botones peligrosos. Recibe el texto de la pregunta, muestra window.confirm al hacer clic y solo emite la salida confirmado si el usuario acepta. Sin texto, debe preguntar ¿Seguro?.

Ver solución
// src/app/ejercicios/confirmar.ts
import { Directive, input, output } from '@angular/core';

@Directive({
  selector: '[appConfirmar]',
  host: { '(click)': 'preguntar()' },
})
export class Confirmar {
  readonly appConfirmar = input('¿Seguro?', {
    transform: (texto: string) => texto || '¿Seguro?',
  });
  readonly confirmado = output<void>();

  protected preguntar() {
    if (window.confirm(this.appConfirmar())) {
      this.confirmado.emit();
    }
  }
}
<button appConfirmar="¿Borrar a este empleado?" (confirmado)="borrar()">Borrar</button>

El transform cubre otra vez el atributo vacío. Fíjate en que se usa (confirmado) y no (click): si el consumidor escuchase click, el borrado se haría también al cancelar.

Ejercicio 3: Tarjeta interactiva

Escribe una directiva appTarjeta que, usando hostDirectives, combine Resaltar, Enfocable y Pulsable. Debe publicar la entrada de color como colorResaltado y la salida pulsado como abrir, y añadir la clase tarjeta y tabindex="0".

Ver solución
// src/app/ejercicios/tarjeta.ts
import { Directive } from '@angular/core';
import { Enfocable } from '../directivas/enfocable';
import { Pulsable } from '../directivas/pulsable';
import { Resaltar } from '../directivas/resaltar';

@Directive({
  selector: '[appTarjeta]',
  hostDirectives: [
    Enfocable,
    { directive: Resaltar, inputs: ['appResaltar: colorResaltado'] },
    { directive: Pulsable, outputs: ['pulsado: abrir'] },
  ],
  host: { class: 'tarjeta', tabindex: '0' },
})
export class Tarjeta {}
<article appTarjeta colorResaltado="#c5cae9" (abrir)="verFicha(e)">{{ e.nombre }}</article>

En inputs, el nombre de la izquierda es el nombre público de la entrada en la directiva, que aquí es su alias appResaltar, no color. Esto es una fuente de errores habitual. Como Pulsable inyecta Deshabilitable con optional, funciona aunque aquí no esté.

Ejercicio 4: Mostrar según el ancho

Crea la directiva estructural *appSiAncho, que reciba una media query y muestre su contenido solo mientras se cumpla: *appSiAncho="'(min-width: 800px)'". Debe reaccionar al cambiar el tamaño de la ventana y dejar de escuchar al destruirse.

Ver solución
// src/app/ejercicios/si-ancho.ts
import { Directive, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';

@Directive({ selector: '[appSiAncho]' })
export class SiAncho {
  readonly appSiAncho = input.required<string>();

  constructor() {
    const plantilla = inject(TemplateRef);
    const contenedor = inject(ViewContainerRef);

    effect((alLimpiar) => {
      const consulta = window.matchMedia(this.appSiAncho());

      const aplicar = () => {
        contenedor.clear();
        if (consulta.matches) {
          contenedor.createEmbeddedView(plantilla).markForCheck();
        }
      };

      aplicar();
      consulta.addEventListener('change', aplicar);
      alLimpiar(() => consulta.removeEventListener('change', aplicar));
    });
  }
}

matchMedia avisa solo cuando cambia el resultado, no en cada píxel, y por eso es mejor que escuchar resize. La función de limpieza de effect se ejecuta si cambia la consulta y al destruir la directiva. El evento llega fuera de cualquier revisión, y markForCheck() pide a Angular que dibuje la vista recién creada.

En SSR no hay matchMedia. Si tu aplicación usa servidor, envuelve el acceso con afterNextRender (módulo 24).

Ejercicio 5: Repartir en grupos

Escribe *appTrozos, que reciba un array, parta la lista en grupos de tamano elementos y cree una vista por grupo, con el grupo como variable implícita y indice como número de grupo. Debe ser genérica: e.nombre tiene que estar tipado.

<div *appTrozos="empleados; tamano: 3; let grupo; let i = indice">
  Fila {{ i + 1 }}: @for (e of grupo; track e.empId) { {{ e.nombre }} }
</div>
Ver solución
// src/app/ejercicios/trozos.ts
import { Directive, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';

export interface ContextoTrozos<T> {
  $implicit: T[];
  indice: number;
}

@Directive({ selector: '[appTrozos]' })
export class Trozos<T> {
  readonly appTrozos = input.required<readonly T[]>();
  readonly appTrozosTamano = input(3);

  private readonly plantilla = inject<TemplateRef<ContextoTrozos<T>>>(TemplateRef);
  private readonly contenedor = inject(ViewContainerRef);

  static ngTemplateContextGuard<T>(
    _dir: Trozos<T>,
    _ctx: unknown,
  ): _ctx is ContextoTrozos<T> {
    return true;
  }

  constructor() {
    effect(() => {
      const lista = this.appTrozos();
      const tamano = Math.max(1, this.appTrozosTamano());

      this.contenedor.clear();
      for (let inicio = 0, indice = 0; inicio < lista.length; inicio += tamano, indice++) {
        this.contenedor.createEmbeddedView(this.plantilla, {
          $implicit: lista.slice(inicio, inicio + tamano),
          indice,
        });
      }
    });
  }
}

La clave tamano de la microsintaxis se conecta con la entrada appTrozosTamano, no con tamano. T se deduce de appTrozos, y ngTemplateContextGuard lo traslada a la variable grupo. El Math.max evita un bucle infinito si alguien pasa tamano: 0.

Ejercicio 6: Tamaño de fichero

Escribe un pipe tamanoFichero que reciba bytes y use Intl.NumberFormat con style: 'unit' para producir textos como 1,5 MB, según LOCALE_ID. Admite null.

Ver solución
// src/app/ejercicios/tamano-fichero-pipe.ts
import { LOCALE_ID, Pipe, PipeTransform, inject } from '@angular/core';

const UNIDADES = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'];

@Pipe({ name: 'tamanoFichero' })
export class TamanoFicheroPipe implements PipeTransform {
  private readonly idioma = inject(LOCALE_ID);

  transform(bytes: number | null | undefined): string {
    if (bytes === null || bytes === undefined) {
      return '';
    }
    let valor = bytes;
    let indice = 0;
    while (valor >= 1000 && indice < UNIDADES.length - 1) {
      valor /= 1000;
      indice++;
    }
    return new Intl.NumberFormat(this.idioma, {
      style: 'unit',
      unit: UNIDADES[indice],
      unitDisplay: 'short',
      maximumFractionDigits: 1,
    }).format(valor);
  }
}

Aquí el formateador se crea en cada llamada, porque depende de la unidad. Como el pipe es puro, solo ocurre cuando cambia el valor. Si esto apareciera en una lista muy larga, podrías guardar un formateador por unidad en un Map.

Ejercicio 7: Lista en lenguaje natural

Escribe un pipe listaTexto que convierta ['Ana', 'Luis', 'Marta'] en Ana, Luis y Marta con Intl.ListFormat. Debe aceptar un parámetro para usar la disyunción (o) y mantener la conjunción correcta con palabras como Ignacio.

Ver solución
// src/app/ejercicios/lista-texto-pipe.ts
import { LOCALE_ID, Pipe, PipeTransform, inject } from '@angular/core';

@Pipe({ name: 'listaTexto' })
export class ListaTextoPipe implements PipeTransform {
  private readonly idioma = inject(LOCALE_ID);

  transform(
    lista: readonly string[] | null | undefined,
    tipo: 'conjunction' | 'disjunction' = 'conjunction',
  ): string {
    return new Intl.ListFormat(this.idioma, { style: 'long', type: tipo }).format(lista ?? []);
  }
}
{{ ['Ana', 'Luis', 'Marta'] | listaTexto }}                <!-- Ana, Luis y Marta -->
{{ ['Ana', 'Ignacio'] | listaTexto }}                      <!-- Ana e Ignacio -->
{{ ['Ana', 'Luis'] | listaTexto: 'disjunction' }}          <!-- Ana o Luis -->

Un join a mano no sabe que antes de Ignacio la y pasa a ser e. Intl sí, porque conoce las reglas del idioma. Ten en cuenta que un literal de array en una plantilla es una constante para Angular, que la reutiliza entre revisiones, de modo que el pipe puro no se vuelve a ejecutar.

Ejercicio 8: De pipe impuro a computed

Este componente filtra con un pipe impuro que se ejecuta en cada revisión, y además necesita el número de resultados. Reescríbelo con computed, sin mutar el array.

protected nombres = ['Ana', 'Luis', 'Marta'];
protected texto = signal('');

protected anadir(nombre: string) {
  this.nombres.push(nombre);
}
@for (n of nombres | filtroImpuro: texto(); track n) { <li>{{ n }}</li> }
<p>{{ (nombres | filtroImpuro: texto()).length }} resultados</p>
Ver solución
// src/app/ejercicios/lista-filtrada.ts
import { Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-lista-filtrada',
  template: `
    <input #caja (input)="texto.set(caja.value)" />
    <button type="button" (click)="anadir('Marcos')">Añadir</button>
    <ul>
      @for (n of filtrados(); track n) {
        <li>{{ n }}</li>
      }
    </ul>
    <p>{{ filtrados().length }} resultados</p>
  `,
})
export class ListaFiltrada {
  protected readonly nombres = signal(['Ana', 'Luis', 'Marta']);
  protected readonly texto = signal('');

  protected readonly filtrados = computed(() => {
    const buscado = this.texto().toLowerCase();
    return this.nombres().filter((n) => n.toLowerCase().includes(buscado));
  });

  protected anadir(nombre: string) {
    this.nombres.update((lista) => [...lista, nombre]);
  }
}

El computed calcula una sola vez aunque se lea dos, y solo cuando cambian nombres o texto. Y al sustituir el array en lugar de mutarlo, la señal detecta el cambio. Mantener el pipe habría exigido dos filtrados por cada revisión.

Ejercicio 9 (propuesto): Contador de caracteres

Crea una directiva appContador para <textarea> que reciba un máximo y exporte (exportAs) una señal restantes. La plantilla debe mostrar los caracteres restantes y una clase agotado en el elemento cuando quede cero. Piensa dónde conviene escuchar y si debes limitar la escritura o solo avisar.

Ejercicio 10 (propuesto): Panel de pedidos

Reutilizando las directivas y pipes del módulo, escribe un panel de pedidos contra GET /api/pedidos: async, estadoPedido, date, filtro por estado, tiempo relativo desde fechaPedido, y filas de carga con *appRepetir. Añade el importe total con currency a partir de GET /api/pedidos/:id. Decide tú qué partes deben ser pipes y cuáles computed.


Resumen

Concepto API Idea clave
Directiva de atributo @Directive, selector Añade comportamiento a un elemento existente, sin plantilla propia
Enlaces del anfitrión host Propiedades, clases, estilos y eventos declarados, con comprobación de tipos
Acceso al DOM ElementRef, Renderer2 Último recurso; Renderer2 para crear nodos, y limpia en DestroyRef
Uso desde la plantilla exportAs, #x="nombre" La plantilla obtiene la instancia de la directiva
Composición hostDirectives Combina directivas sin herencia; las entradas y salidas se exponen a mano
Misma directiva dos veces Deduplicación 🆕 v22.0 Manda la de la plantilla; las anfitrionas se fusionan
Directiva estructural *, TemplateRef, ViewContainerRef El asterisco crea un ng-template, y la directiva decide cuántas vistas crea
Microsintaxis clave: valor, let x = y clave se conecta con selector + Clave
Tipos en plantilla ngTemplateContextGuard Dice al compilador qué tiene el contexto
Pipes integrados date, number, currency, percent… Se importan uno a uno; digitsInfo controla decimales
Pipe propio @Pipe, PipeTransform Una función con nombre; acepta null y undefined
Pipe puro pure: true, por defecto Solo se ejecuta si cambia la identidad de sus argumentos
Pipe impuro pure: false Se ejecuta en cada revisión de su vista; con OnPush 🔄 v22.0, menos
Pipe o señal computed Presentación en pipe, lógica en computed
Datos asíncronos AsyncPipe Suscribe y desuscribe solo; cada \| async es una suscripción
Idioma LOCALE_ID, registerLocaleData Por defecto, en-US; el idioma no decide la moneda
Moneda y fecha DEFAULT_CURRENCY_CODE, DATE_PIPE_DEFAULT_OPTIONS Valores por defecto de currency y date
API del navegador Intl.* Tiempo relativo, listas, ordenación; úsala dentro de pipes