Saltar a contenido

Parte 5: Pipes App

Una aplicación pequeña para aprender pipes: las piezas de Angular que cambian cómo se muestra un valor en la plantilla (mayúsculas, números con decimales, monedas, porcentajes). Al trabajar con listas también aprenderás a distinguir una copia de una modificación del array original. Es una aplicación Angular 22 creada con ng new, con Tailwind CSS y daisyUI para el aspecto, igual que la Parte 4: Country App. Aquí no hay API: cada pantalla es una prueba de un grupo de pipes. El único servicio es LocaleService, que guarda el idioma elegido (Módulo PP3).

Proyecto: 05-pipes-app/. Sitio publicado: angular-pipes.reskyon.com. Ábrelo mientras lees. Para correrlo en local usa npm start dentro de la carpeta (http://localhost:4200/).

Esta página va creciendo: cada módulo nuevo se agrega al final. Por ahora cubre los pipes de texto, los de números, los de fecha, un pipe propio, el diseño responsive, los pipes poco comunes, AsyncPipe, un componente compartido, un segundo pipe propio con argumento, un archivo de constantes, pipes propios sobre enum, ordenación de arrays y búsqueda con un campo que se puede limpiar.

Qué vas a aprender:

  • Texto, números y fechas. Dar formato a los valores, leer el título de la ruta y elegir idioma y zona horaria (Módulo PP1, Módulo PP2, Módulo PP3).
  • Pipes propios y presentación. Crear timeZone con Intl, adaptar la pantalla al celular y compartir el badge de documentación con input(), computed() y proyección de contenido (Módulo PP4, Módulo PP5, Módulo PP8).
  • Otros valores y tareas que terminan después. Elegir frases, cortar listas, recorrer objetos y mostrar promesas y observables con AsyncPipe (Módulo PP6, Módulo PP7).
  • Argumentos, constantes y enums. Crear toggleCase, separar los datos del componente y traducir booleanos y enums con yesNo, colorMap y creatorMap (Módulo PP9, Módulo PP10, Módulo PP11).
  • Ordenar y buscar héroes. Entender el comparador de sort, los empates, localeCompare, el filtrado y las referencias; pasar #searchInput al reset (Módulo PP12, Módulo PP13).

Cada módulo sigue el mismo orden: el problema que resuelve, el código del repositorio, cómo funciona por dentro, errores comunes y preguntas de entrevista con su respuesta.

Esta página es una parte de la guía de estudio; allí están el mapa general y la ruta recomendada. Conviene haber visto antes la Parte 4: Country App, de donde viene la configuración de Tailwind CSS y daisyUI.


Módulo PP1: Pipes de texto y el título de la ruta

El problema que resuelve

Tienes el texto 'HomErO ThOMPsoN' guardado en una señal. Quieres mostrarlo como Homero Thompson, pero sin cambiar el dato guardado: otra parte del programa puede necesitarlo tal cual. Si lo arreglas en la clase tendrías que crear una segunda variable por cada forma de mostrarlo.

Un pipe (tubería) resuelve eso en la plantilla: toma un valor, lo transforma y entrega el resultado para mostrar. El valor original queda intacto.

Documentación: LowerCasePipe, UpperCasePipe y TitleCasePipe.

El código

El componente declara los datos y los pipes que usa en imports (recortado a lo de este módulo; el archivo real importa también DatePipe y el badge de documentación):

@Component({
  selector: 'app-basic-page',
  templateUrl: './basic-page.component.html',
  imports: [LowerCasePipe, UpperCasePipe, TitleCasePipe, TitleComponent],
})
export default class BasicPageComponent {
  route = inject(ActivatedRoute)
  title = this.route.snapshot.title

  nameLower = signal('Homero')
  nameUpper = signal('homero')
  nameTitle = signal('HomErO ThOMPsoN')
}

La plantilla pone el pipe después de |. El título va en el componente compartido app-title (más abajo se explica el !):

<app-title [title]="title!" />

<div class="stat-value">{{ nameLower() | lowercase }}</div>
<div class="stat-value">{{ nameUpper() | uppercase }}</div>
<div class="stat-value">{{ nameTitle() | titlecase }}</div>

Resultado: homero, HOMERO y Homero Thompson. Debajo de cada uno la pantalla muestra Original: ... para que veas que el dato no cambió.

Cómo funciona por dentro

  • Un pipe es una clase con un método transform(valor). Angular lo llama cada vez que el valor cambia y pone el resultado en la pantalla.
  • Los pipes de este módulo son puros: con el mismo valor devuelven siempre el mismo resultado, así que Angular sólo los vuelve a ejecutar cuando el valor cambia, no en cada revisión de la pantalla.
  • Como los componentes son standalone, cada pipe se importa en el imports del componente. Si lo olvidas, la plantilla no compila.
  • Se pueden encadenar: {{ name() | lowercase | titlecase }}.

Leer el título de la ruta

Cada ruta de app.routes.ts tiene su title:

{
  path: 'basic',
  title: 'Basic Pipes',
  loadComponent: () => import('./pages/basic-page/basic-page.component'),
},

Dentro del componente, ActivatedRoute sabe en qué ruta está. Su propiedad snapshot.title es una foto del título en el momento de crear el componente: un string (o undefined si la ruta no tiene título), no una señal. Por eso en la página se escribe title, sin paréntesis. Escribir title() falla, porque title no es una función.

El h1 no está en la página: está en el componente compartido app-title, y la página le pasa el título como input:

<app-title [title]="title!" />
// title.component.ts
export class TitleComponent {
  title = input.required<string>()
}
<!-- title.component.html -->
<h1 class="text-2xl my-5">{{ title() | uppercase }}</h1>

Dos detalles:

  • El ! después de title. El snapshot es string | undefined, pero el input de app-title es obligatorio y sólo acepta string. Con ! le dices a TypeScript «confía, acá siempre hay valor» (todas las rutas tienen title). Sin el !, la plantilla no compila.
  • Dentro de app-title sí se escribe title(), con paréntesis: ahí title es un input, y los inputs son señales. Mismo nombre, dos cosas distintas: en la página es un string; en el componente compartido, una señal.

Si necesitaras que el título se actualice mientras el componente sigue vivo, usarías toSignal(this.route.title); entonces sí se llamaría title() también en la página.

Errores comunes

  • Olvidar el pipe en imports. La plantilla marca que el pipe no existe.
  • Tratar el título del snapshot como señal (title()): no es una función. Sólo dentro de app-title se lee title(), porque ahí es un input.
  • Pasar title a app-title sin el !. El input es obligatorio y snapshot.title puede ser undefined: la plantilla no compila.
  • Pensar que el pipe cambia el dato. Sólo cambia lo que se ve.

Resumen

Un pipe transforma un valor sólo para mostrarlo. lowercase, uppercase y titlecase cubren los casos de texto. El título de la ruta se lee con inject(ActivatedRoute) y snapshot.title.

Preguntas de entrevista

1. ¿Qué es un pipe y por qué usarlo en vez de transformar en la clase?

Respuesta:

Es una pieza que transforma un valor al mostrarlo en la plantilla. Se usa porque separa el dato (que queda intacto) de su presentación, y porque la misma transformación se reutiliza en cualquier plantilla sin repetir código en cada clase.

2. ¿Qué significa que un pipe sea «puro»?

Respuesta:

Que con la misma entrada siempre da la misma salida y no tiene efectos secundarios. Angular aprovecha eso: sólo lo ejecuta de nuevo cuando cambia el valor de entrada, así que es barato usarlo en la plantilla.

3. ¿Qué diferencia hay entre snapshot.title y toSignal(route.title)?

Respuesta:

snapshot.title es una lectura única, hecha cuando se crea el componente: sirve cuando la ruta no cambia mientras el componente está vivo. route.title es un Observable; con toSignal se convierte en una señal que se actualiza si el título cambia, y se lee con title().

4. ¿Por qué el pipe va en imports del componente?

Respuesta:

Porque en los componentes standalone no existe un módulo que declare lo disponible: cada componente declara lo que usa su plantilla. Así Angular sabe qué pipes, directivas y componentes puede resolver, y el empaquetado puede descartar lo que nadie importa.

Módulo PP2: Pipes de números: number, currency y percent

El problema que resuelve

Tienes el número 2433232.5567. En pantalla quieres 2,433,232.56, o $2,433,232.56 si es dinero, y para 0.8567 quieres 85.67%. Redondear, poner separadores de miles y agregar el símbolo a mano en la clase es repetitivo y depende del idioma. Los pipes number, currency y percent lo hacen según la configuración regional (locale).

Documentación: DecimalPipe (el pipe number), CurrencyPipe y PercentPipe.

El código

export default class NumbersPageComponent {
  route = inject(ActivatedRoute)
  title = this.route.snapshot.title

  totalSales = signal(2_433_232.5567)
  percent = signal(0.8567)
}

El componente importa DecimalPipe, PercentPipe y CurrencyPipe. Ojo: el pipe se escribe number en la plantilla, pero la clase que se importa es DecimalPipe.

{{ totalSales() | number: '1.2-2' }}
{{ totalSales() | currency: 'CAD' : 'symbol-narrow' : '1.2-2' }}
{{ percent() | percent: '1.2-2' }}

La app arranca en es-AR; con el botón «Change to English» de la página basic pasa a en-US, y así se ve (el Módulo PP3 muestra cómo cambia con es-AR):

Pipe Entrada Salida
number: '1.2-2' 2433232.5567 2,433,232.56
currency: 'CAD' : 'symbol-narrow' : '1.2-2' 2433232.5567 $2,433,232.56
percent: '1.2-2' 0.8567 85.67%

Cómo funciona por dentro

Qué significa '1.2-2'. Se lee mínimoDeEnteros.mínimoDeDecimales-máximoDeDecimales:

  • 1: al menos un dígito en la parte entera (0.5, no .5).
  • 2-2: entre 2 y 2 decimales, o sea siempre exactamente 2. Redondea y rellena con ceros si hace falta.

Con '1.0-3' mostrarías entre 0 y 3 decimales, sin ceros de relleno.

currency recibe más argumentos: el código de moneda ('CAD'), cómo mostrar el símbolo y el formato de dígitos. Con 'symbol' el dólar canadiense sale como CA$; con 'symbol-narrow' sale como $.

percent multiplica por 100. El valor 0.8567 es el 85,67 %. Si guardas 85.67 y lo pasas por percent, verás 8,567.00%.

Los tres pipes dan el formato del locale activo: en otro idioma cambian los separadores de miles y decimales.

Errores comunes

  • Pasar a percent un número ya multiplicado por 100.
  • Importar NumberPipe: no existe; la clase es DecimalPipe.
  • Esperar que el pipe cambie el valor guardado: sólo cambia lo que se ve.
  • Confundir el orden de los argumentos de currency: primero la moneda, luego el símbolo, luego los dígitos.

Resumen

number formatea cantidades, currency agrega moneda y percent muestra proporciones. El texto '1.2-2' controla dígitos enteros y decimales. Los tres respetan el locale.

Preguntas de entrevista

1. ¿Qué significa el formato '1.2-2'?

Respuesta:

Al menos 1 dígito entero, y entre 2 y 2 decimales (siempre 2). El primer número es el mínimo de enteros; después del punto van el mínimo y el máximo de decimales separados por un guion.

2. ¿Por qué percent con el valor 85.67 muestra 8,567.00%?

Respuesta:

Porque percent espera una proporción: multiplica por 100. El valor correcto para 85,67 % es 0.8567.

3. ¿Qué diferencia hay entre 'symbol' y 'symbol-narrow' en currency?

Respuesta:

'symbol' usa el símbolo completo, que distingue monedas parecidas (CA$ para el dólar canadiense). 'symbol-narrow' usa el símbolo corto ($). Conviene el corto cuando el contexto ya deja clara la moneda.

4. ¿Cómo mostrarías el número en español de España?

Respuesta:

Registrando los datos del locale (registerLocaleData con @angular/common/locales/es) y entregando LOCALE_ID con 'es' (o pasando el locale como último argumento del pipe). Entonces 2433232.5567 se ve como 2.433.232,56: los separadores cambian con el idioma.

Módulo PP3: Pipes de fecha: date

El problema que resuelve

Una fecha de JavaScript, mostrada tal cual, es un texto enorme e ilegible: Thu Oct 22 2026 15:09:05 GMT-0300 (...). Cada usuario quiere verla distinta: 22/10/26, «jueves 22 de octubre», solo la hora. El pipe date convierte el mismo valor en el formato que pidas, sin cambiar el dato.

Documentación: DatePipe.

El código

La clase solo guarda la fecha en una señal, y el componente importa DatePipe:

customDate = signal(new Date())
{{ customDate() | date }}
{{ customDate() | date: 'medium' : 'GMT-4' }}
{{ customDate() | date: "d MMM, yyyy 'at' hh:mm a" }}
{{ customDate() | date: 'fullDate' }}
{{ customDate() | date: 'yyyy-MM-dd' }}

Con en-US (la app arranca en es-AR; el botón «Change to English» de la página la pasa a inglés) y la fecha 22 de octubre de 2026, 15:09:05, se ve:

Pipe Resultado
date Oct 22, 2026
date: 'medium' : 'GMT-4' Oct 22, 2026, 2:09:05 PM (cambia según tu zona)
date: "d MMM, yyyy 'at' hh:mm a" 22 Oct, 2026 at 03:09 PM
date: 'h:mm a z' 3:09 PM GMT-3 (la zona es la del navegador)
date: 'fullDate' Thursday, October 22, 2026
date: 'shortDate' 10/22/26
date: 'shortTime' 3:09 PM
date: 'mediumTime' 3:09:05 PM
date: 'yyyy-MM-dd' 2026-10-22
date: 'EEEE, MMMM d' Thursday, October 22

El idioma de toda la app: LOCALE_ID

La tabla de arriba es con en-US. La aplicación elige el idioma una sola vez, para todos los pipes, en app.config.ts. Son dos pasos: registerLocaleData carga los datos de cada idioma que vas a usar, y LOCALE_ID le dice a los pipes cuál usar. Esta es la versión simple, con el idioma fijo:

import localeEsAr from '@angular/common/locales/es-AR'
import localeFr from '@angular/common/locales/fr'
import localeEn from '@angular/common/locales/en'

registerLocaleData(localeEsAr)
registerLocaleData(localeFr)
registerLocaleData(localeEn)

export const appConfig: ApplicationConfig = {
  providers: [{ provide: LOCALE_ID, useValue: 'es-AR' }],
}

El app.config.ts real del proyecto no usa useValue: el idioma sale de un servicio, LocaleService, con una fábrica. Se ve más abajo, en «Cambiar el idioma mientras la app corre».

Si un idioma no está registrado, los pipes fallan. Es la causa más común de «el francés no me anda»: con fr-FR hay que registrar fr (el -FR usa los datos de fr).

Con es-AR, en-US y fr-FR, la misma fecha (22 de octubre de 2026, 15:09:05) y los mismos números se ven así:

Pipe en-US es-AR fr-FR
date Oct 22, 2026 22 oct 2026 22 oct. 2026
date: 'fullDate' Thursday, October 22, 2026 jueves, 22 de octubre de 2026 jeudi 22 octobre 2026
date: 'shortDate' 10/22/26 22/10/26 22/10/2026
date: 'shortTime' 3:09 PM 3:09 p. m. 15:09
date: 'yyyy-MM-dd' 2026-10-22 2026-10-22 2026-10-22
number: '1.2-2' (2433232.5567) 2,433,232.56 2.433.232,56 2 433 232,56
percent: '1.2-2' (0.8567) 85.67% 85,67% 85,67 %

En francés los miles se separan con un espacio y el % lleva un espacio antes.

Tres cosas para notar:

  • Los formatos predefinidos (fullDate, shortTime) cambian de orden y de idioma solos. Es la gran ventaja de usarlos.
  • Un formato propio cambia sólo en lo que depende del idioma, los nombres: EEEE, MMMM d da jueves, octubre 22, que en español suena raro; para eso se escribe "EEEE, d 'de' MMMM". El orden de las partes lo pones tú. Y un texto entre comillas, como 'at', no se traduce.
  • yyyy-MM-dd no cambia nunca: por eso sirve para formularios y APIs.

Cambiar el idioma mientras la app corre. En vez de un valor fijo, el proveedor puede usar una fábrica (factory): una función que Angular llama para obtener el valor. Así LOCALE_ID sale de un servicio que guarda el idioma elegido. Así queda el app.config.ts real del proyecto:

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    {
      provide: LOCALE_ID,
      useFactory: (localeService: LocaleService) => localeService.getLocale,
      deps: [LocaleService],
    },
  ],
}
// LocaleService
changeLocale(newLocale: AvailableLocale) {
  localStorage.setItem('locale', newLocale)
  this.currentLocale.set(newLocale)
  window.location.reload()
}

La trampa: Angular llama a la fábrica una sola vez, al arrancar, y los pipes leen LOCALE_ID al crearse. Cambiar la señal del servicio después no cambia el idioma de los pipes que ya existen. Por eso changeLocale guarda el idioma en localStorage y recarga la página: al arrancar de nuevo, la fábrica lee el idioma guardado.

Si quisieras cambiar sin recargar, habría que pasar el idioma como tercer argumento de cada pipe ({{ fecha | date: 'fullDate' : undefined : idioma() }}).

Una fecha que avanza sola

En la página basic la hora se mueve cada segundo. Para lograrlo, un effect cambia la señal con un temporizador:

customDate = signal(new Date())

tickingDateEffect = effect((onCleanup) => {
  const interval = setInterval(() => {
    this.customDate.set(new Date())
  }, 1000)
  onCleanup(() => clearInterval(interval))
})
  • Cada segundo se guarda un Date nuevo con set. Como la señal compara por referencia, un objeto nuevo cuenta como cambio y la plantilla se actualiza. Si modificaras el mismo Date (setSeconds), la señal no avisaría y el pipe date no se volvería a ejecutar.
  • onCleanup apaga el temporizador cuando el componente se destruye. Sin eso, el setInterval seguiría vivo al salir de la página.
  • El effect solo escribe en la señal, no la lee, así que no se vuelve a disparar por sí mismo: corre una vez y el temporizador hace el resto.

Cómo funciona por dentro

Los argumentos. date: formato : zonaHoraria : locale. Los tres son opcionales. Sin formato usa mediumDate. Sin zona horaria usa la del navegador. 'GMT-4' es una zona dada como desfase respecto a GMT.

Los formatos predefinidos (presets). Son nombres ya hechos que además respetan el locale:

  • Solo fecha: shortDate, mediumDate, longDate, fullDate.
  • Solo hora: shortTime, mediumTime, longTime, fullTime.
  • Fecha y hora juntas: short, medium, long, full.

Los formatos propios. Cada letra es una parte de la fecha:

Letras Significan
d, MMM, MMMM, yyyy día, mes corto, mes completo, año
EEEE nombre completo del día de la semana
hh, mm, a hora (12 h), minutos, AM/PM
z zona horaria

Lo que no sea una letra de formato va entre comillas simples, como 'at'. Por eso el formato completo se escribe entre comillas dobles: "d MMM, yyyy 'at' hh:mm a".

Cuidado con MM y mm. MM es el mes y mm son los minutos. Con la misma fecha, 'yyyy-mm-dd' muestra 2026-09-22: los minutos (09) en lugar del mes (10).

Errores comunes

  • Confundir MM con mm. Es el error más frecuente en un formato propio.
  • Olvidar las comillas en el texto fijo (at sin comillas se interpreta como letras de formato).
  • Pensar que date cambia la zona horaria del dato. Solo cambia cómo se muestra; el valor sigue siendo el mismo instante.
  • Esperar otro idioma sin registrarlo. Para es hay que registrar sus datos con registerLocaleData, igual que en el Módulo PP2.

Resumen

date formatea fechas con un nombre predefinido (fullDate, shortTime) o con letras propias (yyyy-MM-dd). Acepta una zona horaria y un locale como segundo y tercer argumento. Recuerda: MM mes, mm minutos.

Preguntas de entrevista

1. ¿Qué diferencia hay entre 'MM' y 'mm' en el formato de date?

Respuesta:

MM es el mes y mm son los minutos. Si escribes 'yyyy-mm-dd' obtienes los minutos donde esperabas el mes: el 22 de octubre de 2026 a las 15:09 se ve 2026-09-22.

2. ¿Cuándo usar un formato predefinido y cuándo uno propio?

Respuesta:

El predefinido (shortDate, fullDate) se adapta solo al locale: el orden día/mes cambia según el país. Es lo mejor para mostrar fechas a personas. El propio (yyyy-MM-dd) da el mismo resultado en cualquier idioma, y sirve para entradas de formulario o APIs.

3. ¿Qué hace el tercer argumento de date?

Respuesta:

Es el locale: el idioma y la región con que se escriben nombres de meses y días y el orden de las partes. Primero hay que registrar los datos de ese idioma (registerLocaleData con @angular/common/locales/es).

4. ¿Cambia el pipe date el valor de la fecha?

Respuesta:

No. Solo cambia el texto que se muestra. Con una zona como 'GMT-4' el instante es el mismo; solo se escribe con la hora de esa zona.

5. ¿Por qué hay que guardar un Date nuevo en la señal en vez de modificar el existente?

Respuesta:

Porque la señal avisa del cambio comparando por referencia. Si cambias el mismo objeto Date por dentro, la referencia es la misma y nadie se entera: la plantilla no se actualiza y el pipe puro tampoco se vuelve a ejecutar. Con set(new Date()) la referencia cambia y todo se actualiza.

6. ¿Para qué sirve onCleanup en el effect del reloj?

Respuesta:

Para apagar el setInterval cuando el effect se destruye (al salir de la página) o antes de volver a ejecutarse. Sin eso, el temporizador seguiría corriendo en segundo plano, cambiando una señal que ya nadie usa: una fuga de memoria.

7. ¿Cómo cambias el idioma de todos los pipes de una app, y qué dos pasos hacen falta?

Respuesta:

En app.config.ts: primero registerLocaleData con los datos del idioma (@angular/common/locales/es-AR), y luego el proveedor de LOCALE_ID. En la versión simple es { provide: LOCALE_ID, useValue: 'es-AR' }; en el proyecto es una fábrica (useFactory) que le pide el idioma a LocaleService. Sin el primero, los pipes fallan porque faltan los datos; sin el segundo, los datos están pero nadie los usa.

8. ¿Por qué cambiar la señal del servicio no cambia el idioma de los pipes?

Respuesta:

Porque LOCALE_ID se calcula una sola vez, cuando arranca la app: Angular llama a la fábrica una vez y los pipes toman ese valor al crearse. La señal sí cambia, pero nadie vuelve a pedirle el valor a la fábrica. Por eso el servicio guarda el idioma en localStorage y recarga la página.

9. Cambio el idioma a francés y la página falla. ¿Qué reviso primero?

Respuesta:

Que el idioma esté registrado: registerLocaleData con @angular/common/locales/fr. Si solo se registró es-AR, los pipes no encuentran los datos de fr-FR y fallan. Con fr registrado, fr-FR funciona, porque usa los datos de fr.

Módulo PP4: Un pipe propio: zona horaria con Intl

El problema que resuelve

Quieres mostrar la hora de Caracas, de Houston y de Madrid a la vez. El pipe date recibe la zona como un desfase fijo ('GMT-4'), y eso falla donde hay horario de verano: Houston es UTC−5 ahora y UTC−6 desde el 1 de noviembre; Madrid es UTC+2 y pasa a UTC+1 el 25 de octubre. Con un desfase fijo, la hora queda mal la mitad del año.

Además, date no entiende nombres de zona como America/Chicago. La herramienta del navegador que sí los entiende es Intl.DateTimeFormat, que conoce las reglas reales de cada zona. Como Angular no trae un pipe que la use, se escribe uno propio.

Documentación: pipes personalizados en la guía de Angular. Un pipe propio no tiene página en la referencia de la API, porque lo escribes tú.

Qué es Intl

Intl es una herramienta que ya viene dentro del navegador (y de Node). No es de Angular y no hay que instalarla. Su trabajo es que un mismo dato se escriba como lo espera cada persona, según su idioma, su país y su zona horaria.

El problema que resuelve: la misma fecha se escribe 22/10/26 en Argentina, 10/22/26 en Estados Unidos y 22 oct 2026 en otros formatos. Con los números pasa igual: 2.433.232,56 en español y 2,433,232.56 en inglés. Hacerlo a mano obliga a conocer las reglas de cada país; Intl ya las conoce.

Siempre se usa en dos pasos: primero se crea un formateador con el idioma y las opciones, y después se le da el valor:

const formato = new Intl.DateTimeFormat('es-AR', { dateStyle: 'full' })
formato.format(new Date())

Trae varios formateadores:

Formateador Para qué Ejemplo
DateTimeFormat fechas y horas jueves, 22 de octubre de 2026
NumberFormat números y monedas $ 2.433.232,56 (es-AR) o $2,433,232.56 (en-US)
RelativeTimeFormat tiempo relativo ayer, dentro de 3 horas
ListFormat listas con «y» Caracas, Houston y Madrid
DisplayNames nombres de países o idiomas Venezuela (de 'VE')

Por qué sirve para las zonas horarias. DateTimeFormat tiene la opción timeZone, que entiende nombres como America/Chicago. Por dentro trae la base de datos oficial de zonas horarias, con las reglas del horario de verano de cada lugar. Con la misma zona y dos fechas se ve el cambio:

1 de julio:      1:00:00 PM CDT   (UTC-5)
1 de diciembre: 12:00:00 PM CST   (UTC-6)

El mismo mediodía UTC da horas distintas porque Houston cambió de horario. Con un 'GMT-5' fijo, diciembre saldría mal.

En qué se diferencia de los pipes de Angular:

Pipe date de Angular Intl
Quién tiene los datos del idioma Tu app, tras registerLocaleData El navegador
Zona horaria Solo desfases ('GMT-4') Nombres (America/Chicago)
Idiomas Solo los que registraste Casi todos, sin registrar

Para tener en cuenta: el texto exacto puede variar un poco entre navegadores y versiones (espacios, puntos, la forma de «p. m.»), y crear un formateador cuesta algo, así que si formatearas miles de valores conviene crearlo una vez y reutilizarlo.

El código

El pipe vive en src/app/pipes/time-zone.pipe.ts:

@Pipe({ name: 'timeZone' })
export class TimeZonePipe implements PipeTransform {
  private localeId = inject(LOCALE_ID)

  transform(
    value: Date,
    timeZone?: string,
    { locale = this.localeId, dateStyle = 'short', timeStyle = 'medium' }: TimeZoneOptions = {},
  ): string {
    return new Intl.DateTimeFormat(locale, {
      dateStyle: dateStyle ?? undefined,
      timeStyle: timeStyle ?? undefined,
      timeZone,
    }).format(value)
  }
}

Y las opciones, en src/app/interfaces/time-zone.interfaces.ts:

type Style = 'short' | 'medium' | 'long' | 'full'

export interface TimeZoneOptions {
  locale?: string
  dateStyle?: Style | null
  timeStyle?: Style | null
}

En la plantilla de custom-page (con customDate actualizándose cada segundo, como en el Módulo PP3):

{{ customDate() | timeZone: 'America/Caracas' }}
{{ customDate() | timeZone: 'America/Chicago' }}
{{ customDate() | timeZone: 'Europe/Madrid' }}
{{ customDate() | timeZone: 'America/Argentina/Buenos_Aires' : { dateStyle: null, timeStyle: 'medium' } }}

Con el 22 de octubre de 2026, 18:09:05 UTC, se ve:

Zona Resultado
America/Caracas 22/10/26, 2:09:05 p. m.
America/Chicago (Houston) 22/10/26, 1:09:05 p. m.
Europe/Madrid 22/10/26, 8:09:05 p. m.
Buenos Aires, con { dateStyle: null } 3:09:05 p. m. (solo la hora)

Houston no tiene zona propia: usa America/Chicago, la zona de la hora central de Estados Unidos.

Cómo funciona por dentro

El valor y los argumentos. En {{ fecha | timeZone: 'zona' : opciones }}, lo que está a la izquierda del | llega como primer parámetro de transform, y cada argumento después de : llega como el siguiente. Por eso la firma es (value, timeZone, options) y no un solo objeto con todo adentro. Si transform recibe un solo objeto, Angular no compila y avisa de que se esperaba un argumento y llegaron dos.

El idioma por defecto sale de LOCALE_ID. El pipe pide LOCALE_ID con inject y lo usa si no le pasas otro idioma. Así habla el mismo idioma que los pipes de Angular (date, number...) sin repetirlo. Si el idioma estuviera escrito a mano ('es-AR'), esta página seguiría en español aunque el resto cambiara a francés.

Solo fecha o solo hora. Intl muestra la fecha con dateStyle y la hora con timeStyle. Si omites uno, esa parte no aparece. Pero Intl no acepta null (falla con un error de rango), así que el pipe usa null como señal propia de «no mostrar esto» y lo convierte con ?? undefined:

  • { dateStyle: null }: solo la hora.
  • { timeStyle: null }: solo la fecha.

Por qué null y no undefined para apagar. Los valores por defecto de la desestructuración (dateStyle = 'short') solo se aplican cuando la opción falta (undefined). Si pasas null, el valor se respeta y el pipe lo interpreta como «apagado». Con undefined volvería el valor por defecto.

La zona sí importa aunque muestres solo la fecha. A las 23:30 del 22 en Caracas, en Madrid ya es el 23. Por eso el pipe siempre recibe la zona.

Errores comunes

  • Un pipe con un solo parámetro objeto: la plantilla no compila. El valor va primero.
  • Pasar null directamente a Intl: falla. Hay que convertirlo antes.
  • Pasar 'GMT-4' a Intl: no lo entiende (da un error de rango); usa el nombre de la zona, como America/Caracas.
  • Escribir mal el nombre de la zona: da un error de rango. Los nombres siguen el formato Continente/Ciudad.
  • Esperar que el pipe cambie el dato: solo cambia cómo se ve.

Resumen

Un pipe propio es una clase con @Pipe y un método transform. El valor a formatear va primero y los argumentos después. Para zonas horarias con horario de verano se usa Intl.DateTimeFormat con el nombre de la zona, y el pipe convierte null en «no mostrar esta parte».

Preguntas de entrevista

1. ¿Por qué no basta el pipe date con 'GMT-5' para mostrar la hora de Houston?

Respuesta:

Porque 'GMT-5' es un desfase fijo y Houston cambia con el horario de verano: UTC−5 en verano y UTC−6 en invierno. Durante la mitad del año la hora saldría una hora corrida. Con el nombre America/Chicago, Intl aplica la regla correcta de cada fecha.

2. ¿Cómo recibe transform el valor y los argumentos de un pipe?

Respuesta:

El valor de la izquierda del | es el primer parámetro; cada argumento después de : es el siguiente. En {{ fecha | timeZone: 'America/Caracas' : { ... } }} llegan (fecha, 'America/Caracas', { ... }). Un transform con un solo objeto no se corresponde con esa llamada y Angular lo marca como error de compilación.

3. ¿Cómo permites mostrar solo la hora o solo la fecha?

Respuesta:

Intl.DateTimeFormat muestra cada parte solo si pasas su estilo (dateStyle o timeStyle). Como Intl no acepta null, el pipe lo recibe como opción propia y lo convierte con ?? undefined. Así, { dateStyle: null } da solo la hora y { timeStyle: null } da solo la fecha.

4. ¿Por qué el valor por defecto no se aplica si pasas null?

Respuesta:

Porque los valores por defecto de la desestructuración solo se usan cuando la propiedad es undefined. null es un valor, así que se conserva. Eso permite distinguir «no dije nada» (usa el defecto) de «apágalo» (null).

5. ¿Es puro este pipe, y qué implica en una fecha que cambia cada segundo?

Respuesta:

Sí: con los mismos argumentos devuelve siempre lo mismo. Angular lo vuelve a ejecutar cuando cambia la entrada. Por eso el reloj guarda un Date nuevo cada segundo con set: una referencia nueva activa el pipe, mientras que modificar el mismo Date por dentro no lo haría.

Módulo PP5: Que se vea bien en el celular: responsive con Tailwind y daisyUI

El problema que resuelve

Abres la aplicación en el celular y hay dos cosas rotas. Las tarjetas de los pipes van en fila y se salen de la pantalla, con títulos tan largos como Currency Pipe: currency: 'CAD' : 'symbol-narrow' : '1.2-2' cortados. Y el navbar tiene el nombre de la app y cinco botones (las cuatro páginas y la wiki) en una sola línea, que no caben.

Diseñar para que una página se adapte a cualquier ancho se llama responsive. La idea es que cada pantalla tenga la distribución que le conviene: en el celular, todo apilado; en una pantalla grande, en fila.

El código

Las tarjetas (stats). daisyUI las pone en fila por defecto. Se les indica que sean verticales y que pasen a horizontales en pantallas grandes:

<div
  class="stats stats-vertical lg:stats-horizontal bg-base-200 w-full border-b border-dashed border-base-400 mb-5"
>
  <div class="stat place-items-center">
    <div class="stat-title whitespace-normal text-center">Decimal Pipe: number: '1.2-2'</div>
    <div class="stat-value whitespace-normal text-center">{{ totalSales() | number: '1.2-2' }}</div>
  </div>
</div>

El navbar. En el celular, los links van dentro de un menú desplegable con un botón de hamburguesa; desde pantallas medianas, vuelven a verse en la barra:

<div class="navbar-start">
  <div class="dropdown md:hidden">
    <div tabindex="0" role="button" class="btn btn-ghost btn-square" aria-label="Open menu">
      <!-- icono de tres rayas -->
    </div>
    <ul tabindex="0" class="menu dropdown-content bg-base-200 rounded-box z-10 mt-3 w-52 p-2 shadow">
      @for (route of routes; track route.path) {
        <li>
          <a routerLinkActive="menu-active" [routerLink]="route.path" (click)="closeMenu()">{{ route.title }}</a>
        </li>
      }
    </ul>
  </div>
  <a class="btn btn-ghost text-xl md:text-3xl" routerLink="/">{{ title() }}</a>
</div>

<div class="navbar-end hidden md:flex">
  <!-- el menú horizontal de siempre -->
</div>

Y en la clase del navbar:

private document = inject(DOCUMENT)

routes = routes
  .filter((route) => route.title)
  .map((route) => ({ path: route.path ?? '', title: route.title as string }))

closeMenu() {
  ;(this.document.activeElement as HTMLElement | null)?.blur()
}

Cómo funciona por dentro

Puntos de corte (breakpoints). Tailwind usa prefijos que significan «a partir de este ancho»:

Prefijo Desde
(ninguno) cualquier ancho, incluido el celular
sm: 640 px
md: 768 px
lg: 1024 px

Se escribe primero lo del celular y luego lo que cambia en pantallas más grandes. Por eso stats-vertical lg:stats-horizontal se lee: «vertical, y desde 1024 px, horizontal».

Mostrar una cosa en el celular y otra en pantallas grandes. Son dos bloques distintos, cada uno oculto donde no corresponde:

  • md:hidden: visible en el celular, oculto desde 768 px (el menú de hamburguesa).
  • hidden md:flex: oculto en el celular, visible desde 768 px (los botones de la barra).

Por qué whitespace-normal. daisyUI pone a stat-title y stat-value la regla «no partir líneas». Eso corta un texto largo en una pantalla angosta. Con whitespace-normal el texto se parte en varias líneas, y text-center lo centra.

Cómo se abre el menú sin JavaScript. El dropdown de daisyUI se abre cuando el botón tiene el foco (tabindex="0") y se cierra cuando lo pierde. Es cómodo, pero al elegir un link el foco sigue en el menú y se queda abierto. Por eso closeMenu() le quita el foco al elemento activo con blur(). El DOCUMENT se inyecta, en lugar de usar document directamente, para no depender de una variable global.

Rutas sin título. La ruta ** (la redirección) no tiene title. Sin el filter, el menú mostraba un botón vacío. La barra horizontal tenía el mismo problema, solo que menos visible.

Errores comunes

  • Escribir primero lo de pantalla grande. Los prefijos son «desde este ancho hacia arriba»: lo del celular va sin prefijo.
  • Olvidar el hidden en el bloque que no corresponde: se ven los dos menús a la vez.
  • Un botón de menú sin etiqueta: un lector de pantalla solo dice «botón». Por eso lleva aria-label, y el icono, aria-hidden.
  • Probar solo en un monitor grande: abre las herramientas de desarrollo y activa el modo celular.
  • Dejar que el menú siga abierto tras navegar: hay que quitarle el foco.

Resumen

Responsive es escribir la versión del celular primero y agregar cambios con prefijos (md:, lg:) para pantallas más grandes. En el navbar, dos bloques se ocultan y se muestran según el ancho; en las tarjetas, una sola clase cambia de vertical a horizontal.

Preguntas de entrevista

1. ¿Qué significa «mobile first» en Tailwind?

Respuesta:

Que las clases sin prefijo valen para todos los anchos, empezando por el celular, y los prefijos (md:, lg:) solo agregan cambios a partir de cierto ancho. Así se parte de la versión más simple y se va enriqueciendo.

2. ¿Qué diferencia hay entre md:hidden y hidden md:flex?

Respuesta:

md:hidden muestra el elemento en el celular y lo oculta desde 768 px. hidden md:flex hace lo contrario: lo oculta en el celular y desde 768 px lo muestra como flex. Juntos permiten tener un menú para cada tamaño.

3. ¿Por qué se oculta un bloque con CSS en lugar de crear uno distinto con @if?

Respuesta:

Porque el ancho de la pantalla lo conoce el CSS sin escribir código, y se adapta solo al girar el celular. Con @if habría que medir la pantalla y escuchar los cambios. La contrapartida es que ambos menús existen en el HTML, aunque uno esté oculto.

4. ¿Cómo se abre y se cierra el menú de daisyUI sin JavaScript?

Respuesta:

Con el foco: el dropdown se muestra mientras el botón (con tabindex="0") tiene el foco y se oculta al perderlo. Por eso, tras elegir un link, se llama a blur() para quitar el foco y cerrarlo.

5. ¿Qué se necesita para que el botón de hamburguesa sea accesible?

Respuesta:

Un nombre que lea el lector de pantalla (aria-label="Open menu"), que sea alcanzable con el teclado (tabindex="0" y role="button") y que el icono decorativo se marque aria-hidden, para que no lo anuncie como una imagen sin nombre.

Módulo PP6: Pipes poco comunes: i18nSelect, i18nPlural, slice, json y keyvalue

El problema que resuelve

Con una lista de personajes en pantalla aparecen preguntas que uppercase y date no resuelven:

  • Quieres decir «invitarlo» o «invitarla» según el género, sin escribir un @if por cada caso.
  • Quieres decir «no hay nadie», «hay una persona» o «hay 5 personas» sin armar la frase a mano.
  • Quieres mostrar solo una parte de la lista, ver un objeto como texto o recorrer sus propiedades.

Para cada una hay un pipe. Todos están en la página uncommon de la app, y por eso se llaman «poco comunes»: no se usan todos los días, pero ahorran código.

i18nSelect: elegir una frase según un valor

Documentación: I18nSelectPipe.

Recibe un texto y un mapa. Busca en el mapa la propiedad que se llama igual que el texto y devuelve su valor:

invitationMap = {
  male: 'invitarlo',
  female: 'invitarla',
}
{{ client().gender | i18nSelect: invitationMap }}

Si gender es 'male' muestra invitarlo. Si el valor no está en el mapa, usa la propiedad other, y si tampoco existe, muestra un texto vacío.

i18nPlural: elegir según una cantidad

Documentación: I18nPluralPipe.

Es parecido, pero recibe un número. Las claves del mapa que empiezan con = son coincidencias exactas, y other es el respaldo:

listMap = {
  '=0': 'there is no one',
  '=1': 'there is one person',
  other: 'there are # people',
}
Right now
<strong>{{ characterList().length | i18nPlural: listMap }}</strong>
waiting.
  • Con 0 personajes sale «there is no one» y con 1, «there is one person».
  • Con cualquier otro número se usa other, y el # se reemplaza por el número: con 5 sale «there are 5 people».
  • El # solo funciona dentro del mensaje, no en la clave. Por eso =0 y =1 no lo llevan: ya sabes cuál es el número.
  • El pipe devuelve la frase completa. El texto alrededor no debe repetirla: por eso el template solo pone «Right now» antes y «waiting.» después.

characterList es una señal. Hay que escribir characterList().length: sin los paréntesis, .length mide la función y siempre da 0.

slice, json y keyvalue

Documentación: SlicePipe, JsonPipe y KeyValuePipe.

slice corta una parte de un arreglo (o de un texto), igual que el slice de JavaScript: el primer número es dónde empieza, el segundo dónde termina, y un número negativo cuenta desde el final. No modifica el arreglo original.

{{ characterList() | slice: 0 : 3 }}   <!-- los primeros tres -->
{{ characterList() | slice: -3 }}      <!-- los últimos tres -->
{{ characterList() | slice: 1 : 4 }}   <!-- del segundo al cuarto -->

json convierte un valor en texto con forma de JSON, con sangría. Sirve para ver qué trae un objeto o un arreglo mientras desarrollas. Se pone dentro de un <pre> para que se conserven los saltos de línea:

<pre>{{ characterList() | slice: 0 : 4 | json }}</pre>

keyvalue convierte un objeto en una lista de pares { key, value }, que se puede recorrer con @for:

@for (item of personProfile | keyvalue; track $index) {
  <p>
    <strong>{{ item.key }}:</strong> {{ item.value.toString() | titlecase }}
  </p>
}
  • Por defecto ordena por la clave, de la A a la Z: aunque el objeto se escriba name, age, gender, address, en pantalla sale address, age, gender, name.
  • item.value puede ser texto o número, por eso se llama toString() antes de titlecase, que solo acepta texto.

Cómo funciona por dentro

  • Los tres primeros reciben un valor y devuelven otro; no cambian el dato original, igual que los pipes de los módulos anteriores.
  • i18nSelect e i18nPlural son una forma de escribir un @switch dentro de una expresión: el mapa hace de lista de casos.
  • Se pueden encadenar: characterList() | slice: 0 : 4 | json primero corta y luego convierte.
  • Cada pipe se importa en imports del componente (I18nSelectPipe, I18nPluralPipe, SlicePipe, JsonPipe, KeyValuePipe, TitleCasePipe).

Errores comunes

  • Olvidar los paréntesis de la señal: characterList.length mide la función; hay que escribir characterList().length.
  • Poner el # en la clave del mapa ('#'): no funciona, va en el mensaje.
  • Repetir en el template lo que ya dice la frase del mapa: sale «hay hay 5 personas».
  • Cambiar un arreglo con pop() y devolver el mismo arreglo en una señal: la señal no ve el cambio porque es el mismo objeto. Hay que devolver uno nuevo, por ejemplo prev.slice(0, -1).
  • Esperar que keyvalue respete el orden del objeto: ordena por clave.

Resumen

i18nSelect elige una frase según un texto e i18nPlural según un número (con # para poner ese número en la frase). slice corta un arreglo sin tocarlo, json lo muestra como texto y keyvalue convierte un objeto en una lista que se puede recorrer.

Preguntas de entrevista

1. ¿Para qué sirven i18nSelect e i18nPlural, y en qué se parecen?

Respuesta:

Los dos eligen una frase de un mapa. i18nSelect busca por un texto (por ejemplo el género) e i18nPlural por una cantidad. En los dos, la clave other es el respaldo cuando nada coincide. Evitan escribir @if o @switch para cada caso en la plantilla.

2. ¿Qué hace el # en el mapa de i18nPlural?

Respuesta:

Es un marcador dentro del mensaje: el pipe lo reemplaza por el número que recibió. Con 5, 'there are # people' queda 'there are 5 people'. No sirve en las claves, y los casos =0 y =1 no lo necesitan porque el número ya se conoce.

3. ¿Qué diferencia hay entre la clave '=1' y la clave other?

Respuesta:

'=1' coincide solo cuando el número es exactamente 1. other coincide con cualquier número que no tenga una clave exacta. El pipe revisa primero las claves exactas y, si ninguna calza, usa other.

4. ¿Modifica slice el arreglo original?

Respuesta:

No. Devuelve un arreglo nuevo con la parte pedida y deja el original igual. Por eso en la pantalla se puede mostrar la lista completa y, al lado, solo sus tres primeros elementos.

5. ¿En qué orden recorre keyvalue las propiedades de un objeto?

Respuesta:

Por defecto ordena por clave, de la A a la Z, sin importar el orden en que se escribió el objeto. Si necesitas otro orden, el pipe acepta una función de comparación como argumento.

6. ¿Por qué characterList.length daba siempre 0 si characterList es una señal?

Respuesta:

Porque una señal es una función, y .length de una función es la cantidad de parámetros que recibe: cero. Para leer el arreglo hay que llamar a la señal, characterList(), y recién entonces pedir .length.

Módulo PP7: AsyncPipe: promesas y observables

El problema que resuelve

Una promesa o un observable entregan su valor más tarde: la promesa a los 2 segundos, el observable cada segundo. Normalmente tendrías que escribir subscribe() o .then() en la clase, guardar el resultado en una variable y, para observables, acordarte de cancelar la suscripción al salir.

El pipe async hace todo eso solo: se suscribe, entrega el último valor que llegó y cancela la suscripción cuando ya no hace falta.

Documentación: AsyncPipe.

El código con una promesa

isPromiseActive = signal(true)
promiseValue = signal(this.createPromise())

createPromise(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve('Promise resolved successfully!'), 2000)
  })
}
@if (isPromiseActive()) {
  @if (promiseValue() | async; as value) {
    <p>{{ value }}</p>
  } @else {
    <p>Loading...</p>
  }
}
  • Mientras no hay valor, async devuelve null, así que el @if muestra el @else («Loading...»). A los 2 segundos llega el texto y el @if lo muestra.
  • ; as value guarda el valor en una variable para usarla dentro del bloque. Como ya es el texto, no lleva otro | async.
  • promiseValue es una señal, por eso se lee con promiseValue().

El código con un observable

myObservable = interval(1000).pipe(tap((value) => console.log(value)))
isSubscribed = signal(false)
@if (isSubscribed()) {
  @if (myObservable | async; as value) {
    <p>Observable: <strong>{{ value }}</strong></p>
  } @else {
    <p>Loading...</p>
  }
}

interval(1000) emite 0, 1, 2, 3… un número por segundo y nunca termina. El tap solo escribe cada valor en la consola, para que veas si la suscripción sigue viva. Un observable puede emitir muchas veces, y el pipe actualiza la pantalla con cada valor nuevo. isSubscribed arranca en false: el contador no corre hasta que pulsas «Subscribe».

Encender y apagar con una señal

Los botones no llaman a subscribe() ni a unsubscribe(). Solo cambian una señal, y el @if pone o quita el pipe de la plantilla:

subscribeToObservable(): void {
  this.isSubscribed.set(true)
}

cancelSubscription(): void {
  this.isSubscribed.set(false)
}
<button [disabled]="isSubscribed()" (click)="subscribeToObservable()">Subscribe</button>
<button [disabled]="!isSubscribed()" (click)="cancelSubscription()">Cancel Subscription</button>

Cuando el @if quita el pipe, el pipe se destruye y cancela la suscripción por su cuenta. Al volver a ponerlo, se suscribe de nuevo y el contador empieza otra vez desde 0. Cada botón se deshabilita cuando no tiene sentido usarlo.

Con la promesa se hace igual, con una diferencia: una promesa se resuelve una sola vez y no se puede cancelar. Si al volver a activar la card reutilizaras la misma promesa, ya estaría resuelta y el valor aparecería de golpe, sin esperar los 2 segundos. Por eso el botón crea una promesa nueva:

triggerPromise(): void {
  this.promiseValue.set(this.createPromise())
  this.isPromiseActive.set(true)
}

resetPromise(): void {
  this.isPromiseActive.set(false)
}

Al apagar la card, la promesa sigue corriendo en segundo plano, pero el pipe ya no está y nadie escucha el resultado.

Cómo funciona por dentro

  • async se suscribe en cuanto aparece en la plantilla y avisa a Angular cuando llega un valor, para que la pantalla se actualice.
  • Antes del primer valor devuelve null.
  • Cuando se destruye (porque el @if lo quitó o porque se cerró el componente), cancela la suscripción. Por eso no hay que escribir ngOnDestroy para esto.
  • interval es un observable frío (cold): cada suscripción empieza su propia cuenta desde 0.

Errores comunes

  • Poner un segundo | async sobre el valor ya resuelto ({{ value | async }}): value ya es el texto, no una promesa; la compilación falla.
  • Usar | async varias veces sobre el mismo observable frío: cada uso crea su propia suscripción, y con interval serían varios contadores y varios console.log. Si necesitas el valor en varios lugares, guárdalo con as.
  • Esperar que el @if muestre el 0: @if trata el 0 como falso, así que el contador muestra «Loading...» los dos primeros segundos (el primero porque no llegó nada, el segundo porque llegó el 0).
  • Reutilizar una promesa ya resuelta para «volver a cargar»: no vuelve a esperar. Hay que crear una nueva.
  • Llamar subscribe() a mano y no guardar la suscripción: no podrás cancelarla, y cada clic crea otra.

Resumen

async se suscribe a una promesa o a un observable, muestra el último valor (null mientras no hay ninguno) y cancela la suscripción al desaparecer. Para encender y apagar, basta una señal y un @if. Con promesas hay que crear una nueva cada vez, porque solo se resuelven una vez.

Preguntas de entrevista

1. ¿Qué ventajas tiene async frente a hacer subscribe() en la clase?

Respuesta:

Se suscribe y cancela la suscripción solo, así que no hay fugas por olvidar el unsubscribe(). Además actualiza la pantalla cada vez que llega un valor y evita guardar el resultado en una variable de la clase.

2. ¿Qué devuelve async antes de que llegue el primer valor?

Respuesta:

null. Por eso se combina con @if (… | async; as value) y un @else: el @else cubre la espera y el bloque principal se muestra cuando ya hay valor.

3. ¿Por qué el contador muestra «Loading...» durante dos segundos y no uno?

Respuesta:

Porque interval emite el primer valor, 0, al pasar un segundo, y @if lo trata como falso, igual que a null. El primer valor «verdadero» (el 1) llega al segundo 2.

4. ¿Cómo cancelas la suscripción de un async sin llamar a unsubscribe()?

Respuesta:

Quitando el pipe de la plantilla, por ejemplo con un @if que depende de una señal. Al destruirse, el pipe cancela la suscripción por su cuenta. Al volver a mostrarlo, se suscribe de nuevo.

5. ¿Por qué hay que crear una promesa nueva para «volver a ejecutarla»?

Respuesta:

Porque una promesa se resuelve una sola vez y recuerda su resultado. Si el pipe se suscribe a una promesa ya resuelta, recibe el valor de inmediato, sin esperar. Una promesa nueva vuelve a empezar la espera.

6. ¿Qué pasa si usas | async tres veces sobre interval(1000)?

Respuesta:

Se crean tres suscripciones, y como interval es frío, cada una tiene su propio contador y escribe en la consola por separado. Para compartir una sola, guarda el valor con as en un @if, o convierte el observable en una señal.

Módulo PP8: Un componente compartido: el badge de documentación

El problema que resuelve

Cada sección de la app tiene un link a la documentación de Angular de su pipe. Si escribes el <a> con sus clases en cada sección, el mismo bloque queda copiado en una docena de lugares: para cambiar el color, el texto o la forma de abrir el link tendrías que tocarlos todos, y es fácil que uno quede distinto.

Un componente compartido resuelve eso: el link se escribe una vez (app-docs-link) y cada sección solo dice qué pipe quiere.

El código

El componente recibe cuatro datos con input():

export class DocsLinkComponent {
  name = input.required<string>()
  label = input('Docs')
  color = input<BadgeColor>('primary')
  url = input<string>()
  href = computed(() => this.url() ?? `https://angular.dev/api/common/${this.name()}`)
  colorClass = computed(() => BADGE_COLORS[this.color()])
}
<a
  class="badge"
  [class]="colorClass()"
  [href]="href()"
  [attr.title]="name()"
  target="_blank"
  rel="noopener noreferrer"
>
  {{ label() }}<span class="sr-only"> ({{ name() }}, opens in a new tab)</span>
</a>
  • name es obligatorio: es la clase del pipe (DatePipe) y de ahí sale la dirección angular.dev/api/common/DatePipe.
  • label es el texto del badge. Por defecto dice «Docs»; en «Text Pipes» y «Number Pipes» hay tres badges juntos, así que cada uno muestra el nombre de su pipe.
  • color es uno de los colores de badge de daisyUI (primary, secondary, accent, neutral, info, success, warning, error). Si no se pasa, es primary.
  • url es opcional: sirve para algo que no está en la referencia de la API, como la guía de pipes propios, donde no hay una página por pipe.

Y así se usa en una sección:

<div class="flex flex-wrap items-center gap-2 my-5">
  <h2 class="text-xl">Text Pipes</h2>
  <div class="ml-auto flex flex-wrap gap-2">
    <app-docs-link name="LowerCasePipe" label="LowerCasePipe" />
    <app-docs-link color="accent" name="UpperCasePipe" label="UpperCasePipe" />
    <app-docs-link color="info" name="TitleCasePipe" label="TitleCasePipe" />
  </div>
</div>

Por qué los colores están en una lista

Podrías escribir [class]="'badge-' + color()", pero no funciona. Tailwind busca en el código las clases escritas completas y solo genera el CSS de esas. Si badge-accent nunca aparece escrita, no se genera y el badge se ve sin color. Por eso los colores están en un objeto:

const BADGE_COLORS = {
  primary: 'badge-primary',
  accent: 'badge-accent',
  // ...
} as const

export type BadgeColor = keyof typeof BADGE_COLORS

Así cada clase aparece completa en el código, y el tipo BadgeColor hace que un color mal escrito falle al compilar en vez de verse mal en pantalla.

Poner el badge junto al título de una card

La card (app-card) no sabe nada de documentación. Solo reserva un lugar junto al título para lo que sea un app-docs-link, con select en ng-content:

<div class="flex flex-wrap items-center gap-2 mb-2">
  <h2 class="card-title">{{ title() }}</h2>
  <div class="ml-auto flex flex-wrap gap-2">
    <ng-content select="app-docs-link"></ng-content>
  </div>
</div>
<ng-content></ng-content>

Todo lo que se escribe dentro de <app-card> va al <ng-content> normal, salvo los <app-docs-link>, que van al lugar junto al título. El ml-auto (margen izquierdo automático) empuja los badges a la derecha de la fila.

Cómo funciona por dentro

  • input() define datos que el padre pasa por la plantilla. Con input.required Angular exige que el padre lo pase; con un valor inicial (input('Docs')) es opcional.
  • computed() calcula href y colorClass a partir de los inputs y se recalcula solo si alguno cambia.
  • El link abre una pestaña nueva, así que lleva rel="noopener noreferrer": la página nueva no puede controlar la pestaña de origen.
  • Para accesibilidad: el texto visible es corto («Docs»), y un texto oculto (sr-only) le dice al lector de pantalla a qué pipe corresponde y que se abre una pestaña nueva. El nombre también sale como tooltip (title).

Errores comunes

  • Armar la clase con un texto ('badge-' + color): Tailwind no la ve y el color no se aplica.
  • Varios badges iguales en una sección: si todos dicen «Docs», nadie sabe cuál es cuál. Usa label con el nombre de cada pipe.
  • Abrir una pestaña nueva sin rel="noopener noreferrer".
  • Un link que solo dice «Docs» para un lector de pantalla: no dice a qué pertenece. Hace falta el texto oculto con el nombre.
  • Copiar el <a> en cada página en vez de usar el componente.

Resumen

Un componente compartido escribe una vez lo que se repite y lo configura con input(): name (obligatorio), label, color y url. Los colores van en un objeto para que Tailwind genere sus clases, y la card reserva un lugar junto al título con ng-content select.

Preguntas de entrevista

1. ¿Qué diferencia hay entre input() e input.required()?

Respuesta:

input.required() obliga al padre a pasar el dato: si no lo pasa, la plantilla no compila. input() con un valor inicial es opcional y usa ese valor cuando el padre no dice nada. En el badge, name es obligatorio y label y color tienen valor por defecto.

2. ¿Por qué no funciona [class]="'badge-' + color()" con Tailwind?

Respuesta:

Porque Tailwind genera el CSS leyendo el código fuente como texto y buscando clases escritas completas. Una clase armada al ejecutar (badge- más el color) nunca aparece completa en el código, así que no se genera. La solución es tener las clases completas en un objeto y elegir una con el valor.

3. ¿Para qué sirve select en ng-content?

Respuesta:

Para repartir el contenido que el padre escribe dentro del componente en varios lugares. Con select="app-docs-link", los <app-docs-link> van a ese lugar y todo lo demás va al <ng-content> sin select.

Respuesta:

Porque abre una pestaña nueva (target="_blank"). Sin noopener, la página nueva podría controlar la pestaña original; noreferrer además evita enviarle la dirección de origen.

5. ¿Para qué sirve un type como BadgeColor en lugar de un string?

Respuesta:

Con string se podría escribir cualquier cosa, incluso color="rojo", y el error se vería recién en pantalla. Con un tipo de unión ('primary' | 'accent' | ...), un valor que no está en la lista falla al compilar.

Módulo PP9: Un pipe propio con argumento: toggleCase

El problema que resuelve

En la página custom quieres mostrar un nombre en mayúsculas o en minúsculas, y que un botón cambie entre las dos. Con uppercase y lowercase tendrías que escribir dos pipes distintos en la plantilla y decidir cuál mostrar con un @if.

Un pipe propio con argumento lo resuelve: es un solo pipe, y el argumento dice cómo debe transformar. Ya viste uno en el Módulo PP4 (timeZone recibe la zona); este es el más pequeño posible para ver la idea.

El código

El pipe vive en src/app/pipes/toggle-case.pipe.ts:

@Pipe({
  name: 'toggleCase',
})
export class ToggleCasePipe implements PipeTransform {
  transform(value: any, CaseType: CaseType = 'uppercase'): string {
    const name = signal(value)
    return CaseType === 'uppercase' ? name().toUpperCase() : name().toLowerCase()
  }
}

Y el tipo del argumento está en src/app/interfaces/toggle-case.interfaces.ts:

export type CaseType = 'uppercase' | 'lowercase'

En la plantilla se usa sin argumento (toma el valor por defecto) y con argumento:

{{ name() | toggleCase }}
{{ name() | toggleCase: toggleCaseValue() }}

Y en el componente, una señal guarda cuál de los dos modos está activo y el botón la cambia:

toggleCaseValue = signal<'uppercase' | 'lowercase'>('uppercase')

handleToggleCase() {
  this.toggleCaseValue.set(this.toggleCaseValue() === 'uppercase' ? 'lowercase' : 'uppercase')
}

Cómo funciona por dentro

  • @Pipe({ name: 'toggleCase' }) es el nombre que escribes después de la barra |. Es parecido al selector de un componente.
  • implements PipeTransform obliga a tener un método transform. Angular lo llama cada vez que cambia el valor de la izquierda.
  • El primer parámetro de transform es el valor de la izquierda de |. Los demás son los argumentos que se escriben después de :.
  • El segundo parámetro tiene un valor por defecto ('uppercase'), así que el argumento es opcional: toggleCase solo equivale a toggleCase: 'uppercase'.
  • El tipo CaseType es una unión de dos textos. Si escribes toggleCase: 'titlecase', el compilador avisa.
  • El pipe es standalone (como los componentes de este proyecto), así que no se declara en ningún módulo: cada componente que lo usa lo pone en su imports, igual que TimeZonePipe.
  • Un pipe puro (el que Angular crea por defecto) solo se vuelve a ejecutar si cambia el valor o algún argumento. Por eso el botón funciona: al cambiar toggleCaseValue(), cambia el argumento y el pipe se ejecuta de nuevo.

Errores comunes

  • Olvidar poner el pipe en imports del componente: la plantilla da error de que no existe el pipe.
  • Llamar transform desde la plantilla ({{ pipe.transform(...) }}) en vez de usar |.
  • Pasar el argumento sin los : (toggleCase 'lowercase'): la sintaxis es toggleCase: 'lowercase'.
  • Cambiar el valor por dentro (por ejemplo, modificar un objeto y esperar que el pipe se entere): un pipe puro mira si el valor es otro, no si cambió por dentro.
  • Usar any en value cuando solo tiene sentido con texto: string es más claro y el compilador avisa si pasas otra cosa.

Resumen

Un pipe propio es una clase con @Pipe y un método transform. El primer parámetro es el valor; los siguientes son los argumentos, y pueden tener valor por defecto. Se importa en cada componente que lo usa y, al ser puro, se vuelve a ejecutar solo cuando cambia el valor o un argumento.

Preguntas de entrevista

1. ¿Qué hace falta para crear un pipe propio?

Respuesta:

Una clase con el decorador @Pipe({ name: '...' }) que implemente PipeTransform, es decir, que tenga un método transform. El name es lo que se escribe en la plantilla después de |. Como es standalone, se agrega al imports del componente que lo usa.

2. ¿Cómo recibe argumentos un pipe?

Respuesta:

Como parámetros extra de transform. El primero es el valor de la izquierda de | y el resto son los argumentos que se escriben después de :, en el mismo orden. Si el parámetro tiene valor por defecto, el argumento es opcional.

3. ¿Qué es un pipe puro y por qué importa?

Respuesta:

Es un pipe que Angular solo vuelve a ejecutar cuando cambia el valor de entrada o alguno de sus argumentos (por referencia). Importa para el rendimiento: transform no se llama en cada ciclo de detección de cambios. Pero si le pasas un objeto y lo modificas por dentro, el pipe no se entera. Hay pipes impuros (pure: false), que se ejecutan siempre, pero son más lentos y casi nunca hacen falta.

4. ¿Por qué usar un pipe y no un método del componente en la plantilla?

Respuesta:

Un método se vuelve a llamar en cada ciclo de detección de cambios, aunque nada haya cambiado. Un pipe puro guarda el resultado y solo recalcula cuando cambia su entrada. Además, el pipe se puede reutilizar en cualquier plantilla sin copiar código.

Módulo PP10: Constantes: los datos fuera del componente

El problema que resuelve

Al principio, los datos de ejemplo viven dentro del componente: el nombre 'Geralt of Rivia', la lista de personajes, la dirección de la wiki. Cuando otra página necesita el mismo dato lo copias, y ahora hay dos lugares que cambiar. Además, el componente se llena de datos y cuesta ver su lógica.

La solución es sacarlos a un archivo de constantes: se escriben una vez y cada componente los importa.

El código

Todo está en src/app/constants/app.constants.ts (recortado: el archivo real tiene tres personajes, veinte nombres y veinticinco héroes):

import { Color, Character, Creator, Hero } from '../interfaces/pages.interfaces'

export const EXTERNAL_WIKI_URL = 'https://angular-wiki.reskyon.com/05-pipes-app/'
export const EXTERNAL_ORGANIZATION_URL = 'https://github.com/Reskyon'

export const characterOne: Character = {
  name: 'Geralt of Rivia',
  age: 100,
  gender: 'male',
  address: 'Kaer Morhen',
}

export const characterList = ['Geralt of Rivia', 'Cirilla Fiona Elen Riannon', /* ... */]

export const ColorMap = {
  [Color.red]: '#E57373',
  [Color.black]: '#424242',
  [Color.blue]: '#64B5F6',
  [Color.green]: '#81C784',
}

export const heroes: Hero[] = [
  { id: 1, name: 'Superman', canFly: true, color: Color.blue, creator: Creator.DC },
  { id: 2, name: 'Batman', canFly: false, color: Color.black, creator: Creator.DC },
  /* ... */
]

Character es una interface en src/app/interfaces/pages.interfaces.ts:

export interface Character {
  name: string
  age: number
  gender: string
  address: string
}

Hay dos clases de datos en el archivo:

  • Direcciones (EXTERNAL_WIKI_URL, EXTERNAL_ORGANIZATION_URL): las usan el navbar y el footer para sus links.
  • Datos de ejemplo (characterOne, characterTwo, characterThree, characterList, heroes): los usan las páginas para probar los pipes. heroes es la lista de la tabla de la página custom.
  • ColorMap: el código hexadecimal de cada Color; lo usa el pipe colorMap (Módulo PP11).

Y así se usan. La página custom toma el nombre del personaje y lo guarda en una señal para pasárselo al pipe:

import { characterOne } from '../../constants/app.constants'

name = signal(characterOne.name)

El navbar y el footer solo importan la dirección que necesitan:

import { EXTERNAL_WIKI_URL } from '../../constants/app.constants'

Cómo funciona por dentro

  • Un export const en un archivo .ts normal es un valor que cualquier otro archivo puede importar. No hay nada de Angular: es TypeScript común.
  • Cada componente importa solo lo que usa ({ characterOne }), no todo el archivo.
  • Poner el tipo (: Character) a cada objeto hace que el compilador avise si falta un campo o si escribes mal uno. Lo ves al guardar, no en pantalla.
  • Las constantes son datos que no cambian. Si un dato debe cambiar y la pantalla debe enterarse, se mete en una señal: signal(characterOne.name). La constante es el valor de partida; la señal es la que se actualiza.
  • Los tipos (Character, CaseType) van en interfaces/, separados de los datos, que van en constants/. Así cada carpeta tiene una sola función.

Errores comunes

  • Modificar una constante desde un componente (characterOne.name = '...'): como el objeto lo comparten todas las páginas, el cambio se ve en todas. Si necesitas cambiarlo, haz una copia ({ ...characterOne }) o usa una señal.
  • Meter claves secretas en el archivo de constantes: va al navegador y cualquiera la ve. Una clave secreta va siempre por el proxy (ver la Parte 3: Gifs App). Una dirección pública como la de la wiki sí puede estar aquí.
  • Copiar el mismo dato en dos componentes en vez de importarlo.
  • Un solo archivo gigante cuando el proyecto crece: conviene separar por tema (direcciones, personajes) si el archivo ya no se lee de un vistazo.

Resumen

Los datos que se repiten o que ensucian el componente van a constants/app.constants.ts y se importan donde se necesitan. Los tipos van aparte, en interfaces/. Una constante no cambia; si algo debe cambiar en pantalla, se parte de la constante y se guarda en una señal.

Preguntas de entrevista

1. ¿Por qué sacar los datos a un archivo de constantes?

Respuesta:

Para tener cada dato en un solo lugar: si cambia, lo cambias una vez y todos los componentes lo ven. Además el componente queda con su lógica, sin datos de ejemplo en medio, y es más fácil de leer y de probar.

2. ¿Cuál es la diferencia entre constants/ e interfaces/?

Respuesta:

constants/ guarda valores que existen en tiempo de ejecución (un texto, un objeto, una lista). interfaces/ guarda tipos, que solo existen mientras compilas y desaparecen del código final. Separarlos deja claro qué es un dato y qué es la forma que debe tener.

3. Una constante es un objeto y dos componentes lo importan. Si uno lo modifica, ¿qué pasa?

Respuesta:

El otro también ve el cambio, porque los dos tienen una referencia al mismo objeto (un módulo se carga una sola vez). const solo impide reasignar la variable, no cambiar las propiedades del objeto. Para evitarlo se hace una copia ({ ...characterOne }), se usa una señal, o se marca el tipo como Readonly.

4. ¿Cuándo una constante debe pasar a ser una señal?

Respuesta:

Cuando el valor debe cambiar y la pantalla debe enterarse. La constante sigue siendo el valor de partida (signal(characterOne.name)), y la señal es la que se actualiza con set(). Así los datos fijos quedan en un lugar y el estado que cambia, en el componente.

Módulo PP11: Pipes propios con enums: yesNo, colorMap y creatorMap

El problema que resuelve

En la página custom hay una tabla de héroes. Cada héroe trae datos que no se pueden mostrar tal cual: canFly es true o false, y color y creator son números (0, 1, 2…). Si los pones en la tabla, el usuario lee «true» y «2», que no dicen nada.

Hace falta traducir cada dato a algo que se entienda: «Yes» o «No», el nombre del color, el nombre de la editorial. Tres pipes propios lo hacen, y la plantilla queda limpia, sin @if ni funciones.

Qué es un enum

Un enum es una lista de nombres que TypeScript guarda como números. Sirve para no escribir números sueltos en el código:

export enum Color {
  red,    // 0
  black,  // 1
  blue,   // 2
  green,  // 3
}

export enum Creator {
  DC,     // 0
  Marvel, // 1
}

Así escribes Color.blue en vez de 2, y el código se lee solo. Un detalle útil de los enums numéricos: funcionan en los dos sentidos.

Color.blue // 2   (del nombre al número)
Color[2]   // 'blue'   (del número al nombre: el «mapeo inverso»)

Eso es justo lo que necesitan los pipes. Solo funciona con enums numéricos: si le pones valores de texto (red = 'red'), el camino inverso no existe.

El código

El héroe y el tipo del resultado de yesNo están en src/app/interfaces/pages.interfaces.ts:

export interface Hero {
  id: number
  name: string
  canFly: boolean
  color: Color
  creator: Creator
}

export type YesNo = 'Yes' | 'No'

yesNo: de un booleano a texto.

@Pipe({ name: 'yesNo' })
export class YesNoPipe implements PipeTransform {
  transform(value: boolean): YesNo {
    return value ? 'Yes' : 'No'
  }
}

creatorMap: de un número a su nombre, con el mapeo inverso.

@Pipe({ name: 'creatorMap' })
export class CreatorMapPipe implements PipeTransform {
  transform(value: Creator): string {
    return Creator[value]
  }
}

colorMap: devuelve el nombre del color o su código hexadecimal, según un argumento opcional.

@Pipe({ name: 'colorMap' })
export class ColorMapPipe implements PipeTransform {
  transform(value: Color, color: 'name' | 'value' = 'name'): string {
    return color === 'name' ? Color[value] : ColorMap[value]
  }
}

ColorMap es un objeto de constants/app.constants.ts que guarda el código de cada color (ver Módulo PP10):

export const ColorMap = {
  [Color.red]: '#E57373',
  [Color.black]: '#424242',
  [Color.blue]: '#64B5F6',
  [Color.green]: '#81C784',
}

Y así se usan en la tabla. El componente guarda la lista de héroes en una señal y expone el enum Creator como una propiedad, porque la plantilla no puede leer un enum directamente:

import { characterOne, heroes } from '../../constants/app.constants'
import { Creator, Hero } from '../../interfaces/pages.interfaces'

heroes = signal<Hero[]>(heroes)
creator = Creator

En esta etapa anterior a la búsqueda y la ordenación, la plantilla recorre la señal con un @for, y colorea la editorial comparando con creator.DC y creator.Marvel, no con los números:

@for (hero of heroes(); track hero.id; let i = $index) {
  <tr>
    <td>{{ hero.name }}</td>
    <td>{{ hero.canFly | yesNo }}</td>
    <td>
      <div class="badge" [style.backgroundColor]="hero.color | colorMap: 'value'">
        {{ hero.color | colorMap | titlecase }}
      </div>
    </td>
    <td
      [class.text-info]="hero.creator === creator.DC"
      [class.text-error]="hero.creator === creator.Marvel"
    >
      {{ hero.creator | creatorMap | titlecase }}
    </td>
  </tr>
}

Asociar un enum con un valor: el objeto ColorMap

Un enum te da el nombre (Color[2] es 'blue'). Pero a veces necesitas un valor propio para cada opción: el código hexadecimal de cada color, un texto en español, un icono. Eso el enum no lo guarda. Para eso se hace un objeto que asocia cada opción con su valor:

export const ColorMap = {
  [Color.red]: '#E57373',
  [Color.black]: '#424242',
  [Color.blue]: '#64B5F6',
  [Color.green]: '#81C784',
}

Se lee como una tabla de dos columnas: «cuando la opción es red, el valor es #E57373».

Opción del enum Número Valor en ColorMap
Color.red 0 '#E57373'
Color.black 1 '#424242'
Color.blue 2 '#64B5F6'
Color.green 3 '#81C784'

Los corchetes [Color.red]. En un objeto normal, la clave se escribe como texto: { red: '#E57373' }. Con corchetes, JavaScript primero calcula lo que hay dentro y usa el resultado como clave. [Color.red] vale 0, así que el objeto queda { 0: '#E57373', 1: '#424242', ... }. Se llaman claves calculadas (computed property names).

¿Por qué no escribir { red: '#E57373' } y ya? Porque el pipe recibe el número (hero.color vale 2), no el nombre. Con las claves hechas con el enum, la búsqueda es directa: ColorMap[2] da '#64B5F6'. Y como las claves salen del enum, el compilador te ayuda: si agregas yellow al enum y te olvidas de ponerle su valor, el pipe deja de compilar donde lo usas con ese color.

Dos búsquedas distintas sobre el mismo número. El pipe colorMap usa las dos y el argumento elige cuál:

Color[value]    // 'blue'     -> el enum devuelve el NOMBRE
ColorMap[value] // '#64B5F6'  -> el objeto devuelve el VALOR PROPIO

Para la hora de elegir dónde guardar un dato nuevo:

  • Si solo necesitas el nombre de la opción, ya lo tienes en el enum (Creator[value] en creatorMap). No hace falta ningún objeto.
  • Si cada opción necesita un dato propio (el color hexadecimal), haz un objeto como ColorMap.

¿Por qué no usar keyvalue aquí?

En el Módulo PP6 viste keyvalue, que convierte un objeto en una lista de pares { key, value }. Parece buen candidato para ColorMap, pero no encaja, por dos razones.

1. keyvalue sirve para recorrer, y aquí hay que buscar. keyvalue es para cuando quieres mostrar todas las entradas de un objeto (por ejemplo con un @for). Aquí pasa lo contrario: la tabla de héroes ya tiene el número de un héroe y necesita un solo valor. Eso es una búsqueda directa por clave, ColorMap[value], que es inmediata. Con keyvalue tendrías que armar la lista de todos los pares y luego buscar dentro de ella cuál es el que corresponde: más trabajo para el mismo resultado.

<!-- keyvalue: mostrar TODAS las entradas -->
@for (item of ColorMap | keyvalue; track item.key) {
  <li>{{ item.key }}: {{ item.value }}</li>
}

<!-- búsqueda: UN valor, el que corresponde a este héroe -->
{{ hero.color | colorMap: 'value' }}

2. Con un enum, keyvalue mezcla los dos sentidos. Un enum numérico guarda los datos en las dos direcciones (el mapeo inverso del que hablamos arriba), así que Color tiene ocho entradas, no cuatro:

// Color por dentro
{ 0: 'red', 1: 'black', 2: 'blue', 3: 'green',
  red: 0, black: 1, blue: 2, green: 3 }

Si le pasas el enum a keyvalue, recibes las ocho: cuatro con el número como clave y cuatro con el nombre como clave. Para listar las opciones tendrías que filtrar las que sobran. Y las claves de un objeto son siempre texto, así que item.key te llega como '2', no como el número 2.

Resumen de cuándo usar cada cosa:

Necesito... Uso
El valor de una opción que ya tengo ColorMap[value] (pipe propio)
El nombre de una opción Color[value] (mapeo inverso)
Listar todas las entradas de un objeto keyvalue con @for
Listar las opciones de un enum keyvalue no, mejor un arreglo con los valores que quieras mostrar

Cómo funciona por dentro

  • Cada pipe recibe el dato crudo y devuelve texto. El dato original (hero) no cambia: el pipe solo cambia cómo se muestra.
  • colorMap se usa de dos maneras con el mismo nombre: sin argumento da el nombre ('red'), y con : 'value' da el código ('#E57373'). El argumento es opcional porque tiene un valor por defecto.
  • Los pipes se pueden encadenar: hero.color | colorMap | titlecase primero pasa de número a nombre y después pone la primera letra en mayúscula. titlecase es un pipe de Angular que ya conoces del Módulo PP1.
  • El código hexadecimal va a [style.backgroundColor], que cambia un estilo del elemento. No se puede usar una clase de Tailwind, porque el color sale de un dato y Tailwind solo genera las clases que aparecen escritas completas (ver Módulo PP8).
  • [class.text-success]="hero.canFly" agrega la clase solo si la condición es verdadera. Con eso la columna «Flight?» se ve verde para «Yes» y de otro color para «No».
  • @for necesita track hero.id: le dice a Angular cómo reconocer cada fila, para no repintar toda la tabla cuando cambia la lista.
  • Los tres pipes son standalone y puros: cada uno va en el imports del componente y solo se ejecuta cuando cambia su valor o su argumento (ver Módulo PP9).

Errores comunes

  • Usar Color[value] con un enum de texto: el mapeo inverso solo existe en los enums numéricos.
  • Comparar con el número (hero.creator === 1) en vez de con el enum (Creator.Marvel): funciona, pero nadie recuerda qué es el 1. En la plantilla no se puede escribir Creator.Marvel directamente: hay que guardar el enum en una propiedad del componente. Así lo hace la página custom: creator = Creator, y en la plantilla hero.creator === creator.Marvel.
  • Armar la clase de Tailwind con el nombre del color ('bg-' + colorName): Tailwind no la genera. Para colores que vienen de un dato, usa el código hexadecimal con [style.backgroundColor].
  • Escribir { red: '#E57373' } en ColorMap cuando el pipe recibe el número: ColorMap[2] no encuentra nada. Las claves deben ser [Color.red], que valen 0, 1, 2, 3.
  • Usar keyvalue para buscar un solo valor: keyvalue es para recorrer todas las entradas. Para una sola, se busca por clave: ColorMap[value].
  • Pasarle un enum a keyvalue y esperar cuatro entradas: llegan ocho, porque el enum guarda el número y el nombre.
  • Dejar un console.log dentro de transform: se ejecuta cada vez que cambia el valor y llena la consola.
  • Olvidar agregar TitleCasePipe al imports: es un pipe de Angular, pero en un componente standalone también hay que importarlo.

Resumen

Un enum guarda nombres como números, y el mapeo inverso (Color[2]) devuelve el nombre. Tres pipes propios traducen datos crudos a texto legible: yesNo (booleano a «Yes»/«No»), creatorMap (número a nombre) y colorMap (número a nombre o a código hexadecimal, según un argumento). Para darle a cada opción un valor propio (como el código hexadecimal) se usa un objeto con claves calculadas, { [Color.red]: '#E57373' }, y se busca por clave: ColorMap[value]. No se usa keyvalue porque ese pipe sirve para recorrer todas las entradas, no para buscar una, y con un enum mezcla el número y el nombre. Los pipes se pueden encadenar con pipes de Angular como titlecase.

Preguntas de entrevista

1. ¿Qué es el mapeo inverso de un enum?

Respuesta:

Es que un enum numérico se puede leer en los dos sentidos. Color.blue da 2, y Color[2] da 'blue'. TypeScript lo crea solo para los enums numéricos; en los enums de texto no existe.

2. ¿Por qué usar un pipe y no preguntar en la plantilla con @if?

Respuesta:

Porque la traducción queda en un solo lugar y se reutiliza. Con @if tendrías que repetir la condición en cada tabla o página que muestre el dato. Además, un pipe puro no se vuelve a calcular si su valor no cambió.

3. ¿Cómo se encadenan pipes y en qué orden se ejecutan?

Respuesta:

Se escriben uno tras otro con |, y se ejecutan de izquierda a derecha: la salida de uno es la entrada del siguiente. En hero.color | colorMap | titlecase, colorMap convierte el número en 'red' y titlecase lo deja en 'Red'.

4. ¿Para qué sirve track en un @for?

Respuesta:

Para que Angular reconozca cada elemento de la lista. Si la lista cambia, Angular sabe qué filas son las mismas, cuáles son nuevas y cuáles se fueron, y solo toca esas en vez de repintar todo. Se usa algo único, como el id.

5. ¿Por qué el color se pone con [style.backgroundColor] y no con una clase de Tailwind?

Respuesta:

Porque el color viene de un dato y se conoce al ejecutar. Tailwind genera el CSS leyendo el código fuente y solo crea las clases que están escritas completas; una clase armada con el nombre del color no existe. Un estilo en línea con el código hexadecimal no depende de eso.

6. ¿Cómo se asocia cada opción de un enum con un valor propio?

Respuesta:

Con un objeto cuyas claves salen del enum: { [Color.red]: '#E57373', ... }. Los corchetes hacen que JavaScript calcule la clave (Color.red vale 0), y así ColorMap[value] encuentra el valor a partir del número. El enum solo guarda el nombre; si cada opción necesita otro dato (un color, un texto, un icono), ese dato va en un objeto aparte.

7. ¿Por qué no se usó keyvalue para ColorMap?

Respuesta:

Porque keyvalue convierte un objeto en una lista de pares para recorrerla (por ejemplo con @for), y aquí se necesita un solo valor: el del héroe que se está mostrando. Eso es una búsqueda directa por clave, ColorMap[value], que no necesita armar ninguna lista. keyvalue sí sirve cuando quieres mostrar todas las entradas de un objeto.

8. ¿Qué pasa si le pasas un enum numérico a keyvalue?

Respuesta:

Llegan el doble de entradas: un enum numérico guarda el número y el nombre ({ 0: 'red', red: 0, ... }), así que Color da ocho pares en vez de cuatro. Además las claves de un objeto son texto, así que la clave llega como '2' y no como el número 2. Por eso, para listar las opciones de un enum conviene un arreglo con las que quieres mostrar.

9. ¿Qué ventaja tiene crear las claves con el enum en vez de escribirlas a mano?

Respuesta:

Que el compilador te protege. Si agregas una opción nueva al enum y no le pones su valor en el objeto, el código que busca por esa opción deja de compilar y te avisa. Con claves escritas a mano, el error aparecería recién en pantalla, como un valor vacío.

Módulo PP12: Ordenar héroes con sort y switch

El problema que resuelve

La tabla de custom ya muestra héroes. Ahora quieres que un botón los ordene por nombre y otro ponga primero a quienes vuelan. No puedes comparar un nombre, un booleano y un enum de la misma manera: cada propiedad necesita su regla.

La ordenación está en hero-sort-by.pipe.ts. La construcción de un pipe ya está en el Módulo PP9; aquí el foco es entender y poder repetir la ordenación de un array (sorting).

El código

Este es el método actual de src/app/pipes/hero-sort-by.pipe.ts, sin los comentarios que se desarrollan debajo:

transform(value: Hero[], sortBy: keyof Hero | null): Hero[] {
  if (!sortBy) {
    return value
  }
  const sorted = [...value]

  switch (sortBy) {
    case 'name':
      return sorted.sort((a, b) => a.name.localeCompare(b.name))
    case 'canFly':
      return sorted.sort((a, b) => Number(b.canFly) - Number(a.canFly))
    case 'color':
      return sorted.sort((a, b) => a.color - b.color)
    case 'creator':
      return sorted.sort((a, b) => a.creator - b.creator)
    default:
      break
  }

  return value
}

Elegir una propiedad con keyof y switch

El componente guarda la elección en una señal:

sortBy = signal<keyof Hero | null>(null)

handleSort(sortBy: keyof Hero | null) {
  this.sortBy.set(sortBy)
}

keyof Hero representa los nombres de las propiedades de Hero: 'id' | 'name' | 'canFly' | 'color' | 'creator'. No contiene los valores de un héroe concreto. null significa que no hay criterio elegido.

switch (sortBy) compara la elección con cada case usando igualdad estricta (===, sin convertir tipos). Si vale 'name', entra en ese caso; si vale 'color', entra en el del color. default recoge las opciones sin caso propio: por ahora, 'id' pertenece al tipo, pero no tiene ordenación implementada.

En cada caso hay un return: termina todo el método y devuelve la lista. Por eso no hace falta un break después. En cambio, el break de default solo sale del switch; después se ejecuta el return value final. Cuando la elección es null, el primer if devuelve la lista sin entrar al switch.

Qué devuelve el comparador de sort

En sort((a, b) => ...), a y b son dos elementos del array. La función que recibes dentro de otra función se llama callback. Aquí actúa como comparador (comparator): devuelve un número que decide el orden de esa pareja. sort lo llama tantas veces como necesita; tú no eliges las parejas ni debes depender del orden en que las compara.

Resultado Dónde debe quedar a respecto de b
Negativo Antes de b
Positivo Después de b
Cero Empatan para ese criterio; conservan su orden relativo

Negativo no significa descendente, ni positivo significa ascendente. El signo decide dónde va una pareja. La fórmula del comparador decide el sentido del orden de toda la lista.

Ejemplo independiente para practicar con números:

[10, 2, 5].sort((a, b) => a - b) // [2, 5, 10]
[10, 2, 5].sort((a, b) => b - a) // [10, 5, 2]

En el primero, si a vale 10 y b vale 2, 10 - 2 da 8. Es positivo: 10 debe ir después de 2. En el segundo, para esa misma pareja, 2 - 10 da -8: 10 debe ir antes de 2.

Sin comparador, sort() compara representaciones de texto: [2, 10].sort() produce [10, 2]. Para números, pasa una resta; no devuelvas solo a > b, porque da un booleano y no distingue los tres resultados.

Cero significa empate en la propiedad elegida

Superman y Batman tienen creator: Creator.DC. Al compararlos por editorial, a.creator - b.creator da 0 - 0, es decir, 0.

Eso no los convierte en el mismo héroe: solo empatan en editorial. Si Superman aparecía antes que Batman en el array recibido, seguirá antes. Conservar ese orden en los empates se llama ordenación estable (stable sort).

No aparece un desempate alfabético automáticamente. Si lo necesitas, debes escribirlo. Esta es una variante didáctica, no el comparador actual:

value.sort((a, b) => a.creator - b.creator || a.name.localeCompare(b.name))

Si la resta es distinta de cero, se usa ese resultado. Si da cero, || evalúa la comparación de nombres. Así ordenas primero por editorial y, dentro de cada editorial, por nombre.

Nombres: localeCompare

En a.name.localeCompare(b.name), el primer nombre se compara con el segundo siguiendo reglas de ordenación de texto de un idioma (locale), como el tratamiento de acentos y mayúsculas. Devuelve un número negativo, cero o positivo: justo el contrato que necesita sort.

'Batman'.localeCompare('Superman') < 0 // true
'Superman'.localeCompare('Batman') > 0 // true
'Batman'.localeCompare('Batman') === 0 // true

No compruebes si devuelve exactamente -1 o 1: lo que importa es el signo. Con a.name.localeCompare(b.name), Batman va antes que Superman. Intercambiar los nombres, b.name.localeCompare(a.name), invierte el sentido.

El código actual no pasa un idioma, así que usa el predeterminado del entorno de JavaScript. El LOCALE_ID de Angular no configura este método. Si quieres elegirlo expresamente, esta es una variante, no el código actual:

value.sort((a, b) => a.name.localeCompare(b.name, 'es-AR'))

Booleanos y enums: convertir o restar

Cada botón usa estos criterios del archivo actual:

Propiedad Comparador Resultado
name a.name.localeCompare(b.name) Orden de nombres según el idioma del entorno
canFly Number(b.canFly) - Number(a.canFly) Primero true, después false
color a.color - b.color red, black, blue, green
creator a.creator - b.creator DC antes que Marvel

Number(true) es 1 y Number(false) es 0. La resta b - a ordena de mayor a menor: si a vuela y b no, 0 - 1 da -1, así que a va primero. Si los dos vuelan o ninguno vuela, da cero. Con a - b irían primero quienes no vuelan.

Color y Creator ya son enums numéricos, explicados en el Módulo PP11. Color.red vale 0, black vale 1, blue vale 2 y green vale 3. Creator.DC vale 0 y Marvel vale 1. La resta a - b los ordena de menor a mayor. No ordena sus nombres alfabéticamente: sigue los números del enum.

Cómo funciona por dentro

sort() cambia el mismo array que recibe y devuelve esa misma referencia. Una referencia es el acceso a un objeto concreto en memoria: dos variables pueden apuntar al mismo array y ver ambas el cambio.

Ejemplo independiente:

const numbers = [10, 2, 5]
const sortedNumbers = numbers.sort((a, b) => a - b)

numbers // [2, 5, 10]
sortedNumbers === numbers // true

Por eso el pipe no ordena el array que recibe: primero hace una copia con const sorted = [...value] y ordena esa copia. [...value] crea un array nuevo con los mismos elementos; sort modifica la copia y el array de partida conserva su orden. El Módulo PP13 explica qué pasaba en la tabla sin esa copia.

const original = [10, 2, 5]
const sorted = [...original].sort((a, b) => a - b)

original // [10, 2, 5]
sorted // [2, 5, 10]

toSorted devuelve directamente otro array ordenado, pero necesita la biblioteca de tipos de ES2023 y este proyecto compila con ES2022; por eso se usa la copia.

Con héroes es una copia superficial (shallow copy): cambia el contenedor, pero los objetos de dentro siguen compartidos. Ordenar la copia no reordena el original; cambiar sorted[0].name sí modifica ese héroe compartido.

Errores comunes

  • Confundir el signo con ascendente o descendente. El signo habla de a respecto de b; el sentido lo establece la fórmula completa.
  • Interpretar cero como igualdad de objetos. Solo significa empate según el comparador, y no añade otro criterio por sí solo.
  • Esperar orden alfabético al restar enums. La resta compara sus números.
  • Creer que sort devuelve una copia. Cambia el array que recibe. Por eso el pipe copia primero con [...value].
  • Suponer que keyof Hero implementa todos los casos. Acepta 'id', pero el switch actual no tiene un case 'id'.

Resumen

El switch elige una regla por propiedad. El comparador devuelve un número: negativo coloca a antes, positivo después y cero conserva el orden relativo del empate. localeCompare compara texto, la conversión con Number permite ordenar booleanos y una resta compara enums numéricos. sort modifica su array, así que el pipe ordena una copia hecha con [...value].

Preguntas de entrevista

1. ¿Un comparador negativo ordena de forma descendente?

Respuesta:

No. Solo indica que a debe ir antes que b. Tanto a - b como b - a pueden devolver negativos; la primera fórmula produce orden ascendente y la segunda descendente para estos números.

2. ¿Qué significa devolver cero y cómo añades un desempate?

Respuesta:

Significa que los elementos empatan según ese criterio y conservan su orden relativo en el array recibido. Para desempatar por nombre, compara los nombres cuando el primer resultado sea cero, como en a.creator - b.creator || a.name.localeCompare(b.name).

3. ¿Qué garantiza localeCompare y cómo eliges el idioma?

Respuesta:

Devuelve un número cuyo signo expresa el orden entre dos textos según reglas de idioma; no garantiza valores exactos de -1 y 1. Puedes pasar 'es-AR' como segundo argumento. LOCALE_ID no elige ese idioma por ti.

4. ¿Una copia con spread evita todas las modificaciones compartidas?

Respuesta:

No. Evita compartir el contenedor del array, pero sigue compartiendo los objetos que contiene. Reordenar la copia no cambia el orden del original; editar una propiedad de un héroe compartido sí afecta al mismo objeto.

5. ¿Por qué estos case no llevan break?

Respuesta:

Porque el return ya sale de todo el método. break solo saldría del switch. Si no hay ninguno de los dos, la ejecución continúa por el siguiente caso aunque su etiqueta no coincida.

Para ampliar: operador keyof de TypeScript.

Módulo PP13: Filtrar héroes y limpiar la búsqueda

El problema que resuelve

Al escribir «man» quieres ver solo los héroes cuyo nombre contiene ese texto. Después quieres ordenarlos y poder vaciar el campo con Reset. Son tres tareas: seleccionar elementos del array, ordenar el resultado y mantener el campo y el estado del componente de acuerdo.

Los ejemplos están en hero-filter.pipe.ts y custom-page.component. El reset quita el criterio y el texto de búsqueda.

El código

El método de src/app/pipes/hero-filter.pipe.ts recibe la lista y el texto:

transform(value: Hero[], filterBy: string): Hero[] {
  if (!value) return []
  // Receives an array of heroes and a filter string, returns heroes whose names include the filter string
  return value.filter((hero) => hero.name.toLowerCase().includes(filterBy.toLowerCase()))
}

filter recorre la lista y conserva cada elemento cuyo callback devuelve true. Devuelve otro array, sin quitar elementos del original. Como en una copia superficial, los héroes de dentro siguen siendo los mismos objetos.

toLowerCase() convierte el texto a minúsculas. Se aplica a ambos lados para que buscar 'MAN' y 'man' dé el mismo resultado. includes() responde si un texto contiene otro. No elimina acentos ni espacios: buscar ' man ' también busca esos espacios.

Ejemplo independiente con nombres del proyecto:

const names = ['Superman', 'Batman', 'Thor']
const matches = names.filter((name) => name.toLowerCase().includes('MAN'.toLowerCase()))

matches // ['Superman', 'Batman']
names // ['Superman', 'Batman', 'Thor']

Todo texto contiene '', así que un filtro vacío devuelve todos los héroes, en un array nuevo cuando se ejecuta transform. Si Angular reutiliza el resultado de un pipe puro, no ejecuta de nuevo filter en cada renderizado.

De la plantilla al componente

La página guarda el texto y el criterio en señales, y los actualiza con estos métodos actuales:

heroes = signal<Hero[]>(heroes)
sortBy = signal<keyof Hero | null>(null)
searchQuery = signal<string>('')

handleSort(sortBy: keyof Hero | null) {
  this.sortBy.set(sortBy)
}

handleSearch(query: string) {
  this.searchQuery.set(query)
}

El input y el botón Reset de custom-page.component.html son:

<input
  type="text"
  class="input input-bordered mr-4"
  placeholder="Search by hero name..."
  (input)="handleSearch(searchInput.value)"
  #searchInput
/>
<button class="btn btn-neutral" (click)="reset(searchInput)">Reset</button>

#searchInput crea una referencia de plantilla (template reference variable). En este input nativo representa el elemento del navegador, un HTMLInputElement; no es el texto escrito ni una signal.

Al escribir, el evento input llama a handleSearch con searchInput.value, que sí es un string. Al pulsar Reset, pasas searchInput completo porque el método necesita acceder al campo para vaciarlo.

Filtrar antes de ordenar

La expresión actual del @for es:

@for (
  hero of heroes() | heroFilter: searchQuery() | heroSortBy: sortBy();
  track hero.id;
  let i = $index
) {

Es la apertura del bloque; el cuerpo con las filas se explicó en el Módulo PP11. Los pipes se aplican de izquierda a derecha:

heroes() -> heroFilter(searchQuery()) -> heroSortBy(sortBy()) -> filas

Primero filter produce la lista de coincidencias. Después heroSortBy ordena una copia de esa lista. Cuando cambia el criterio, el argumento sortBy() cambia y Angular vuelve a ejecutar el pipe de ordenación aunque la búsqueda siga igual.

Aquí importa que heroSortBy copie antes de ordenar. Los pipes son puros por defecto: Angular guarda el último resultado de heroFilter y lo reutiliza mientras reciba el mismo array y el mismo texto. Si heroSortBy ordenara ese array en el lugar, estaría reordenando el resultado guardado del filtro. Al pulsar Reset con la búsqueda vacía, el filtro no se volvería a ejecutar y heroSortBy, al recibir null, devolvería la lista ya ordenada: la tabla no volvería a su orden inicial. Con la copia, el array guardado por el filtro nunca cambia.

Limpiar el campo y el estado

El reset actual del componente recibe el elemento:

reset(searchInput: HTMLInputElement) {
  this.sortBy.set(null)
  this.searchQuery.set('')
  searchInput.value = ''
}
  • sortBy.set(null) quita el criterio de ordenación.
  • searchQuery.set('') quita el filtro de los datos.
  • searchInput.value = '' borra el texto visible. Asignar .value desde TypeScript no dispara por sí solo el evento input, por eso también se actualiza la signal.

No hace falta tocar la lista de héroes: como heroSortBy nunca modificó el array del filtro, al quitar el criterio la tabla vuelve a su orden de partida.

También puedes evitar pasar el elemento: como alternativa al enfoque actual, añade [value]="searchQuery()" al input y deja que la signal controle su texto. En ese caso, vaciarla desde reset() actualiza el campo y ya no hace falta asignar searchInput.value.

Agrupar los imports con index.ts

El nuevo src/app/pipes/index.ts importa y exporta los pipes de esa carpeta. Este archivo de entrada común se suele llamar barrel. El componente los importa así:

import {
  ColorMapPipe,
  CreatorMapPipe,
  HeroFilterPipe,
  HeroSortByPipe,
  TimeZonePipe,
  ToggleCasePipe,
  YesNoPipe,
} from '../../pipes'

Eso organiza los imports de TypeScript. No registra los pipes en Angular: siguen apareciendo en imports: [...] del componente standalone.

Cómo funciona por dentro

El input actualiza searchQuery, el filtro selecciona héroes y el ordenador aplica el criterio elegido. Angular vuelve a ejecutar cada pipe puro cuando cambia alguno de sus argumentos: un valor primitivo se compara por su valor y un array por su referencia. track hero.id identifica las filas aunque cambie su posición; no decide cuándo se ejecutan los pipes.

Ordenar no cambia los campos de los héroes. Filtrar tampoco crea héroes nuevos: crea otro array que contiene referencias a los que coinciden.

Errores comunes

  • Pasar searchInput.value a un reset que espera HTMLInputElement. Estarías pasando un texto donde se necesita el elemento.
  • Vaciar solo el input. La búsqueda guardada en la signal seguiría activa.
  • Vaciar solo la signal con el input actual. Sin un enlace [value], el texto visible no se limpia automáticamente.
  • Ordenar en el lugar dentro de un pipe puro. Reordena el resultado que Angular tiene guardado del pipe anterior, y Reset deja la tabla ordenada.
  • Esperar que la búsqueda ignore acentos o recorte espacios. El código actual solo convierte a minúsculas y usa includes.

Resumen

filter devuelve otro array con las coincidencias. La cadena actual filtra primero y ordena después para trabajar sobre ese resultado. #searchInput permite pasar el elemento al reset y vaciar su .value, además de limpiar la signal. Como heroSortBy ordena una copia, al quitar el criterio el filtro entrega la lista con su orden de partida.

Preguntas de entrevista

1. ¿Por qué heroSortBy ordena una copia y no el array que recibe?

Respuesta:

Porque ese array es el último resultado de heroFilter, que Angular guarda y reutiliza mientras no cambien sus entradas. Ordenarlo en el lugar modificaría el resultado guardado, y el filtro entregaría una lista ya reordenada incluso después de quitar el criterio. Con [...value] el pipe ordena otro array y el guardado queda intacto.

2. ¿filter con una búsqueda vacía devuelve el mismo array?

Respuesta:

No. Cuando se ejecuta, devuelve otro array con todos los elementos porque todos los nombres contienen ''. Los objetos de dentro siguen compartidos. Angular sí puede reutilizar ese resultado si no cambian las entradas del pipe.

3. ¿Qué diferencia hay entre searchInput y searchInput.value?

Respuesta:

searchInput es el elemento del navegador; .value es su texto. Pasa el texto para buscar y el elemento para que un método pueda manipular el campo, como vaciarlo con .value = ''.

4. ¿Qué hace falta para que Reset deje la tabla como al principio?

Respuesta:

Tres cosas: poner sortBy en null, vaciar searchQuery y borrar el texto visible con searchInput.value = ''. El orden vuelve solo, porque heroSortBy nunca tocó el array del filtro; no hace falta reasignar la lista de héroes.

5. ¿Un archivo index.ts sustituye los imports de Angular?

Respuesta:

No. Solo concentra las exportaciones de TypeScript en una ruta. Cada pipe que usa una plantilla standalone debe seguir en imports: [...] del componente.

Para ampliar: pipes y detección de cambios y referencias de plantilla.