Módulo 02: TypeScript para Angular

Objetivos del módulo

  • Entender qué aporta TypeScript, qué ocurre al compilar y por qué los tipos desaparecen en ejecución
  • Preparar un proyecto de práctica con la misma configuración estricta que usa Angular
  • Usar tipos básicos, inferencia, tuplas, tipos literales, any, unknown, never y void
  • Modelar datos con interface y type, y razonar con tipado estructural
  • Combinar tipos con uniones e intersecciones, y estrecharlos con typeof, in, instanceof y predicados
  • Modelar estados con uniones discriminadas y comprobar que no falta ningún caso
  • Escribir funciones, interfaces y clases genéricas con restricciones
  • Transformar tipos con Partial, Pick, Omit, Record, keyof, tipos mapeados y plantillas de texto
  • Escribir clases con readonly, private, protected, #privado, abstract y override, y conocer el orden de inicialización
  • Saber qué es un decorador, escribir uno y entender qué hace Angular con @Component
  • Organizar el código en módulos ES, con import type y alias de rutas
  • Leer el tsconfig.json de Angular y saber qué comprueba cada opción de strict
  • Tipar datos asíncronos y validar lo que llega de una API
  • Leer las declaraciones .d.ts de Angular y los mensajes de error del compilador

📘 Concepto: Este módulo no repite JavaScript. Da por sabido lo que se enseña en el Curso de JavaScript: let y const, funciones flecha, desestructuración, clases, promesas y módulos. Aquí solo se explica lo que TypeScript añade encima, y se elige con un criterio: todo lo que verás lo vas a necesitar para leer y escribir Angular.


1. Qué es TypeScript y por qué Angular lo exige

JavaScript comprueba muy pocas cosas antes de ejecutar. Este código no da ningún aviso al escribirlo, y falla mucho después, en el navegador de un usuario:

const empleado = { nombre: 'Ana', salario: 6500 };

function calcularPaga(e) {          // ❌ TS7006 En JavaScript, nadie sabe qué es "e"
  return e.salario * 14;
}

console.log(calcularPaga(empleado));
console.log(calcularPaga({ nombre: 'Luis', sueldo: 2100 }));
91000
NaN

La segunda línea no lanza ningún error. Calcula undefined * 14, que es NaN, y ese NaN seguirá viajando por la aplicación hasta aparecer en una pantalla, lejos del sitio donde se equivocó alguien. TypeScript añade a JavaScript una descripción de la forma de los datos, y un compilador que comprueba esa descripción antes de ejecutar nada:

interface Empleado {
  nombre: string;
  salario: number;
}

function calcularPaga(e: Empleado): number {
  return e.salario * 14;
}

console.log(calcularPaga({ nombre: 'Ana', salario: 6500 }));
console.log(calcularPaga({ nombre: 'Luis', sueldo: 2100 })); // ❌ TS2353 'sueldo' no existe en Empleado

El error aparece ahora en el editor, subrayado en la línea exacta, antes de abrir el navegador.

📘 Concepto: TypeScript es un superconjunto de JavaScript: todo JavaScript válido tiene la misma sintaxis en TypeScript, y lo que se añade son anotaciones de tipo y unas pocas construcciones propias. El compilador hace dos trabajos independientes: comprueba los tipos y emite JavaScript quitándolos.

1.1 Compilación y ejecución son dos momentos distintos

La idea más importante del módulo es esta: los tipos solo existen mientras se compila. Al generar el JavaScript, el compilador borra las anotaciones. El navegador nunca ve una interface. A esto se le llama borrado de tipos (type erasure).

A la izquierda, un fichero TypeScript con interfaz y anotaciones; el compilador comprueba los tipos y los elimina; a la derecha, el JavaScript resultante, sin interfaz ni anotaciones, que es lo que ejecuta el navegador

Figura 1. El compilador comprueba y luego borra. Lo que llega al navegador no conserva ningún tipo.

De ahí salen tres consecuencias que explican muchas dudas posteriores:

  • Un error de tipos no impide generar código por sí mismo. tsc emite JavaScript aunque haya errores, salvo que lo configures de otro modo. Angular sí detiene su build si hay errores.
  • No puedes preguntar por un tipo en ejecución. typeof x === 'Empleado' no significa nada, porque en ejecución Empleado no existe.
  • Los tipos no protegen de lo que entra de fuera. Si una API devuelve algo distinto de lo que declaras, TypeScript no se entera. Lo tratamos en la sección 11.

1.2 Por qué TypeScript es obligatorio en Angular

En otros frameworks TypeScript es opcional. En Angular no, y no por capricho:

Pieza de Angular Qué necesita de TypeScript
Plantillas El compilador de Angular convierte cada plantilla en código comprobado contra la clase del componente. Si escribes {{ empleado.sueldo }} y no existe, no compila
Decoradores @Component, @Directive y @Pipe usan una sintaxis que JavaScript estándar todavía no tiene en esta forma (sección 8)
Inyección de dependencias Las clases funcionan como claves del inyector, como verás en el módulo 08
Señales y resources signal<number>(0), input<string>() y httpResource<Empleado[]>(...) son funciones genéricas

El compilador de Angular, llamado ngtsc, se construye sobre el de TypeScript y lo amplía. Por eso cada versión de Angular admite solo un rango concreto de versiones de TypeScript.

1.3 Qué versión de TypeScript usa cada Angular 🔄 v22.0

🔄 Cambia en Angular 22.0. Angular 22 exige TypeScript 6.0, y la LTS (Angular 21) usa TypeScript 5.9. Si usas la LTS: instala typescript@~5.9, que es lo que deja ng new en ese proyecto. Todo lo que enseña este módulo compila igual en 5.9, salvo lo marcado expresamente, porque TypeScript 6.0 cambia sobre todo valores por defecto y no la sintaxis.

  Angular 22.2 Angular 21.2 (LTS)
TypeScript admitido >=6.0 <6.1 >=5.9 <6.0
Lo que instala ng new ~6.0.2 ~5.9
strict Activo por defecto en TypeScript 6 Hay que escribirlo, y ng new lo hace

⚠️ Importante: TypeScript 7, la versión reescrita en Go, no está soportado por Angular 22. No actualices typescript a mano a una versión mayor de la que pide tu Angular: la orden ng update ya se encarga de elegir la correcta, como viste en el módulo 01.

1.4 Preparar una carpeta de práctica

Para este módulo no hace falta ningún proyecto Angular hasta la sección 13. Crea una carpeta con TypeScript y una configuración que copie las comprobaciones de Angular:

mkdir practica-ts
cd practica-ts
npm init -y
npm pkg set type=module
npm install --save-dev typescript@~6.0.2

Crea este tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "preserve",
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "isolatedModules": true,
    "experimentalDecorators": true,
    "moduleDetection": "force",
    "skipLibCheck": true,
    "types": [],
    "rewriteRelativeImportExtensions": true,
    "outDir": "salida"
  },
  "include": ["*.ts"]
}

Casi todas estas líneas proceden del tsconfig.json que genera ng new. Las explicamos en la sección 10. Hay tres añadidas para esta práctica: moduleDetection: "force" hace que cada fichero sea un módulo independiente, de modo que dos ejemplos puedan declarar un Empleado sin chocar; rewriteRelativeImportExtensions permite importar ./utilidades.ts y cambia esa ruta por ./utilidades.js en la salida; y outDir deja el JavaScript generado en salida/.

Con eso, cada ejemplo se guarda en un fichero .ts y se comprueba así:

npx tsc

Si no hay errores, tsc no escribe nada en la consola. Si los hay, los muestra con fichero, línea y código.

💡 Consejo: npx tsc --locale es traduce los mensajes al español. Son útiles al principio, aunque conviene acostumbrarse a los ingleses, porque son los que encontrarás al buscar ayuda. En este curso los mostramos en inglés. Con npx tsc --watch el compilador se queda vigilando los ficheros.

1.5 Ejecutar los ejemplos: Node y sus límites

Node 24 ejecuta TypeScript directamente:

node saludo.ts
const nombre: string = 'Ana';
console.log(`Hola, ${nombre}`);
Hola, Ana

Pero Node no comprueba tipos. Solo los quita, sustituyendo las anotaciones por espacios en blanco. Ejecutará tan tranquilo un programa lleno de errores de tipos:

const edad: number = 'treinta'; // ❌ TS2322 tsc lo rechaza, Node ni lo mira
console.log(edad);
treinta

Además, ese modo solo sabe borrar. Las construcciones que obligan a generar código nuevo no funcionan en node archivo.ts, porque no bastaría con quitar texto:

Construcción node archivo.ts npx tsc y node salida/archivo.js
Anotaciones, interfaces, genéricos Funciona Funciona
enum ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX Funciona
constructor(private x: number) ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX Funciona
Decoradores @Algo SyntaxError Funciona con experimentalDecorators
import { algo } from './otro', sin extensión No encuentra el módulo Tampoco lo encuentra
import { algo } from './otro.ts' Funciona Funciona con rewriteRelativeImportExtensions
import type { Algo } from './otro' Funciona: se borra Funciona: se borra

Por eso, en los ejemplos con decoradores, enum o propiedades de parámetro verás esta orden:

npx tsc; node salida/ejemplo.js

Y por eso en las prácticas de este módulo verás ./utilidades.ts cuando se importa código real, mientras que un import type no lleva extensión. Angular no tiene ninguno de estos límites, porque su compilador genera y agrupa el JavaScript completo: en un proyecto Angular se escribe ./utilidades, sin extensión.


2. Tipos básicos

2.1 Primitivos y anotaciones

Una anotación se escribe con dos puntos después del nombre:

const nombre: string = 'Ana';
const salario: number = 6500;
const activo: boolean = true;
const comision: null = null;
let telefono: string | undefined;     // sin valor todavía
const presupuestoTotal: bigint = 12_000_000_000n;
const clave: symbol = Symbol('clave');

console.log(nombre, salario, activo, comision, telefono, presupuestoTotal, clave.description);
Ana 6500 true null undefined 12000000000n clave

No hay distinción entre enteros y decimales: como en JavaScript, todo es number. Y los tipos se escriben en minúscula: string, no String. String con mayúscula es el objeto envoltorio de JavaScript, que no debes usar como tipo.

2.2 Inferencia: anota lo menos posible

No hace falta anotar todo. Si TypeScript puede deducir el tipo, lo hace. Esto se llama inferencia:

let contador = 0;              // number
const nombre = 'Ana';          // 'Ana': un const no cambia, así que el tipo es el valor exacto
const empleados = ['Ana', 'Luis'];  // string[]
const doble = (n: number) => n * 2; // devuelve number, sin decirlo

contador = 'uno'; // ❌ TS2322 contador es number desde su inicialización
console.log(nombre, empleados, doble(contador));

El criterio que se sigue en proyectos reales es sencillo:

Situación ¿Anotar? Por qué
Variable local con valor inicial No Se infiere bien
Parámetros de función Sí, siempre No hay nada de lo que inferir
Retorno de una función pública Recomendable Fija el contrato: un error dentro no cambia el tipo en silencio
Variable sin valor inicial Sí Sin valor, no se puede inferir
Array vacío Sí [] no dice qué contendrá
Propiedades de clase con valor No Se infiere bien

En Angular esto se nota al crear señales: signal(0) se infiere como señal de number, pero una lista vacía necesita ayuda: signal<Empleado[]>([]).

💡 Consejo: En VS Code, pasa el ratón por encima de cualquier variable para ver el tipo inferido. Es la forma más rápida de aprender cómo razona el compilador.

2.3 Arrays y tuplas

Un array se escribe de dos formas equivalentes. La primera es la habitual:

const nombres: string[] = ['Ana', 'Luis'];
const salarios: Array<number> = [6500, 2100];

const soloLectura: readonly string[] = ['Madrid', 'Valencia'];
soloLectura.push('Sevilla'); // ❌ TS2339 un array readonly no tiene push

// Los métodos conservan los tipos: "altos" es number[]
const altos = salarios.filter((s) => s > 3000);

// find puede no encontrar nada: el tipo incluye undefined
const encontrado = nombres.find((n) => n === 'Eva');
console.log(encontrado.length); // ❌ TS18048 encontrado puede ser undefined

console.log(altos);

Una tupla es un array de longitud fija en el que cada posición tiene su tipo:

const fila: [number, string] = [100, 'Ana'];
const [id, nombre] = fila;       // id: number, nombre: string

console.log(fila[2]); // ❌ TS2493 la tupla solo tiene dos posiciones

// Tuplas con nombre: documentan qué es cada posición
type Rango = [desde: number, hasta: number];
const rango: Rango = [1, 10];

console.log(id, nombre, rango);

Se usan poco en datos, pero mucho como retorno de funciones que devuelven varias cosas sin nombre, como Object.entries, que devuelve [string, T][].

2.4 Tipos literales y as const

Un valor concreto también es un tipo. 'ADMIN' es un tipo que solo admite esa cadena. Combinado con la barra vertical, que veremos a fondo en la sección 4, permite listas cerradas de valores:

type Rol = 'ADMIN' | 'USER';
type TamanoPagina = 10 | 25 | 50;

let rol: Rol = 'ADMIN';
rol = 'INVITADO'; // ❌ TS2322 no es uno de los valores permitidos

const tamano: TamanoPagina = 25;
console.log(rol, tamano);

Hay una trampa habitual. Con let, o dentro de un objeto, TypeScript ensancha el literal a string, porque el valor podría cambiar:

type Rol = 'ADMIN' | 'USER';

function iniciarSesion(rol: Rol) {
  return `Sesión con rol ${rol}`;
}

const datos = { rol: 'ADMIN' };          // datos.rol es string, no 'ADMIN'
iniciarSesion(datos.rol); // ❌ TS2345 string no es asignable a Rol

// Con as const, todo se vuelve literal y de solo lectura
const fijos = { rol: 'ADMIN' } as const; // fijos.rol es 'ADMIN'
console.log(iniciarSesion(fijos.rol));

// Muy útil para listas de valores: el tipo se deriva de un array real
const ESTADOS = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'] as const;
type EstadoPedido = (typeof ESTADOS)[number];
const estado: EstadoPedido = 'ENVIADO';
console.log(ESTADOS.includes(estado));
Sesión con rol ADMIN
true

Fíjate en la línea de EstadoPedido. typeof ESTADOS toma el tipo del array, y [number] pregunta por el tipo de sus elementos. La ventaja de esta forma es que el array existe en ejecución, y sirve para rellenar un <select> o para validar, mientras que el tipo se mantiene sincronizado solo.

2.5 enum: conviene conocerlo, no hace falta preferirlo

TypeScript tiene una construcción propia para listas cerradas:

enum Prioridad {
  Baja,        // 0
  Media,       // 1
  Alta,        // 2
}

enum Canal {
  Correo = 'CORREO',
  Sms = 'SMS',
}

console.log(Prioridad.Alta, Prioridad[2], Canal.Sms);
npx tsc; node salida/enums.js
2 Alta SMS

A diferencia de una interfaz, un enum sí genera código: un objeto real en ejecución. Por eso node archivo.ts lo rechaza. Angular usa alguno en su API, como ChangeDetectionStrategy, y lo verás en el módulo 09. Pero su API moderna prefiere uniones de literales: el estado de un resource es 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local', un tipo y nada más.

  Unión de literales enum
Código en ejecución Ninguno Un objeto
Valor en JSON La propia cadena, encaja con la API Numérico por defecto, conviene dar cadenas
Comparar con lo que llega de un servidor Directo Directo solo en enum de cadenas, y requiere conversión de tipo
Listar los valores Con un array as const Con Object.values

En este curso usaremos uniones de literales.

2.6 any, unknown, never y void

Estos cuatro son tipos especiales y conviene distinguirlos bien. Piensa en los tipos como conjuntos de valores: string es el conjunto de todas las cadenas, y 'ADMIN' un subconjunto. Un valor se puede asignar a un tipo si pertenece a su conjunto.

Jerarquía de tipos: unknown arriba contiene a todos; debajo object, string, number y boolean; debajo los literales; abajo del todo never, el conjunto vacío; any aparte, que apaga la comprobación

Figura 2. unknown es el conjunto de todos los valores y never el conjunto vacío. any queda fuera del esquema: desactiva la comprobación.

Tipo Qué significa Puedes asignarle Puedes usarlo sin comprobar
unknown “No sé qué es” Cualquier cosa No
any “No compruebes nada” Cualquier cosa Sí, y ese es el peligro
never “Esto no puede ocurrir” Nada Es asignable a todo, pero nunca hay un valor
void “No devuelve nada útil” undefined No

any y unknown admiten cualquier valor, pero se comportan al revés cuando quieres usarlo:

const peligroso: any = JSON.parse('{"salario": 2100}');
console.log(peligroso.sueldo.toFixed(2));   // Compila, y en ejecución revienta

const seguro: unknown = JSON.parse('{"salario": 2100}');
console.log(seguro.salario); // ❌ TS18046 unknown obliga a comprobar antes

if (typeof seguro === 'object' && seguro !== null && 'salario' in seguro) {
  console.log(seguro.salario);              // Ahora sí
}

⚠️ Importante: any es contagioso. Si una función devuelve any, todo lo que hagas con ese valor pierde la comprobación, y el error reaparece lejos. JSON.parse devuelve any, igual que response.json(). Cuando no sepas qué tipo poner, escribe unknown: te obliga a comprobar, y es la opción honesta.

never aparece cuando algo es imposible. Lo devuelve una función que nunca termina con normalidad, porque siempre lanza un error o entra en un bucle infinito, y es el tipo de una rama que ya no tiene casos posibles. Lo aprovecharemos en la sección 4.5.

function fallar(mensaje: string): never {
  throw new Error(mensaje);
}

function dividir(a: number, b: number): number {
  if (b === 0) {
    fallar('División por cero');  // TypeScript sabe que de aquí no se pasa
  }
  return a / b;
}

function avisar(texto: string): void {
  console.log(texto);
}

avisar(`Resultado: ${dividir(10, 4)}`);
Resultado: 2.5

2.7 Funciones

Se anotan los parámetros y el retorno. Hay parámetros opcionales, con valor por defecto y restantes:

function nombreCompleto(nombre: string, apellidos?: string): string {
  // apellidos es string | undefined
  return apellidos ? `${apellidos}, ${nombre}` : nombre;
}

function paginar(pagina: number, tamano = 10): string {
  // tamano se infiere como number gracias al valor por defecto
  return `pagina=${pagina}&tamano=${tamano}`;
}

function sumar(...importes: number[]): number {
  return importes.reduce((total, i) => total + i, 0);
}

console.log(nombreCompleto('Ana'));
console.log(nombreCompleto('Ana', 'García López'));
console.log(paginar(2));
console.log(sumar(0.1, 0.2));
Ana
García López, Ana
pagina=2&tamano=10
0.30000000000000004

El último resultado no es un error de TypeScript, sino la aritmética decimal de JavaScript. Para mostrar importes se usa toFixed o el pipe currency del módulo 07.

Las funciones son valores y también tienen tipo. Eso permite describir callbacks:

type Filtro = (salario: number) => boolean;

const esAlto: Filtro = (s) => s > 3000;   // s se infiere de Filtro

function contar(salarios: number[], filtro: Filtro): number {
  return salarios.filter(filtro).length;
}

console.log(contar([6500, 2100, 3200], esAlto));
2

Las sobrecargas declaran varias firmas para una sola función. No las escribirás a menudo, pero HttpClient.get tiene muchas, una por cada combinación de opciones, y las verás al leer sus declaraciones:

function aTexto(valor: number): string;
function aTexto(valor: Date): string;
function aTexto(valor: number | Date): string {
  return typeof valor === 'number' ? valor.toFixed(2) : valor.toISOString().slice(0, 10);
}

console.log(aTexto(2.5), aTexto(new Date('2025-01-15')));
aTexto('hola'); // ❌ TS2769 ninguna sobrecarga admite string

3. Objetos: interface y type

3.1 Interfaces

Una interfaz describe la forma de un objeto: qué propiedades tiene y de qué tipo son.

interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number;
}

const direccion: Departamento = {
  deptId: 10,
  nombre: 'DIRECCIÓN',
  localidad: 'Madrid',
  presupuesto: 500000,
};

const incompleto: Departamento = { // ❌ TS2741 falta presupuesto
  deptId: 20,
  nombre: 'VENTAS',
  localidad: 'Valencia',
};

console.log(direccion.nombre, incompleto.nombre);

3.2 Tipado estructural

Si vienes de Java, esto te sorprenderá. En Java, una clase cumple una interfaz si lo declara con implements. En TypeScript importa la forma, no el nombre. Si un objeto tiene las propiedades que se piden, vale. Esto se llama tipado estructural.

interface ConNombre {
  nombre: string;
}

function saludar(alguien: ConNombre): string {
  return `Hola, ${alguien.nombre}`;
}

const cliente = { nombre: 'Talleres Martín S.L.', ciudad: 'Valencia' };
class Empleado {
  nombre = 'Ana';
  salario = 6500;
}

console.log(saludar(cliente));          // Nadie dijo "implements ConNombre"
console.log(saludar(new Empleado()));   // Una clase también encaja
console.log(saludar({ nombre: 'Luis' }));
Hola, Talleres Martín S.L.
Hola, Ana
Hola, Luis

Esto tiene una consecuencia práctica en las pruebas: para sustituir un servicio basta con pasar un objeto con los mismos métodos, sin heredar de nada. Lo aprovecharemos en el módulo 26.

Con los objetos escritos en el sitio hay una comprobación adicional, llamada comprobación de propiedades de más (excess property check). El compilador supone que una propiedad sobrante en un literal es una errata:

interface ConNombre {
  nombre: string;
}

function saludar(alguien: ConNombre): string {
  return `Hola, ${alguien.nombre}`;
}

saludar({ nombre: 'Ana', edad: 30 }); // ❌ TS2353 un literal no puede llevar propiedades de más

const persona = { nombre: 'Ana', edad: 30 };
saludar(persona);   // Compila: ya no es un literal recién escrito

3.3 Opcionales, readonly y firmas de índice

interface Producto {
  readonly productoId: number;        // no se puede reasignar
  nombre: string;
  descripcion?: string;               // puede no estar: string | undefined
  precio: number;
}

const portatil: Producto = { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899 };

portatil.precio = 849;                // Bien
portatil.productoId = 2; // ❌ TS2540 productoId es de solo lectura

console.log(portatil.descripcion.length); // ❌ TS18048 puede no estar
console.log(portatil.descripcion?.length);

⚠️ Importante: readonly solo actúa en compilación y es superficial. No impide cambiar lo que hay dentro de un array u objeto: readonly lineas: Linea[] no deja reasignar lineas, pero sí hacer lineas.push(...). Si quieres proteger también el contenido, usa readonly Linea[]. Y en ejecución no hay ninguna protección: si necesitas una garantía real, está Object.freeze.

Una propiedad opcional (telefono?: string) y una propiedad que puede ser null (telefono: string | null) no son lo mismo:

Declaración {} { telefono: undefined } { telefono: null }
telefono?: string Válido Válido Error
telefono: string \| null Error Error Válido
telefono?: string \| null Válido Válido Válido

Declara lo que hace de verdad tu API. La API EMPRESA envía siempre comision, con null si no hay, así que el tipo correcto es comision: number | null.

Cuando las claves no se conocen de antemano, se usa una firma de índice:

interface ErroresDeCampo {
  [campo: string]: string;
}

const errores: ErroresDeCampo = { email: 'El email no es válido' };

console.log(errores['email']);
console.log(errores.email); // ❌ TS4111 Angular exige corchetes en las firmas de índice
console.log(errores['telefono'].length);  // Compila, pero en ejecución revienta

Dos cosas que observar aquí. La opción noPropertyAccessFromIndexSignature, activa en Angular, obliga a usar corchetes: así se ve de un vistazo que esa clave podría no existir. Y por defecto TypeScript da por hecho que cualquier clave existe, de modo que el tipo de errores['telefono'] es string, aunque no esté. La opción noUncheckedIndexedAccess, de la sección 10, lo evita.

3.4 Herencia y composición de interfaces

interface Persona {
  nombre: string;
  email: string;
}

interface ConFechaAlta {
  fechaAlta: string;
}

interface Empleado extends Persona, ConFechaAlta {
  empId: number;
  salario: number;
}

const ana: Empleado = {
  empId: 100,
  nombre: 'Ana',
  email: 'ana.garcia@empresa.es',
  fechaAlta: '2010-01-15',
  salario: 6500,
};

console.log(ana.nombre, ana.fechaAlta);
Ana 2010-01-15

3.5 Alias de tipo y la diferencia con interface

type da nombre a cualquier tipo, no solo a objetos:

type Id = number;
type Fecha = string;
type Rol = 'ADMIN' | 'USER';
type Coordenada = [number, number];
type Comparador<T> = (a: T, b: T) => number;

type Usuario = {
  id: Id;
  rol: Rol;
  alta: Fecha;
};

const luis: Usuario = { id: 1, rol: 'USER', alta: '2024-01-10' };
const ordenarPorId: Comparador<Usuario> = (a, b) => a.id - b.id;
const punto: Coordenada = [1, 2];

console.log(luis.rol, ordenarPorId(luis, luis), punto);

Para describir objetos, interface y type son casi intercambiables. Las diferencias reales son pocas:

  interface type
Describir un objeto Sí Sí
Uniones, tuplas, primitivos, funciones No Sí
Tipos mapeados y condicionales No Sí
extends Sí Se consigue con &
Fusión de declaraciones Sí, dos interface iguales se unen No, da error de duplicado

Este último punto explica por qué las librerías amplían tipos declarando otra interface con el mismo nombre:

interface Configuracion {
  url: string;
}
interface Configuracion {
  reintentos: number;
}

const config: Configuracion = { url: '/api', reintentos: 3 }; // Tiene ambas

type Otra = { a: number }; // ❌ TS2300 un alias no se puede repetir
type Otra = { b: number }; // ❌ TS2300 un alias no se puede repetir

console.log(config);

La regla que seguimos en el curso: interface para las formas de objetos (los modelos de datos) y type para todo lo demás (uniones y transformaciones).

3.6 Los modelos de EMPRESA

Con lo visto ya podemos describir lo que devuelve la API EMPRESA. Este fichero se reutiliza durante el resto del módulo. Guárdalo como modelos.ts:

// modelos.ts
export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';

export interface Departamento {
  readonly deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number;
}

export interface Empleado {
  readonly empId: number;
  nombre: string;
  apellidos: string;
  email: string;
  telefono: string;
  fechaAlta: string;           // ISO: '2010-01-15'
  oficio: string;
  salario: number;
  comision: number | null;     // siempre viene, con null si no tiene
  jefeId: number | null;       // null en el director general
  deptId: number;
  departamento?: string;       // solo en las lecturas: lo añade la API
}

export interface Producto {
  readonly productoId: number;
  nombre: string;
  descripcion: string;
  categoriaId: number;
  precio: number;
  stock: number;
  fechaAlta: string;
  descatalogado: boolean;
}

export interface LineaPedido {
  readonly pedidoId: number;
  readonly numLinea: number;
  productoId: number;
  cantidad: number;
  precioUnitario: number;
  descuento: number;
  producto: string;
  importe: number;
}

export interface Pedido {
  readonly pedidoId: number;
  clienteId: number;
  empleadoId: number | null;
  fechaPedido: string;
  fechaEnvio: string | null;
  estado: EstadoPedido;
  cliente: string;
  lineas: LineaPedido[];
  total: number;
}

export interface ErrorApi {
  status: number;
  mensaje: string;
  detalles?: { [campo: string]: string };
}

💡 Consejo: Las fechas de la API son string, no Date. Al leer JSON llega una cadena, y un Date en la interfaz sería mentira: el compilador lo aceptaría y empleado.fechaAlta.getFullYear() fallaría en ejecución. El tipo debe describir lo que llega de verdad, no lo que te gustaría que llegase.

📘 Concepto: Observa que readonly se reserva a los identificadores. Declara una intención: un empId no se cambia desde el cliente. Es una buena costumbre en modelos de Angular, porque las señales trabajan mejor con datos que no se modifican en el sitio, como verás en el módulo 05.


4. Uniones, intersecciones y estrechamiento

4.1 Uniones

Una unión se escribe con | y significa “uno de estos tipos”:

function formatearId(id: number | string): string {
  return `#${id}`;
}

console.log(formatearId(100));
console.log(formatearId('A-7'));
console.log(formatearId(true)); // ❌ TS2345 boolean no está en la unión

Con una unión solo puedes usar directamente lo que tienen en común todos sus miembros:

function mayusculas(valor: string | number): string {
  return valor.toUpperCase(); // ❌ TS2339 number no tiene toUpperCase
}

console.log(mayusculas('a'));

Para usar lo específico de un tipo hay que demostrar antes de cuál se trata.

4.2 Estrechamiento

El compilador sigue el flujo del programa. Si una condición descarta un tipo, en esa rama el tipo se reduce. A esto se le llama estrechamiento (narrowing). Las comprobaciones que entiende son JavaScript normal:

class ErrorRed {
  constructor(readonly reintentable: boolean) {}
}

interface Producto {
  precio: number;
}
interface Servicio {
  tarifaHora: number;
}

function describir(valor: string | number | null): string {
  if (valor === null) {
    return 'sin valor';                     // Aquí es null
  }
  if (typeof valor === 'string') {
    return valor.toUpperCase();             // Aquí es string
  }
  return valor.toFixed(2);                  // Solo queda number
}

function coste(item: Producto | Servicio): number {
  if ('precio' in item) {                   // El operador in estrecha objetos
    return item.precio;
  }
  return item.tarifaHora * 8;
}

function mensaje(error: ErrorRed | Error): string {
  if (error instanceof ErrorRed) {          // instanceof estrecha clases
    return error.reintentable ? 'Reintentando' : 'Sin conexión';
  }
  return error.message;
}

console.log(describir(null), describir('ana'), describir(2.5));
console.log(coste({ precio: 899 }), coste({ tarifaHora: 40 }));
console.log(mensaje(new ErrorRed(true)), mensaje(new Error('Fallo')));
npx tsc; node salida/estrechamiento.js
sin valor ANA 2.50
899 320
Reintentando Fallo
Comprobación Sirve para Ejemplo
typeof Primitivos y funciones typeof x === 'string'
=== null, !== undefined Quitar valores vacíos if (x !== null)
Veracidad Quitar null, undefined, y también '' y 0 if (x)
in Objetos con propiedades distintas 'precio' in item
instanceof Instancias de clases error instanceof HttpErrorResponse
Propiedad discriminante Uniones discriminadas estado.tipo === 'listo'
Predicado propio Lo que el compilador no sabe deducir esEmpleado(x)

⚠️ Importante: typeof y instanceof funcionan porque se ejecutan de verdad. No existe x instanceof Empleado si Empleado es una interface, porque en ejecución no existe. El compilador lo rechaza con TS2693.

Una trampa frecuente: la veracidad descarta también 0 y la cadena vacía.

function mostrarComision(comision: number | null): string {
  // Mal: un 0 se trataría como "sin comisión"
  const mal = comision ? `${comision} €` : 'Sin comisión';
  // Bien: solo descarta null
  const bien = comision !== null ? `${comision} €` : 'Sin comisión';
  return `${mal} / ${bien}`;
}

console.log(mostrarComision(0));
Sin comisión / 0 €

4.3 null, undefined y sus operadores

Con strict activo, null y undefined no son valores válidos de cualquier tipo. Quien quiera admitirlos debe decirlo. Es la protección más valiosa de TypeScript: elimina el clásico Cannot read properties of undefined.

JavaScript moderno trae dos operadores para trabajar con estos valores, y TypeScript entiende ambos:

import type { Empleado } from './modelos';

function comisionAnual(e: Empleado): string {
  // ?? solo sustituye null y undefined. Con || un 0 también se sustituiría
  return ((e.comision ?? 0) * 14).toFixed(2);
}

function longitudDepartamento(e: Empleado): number {
  // ?. corta la cadena y devuelve undefined si no hay valor
  return e.departamento?.length ?? 0;
}

const ana: Empleado = {
  empId: 100, nombre: 'Ana', apellidos: 'García López', email: 'ana.garcia@empresa.es',
  telefono: '600100100', fechaAlta: '2010-01-15', oficio: 'DIRECTOR GENERAL',
  salario: 6500, comision: null, jefeId: null, deptId: 10,
};

console.log(comisionAnual(ana), longitudDepartamento(ana));
0.00 0

El operador de aserción no nula ! le dice al compilador “te aseguro que aquí no hay null”. No hace ninguna comprobación, y desaparece al compilar:

const empleados = [{ id: 100, nombre: 'Ana' }];

const encontrado = empleados.find((e) => e.id === 999);
console.log(encontrado!.nombre);   // Compila. En ejecución: TypeError

Úsalo solo cuando sepas algo que el compilador no puede saber. En Angular lo verás en lecturas como form.value.nombre!, pero si puedes comprobar, comprueba.

4.4 Uniones discriminadas

Es el patrón más útil de esta sección. Imagina que modelas el estado de una petición con banderas sueltas:

interface EstadoMalo {
  cargando: boolean;
  error: string | null;
  datos: string[] | null;
}

const imposible: EstadoMalo = { cargando: true, error: 'Fallo', datos: ['Ana'] };
console.log(imposible);

¿Está cargando, ha fallado o ya tiene datos? Las tres cosas a la vez, y el compilador no lo impide. Con tres propiedades hay ocho combinaciones y solo tres tienen sentido. Cada if del código tendrá que protegerse de las otras cinco.

Una unión discriminada hace que los estados imposibles no se puedan escribir. Cada caso tiene una propiedad común con un literal distinto, la propiedad discriminante, y solo las propiedades que le corresponden:

// estado-carga-simple.ts
export type EstadoCarga =
  | { tipo: 'inactivo' }
  | { tipo: 'cargando' }
  | { tipo: 'listo'; datos: string[] }
  | { tipo: 'error'; mensaje: string };

export function textoEstado(estado: EstadoCarga): string {
  switch (estado.tipo) {
    case 'inactivo':
      return 'Pulsa cargar';
    case 'cargando':
      return 'Cargando...';
    case 'listo':
      return `${estado.datos.length} elementos`;   // Aquí existe datos
    case 'error':
      return `Error: ${estado.mensaje}`;           // Aquí existe mensaje
  }
}

console.log(textoEstado({ tipo: 'listo', datos: ['Ana', 'Luis'] }));
console.log(textoEstado({ tipo: 'error', mensaje: 'Sin conexión' }));
2 elementos
Error: Sin conexión

Un valor EstadoCarga entra en un switch por la propiedad tipo y se divide en cuatro ramas; en cada una, el compilador conoce solo las propiedades de ese caso

Figura 3. Al comprobar la propiedad discriminante, cada rama del switch ve solo las propiedades de su caso.

Intentar leer lo que no toca es un error de compilación, y un estado imposible no se puede construir:

import type { EstadoCarga } from './estado-carga-simple';

function leer(estado: EstadoCarga): number {
  if (estado.tipo === 'cargando') {
    return estado.datos.length; // ❌ TS2339 en 'cargando' no hay datos
  }
  return 0;
}

const malo: EstadoCarga = { tipo: 'cargando', mensaje: 'x' }; // ❌ TS2353 ese caso no lleva mensaje
console.log(leer(malo));

📘 Concepto: La frase que resume este patrón es “haz que los estados ilegales sean irrepresentables”. En Angular lo usarás para estados de carga, resultados de operaciones y mensajes. La API de resources del módulo 13 resuelve el mismo problema con su propio tipo de estado.

4.5 Comprobar que no falta ningún caso con never

Si mañana añades un quinto estado, ¿cómo te enteras de todos los switch que hay que actualizar? Con never. En la rama default ya no debería quedar ningún caso, así que el valor debe ser asignable a never, que es el conjunto vacío:

type Figura =
  | { tipo: 'circulo'; radio: number }
  | { tipo: 'cuadrado'; lado: number }
  | { tipo: 'triangulo'; base: number; altura: number };

function comprobarExhaustivo(valor: never): never {
  throw new Error(`Caso no tratado: ${JSON.stringify(valor)}`);
}

function area(figura: Figura): number {
  switch (figura.tipo) {
    case 'circulo':
      return Math.PI * figura.radio ** 2;
    case 'cuadrado':
      return figura.lado ** 2;
    default:
      return comprobarExhaustivo(figura); // ❌ TS2345 falta el caso triangulo
  }
}

console.log(area({ tipo: 'cuadrado', lado: 3 }));

El compilador te avisa porque en default todavía queda el tipo { tipo: 'triangulo'; ... }. Al añadir el case, el error desaparece. Esto convierte un olvido silencioso en un error de compilación.

4.6 Intersecciones

Una intersección (&) combina tipos: el resultado tiene todas las propiedades de ambos.

interface ConId {
  id: number;
}
interface ConAuditoria {
  creadoEn: string;
  creadoPor: string;
}

type Departamento = { nombre: string } & ConId & ConAuditoria;

const ventas: Departamento = {
  id: 20,
  nombre: 'VENTAS',
  creadoEn: '2024-01-10',
  creadoPor: 'ana.garcia@empresa.es',
};

console.log(ventas.id, ventas.nombre, ventas.creadoPor);
20 VENTAS ana.garcia@empresa.es

El nombre confunde: A | B amplía el conjunto de valores, y A & B tiene más propiedades pero menos valores posibles, porque solo valen los que cumplen ambos. Angular define Signal<T> con una intersección: una función y un objeto a la vez, como verás en la sección 12.

4.7 Predicados de tipo

El compilador no puede deducir qué hace una función propia. Si escribes una comprobación en una función, el estrechamiento se pierde, salvo que declares un predicado de tipo con valor is Tipo:

import type { EstadoPedido } from './modelos';

const ESTADOS: readonly string[] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];

function esEstadoPedido(valor: string): valor is EstadoPedido {
  return ESTADOS.includes(valor);
}

function etiqueta(estado: EstadoPedido): string {
  return estado === 'ENTREGADO' ? 'Completado' : 'En curso';
}

const leido = 'ENTREGADO' as string;  // Imagina que viene de la URL
if (esEstadoPedido(leido)) {
  console.log(etiqueta(leido));       // Aquí leido es EstadoPedido
}

// Un uso muy común: filter pierde o conserva tipos según el predicado
const comisiones = [100, null, 250, null];
const reales = comisiones.filter((c): c is number => c !== null);
console.log(reales.map((c) => c * 2));
Completado
[ 200, 500 ]

⚠️ Importante: Un predicado es una promesa que haces al compilador, y nadie la verifica. Si esEstadoPedido devolviese siempre true, TypeScript se lo creería.

Las funciones de aserción son parecidas, pero en lugar de devolver un booleano lanzan un error si la condición falla. Después de llamarlas, el tipo queda estrechado:

function afirmarDefinido<T>(valor: T | null | undefined, nombre: string): asserts valor is T {
  if (valor === null || valor === undefined) {
    throw new Error(`Falta ${nombre}`);
  }
}

const token: string | null = 'abc123' as string | null;
afirmarDefinido(token, 'token');
console.log(token.length);
6

4.8 Aserciones de tipo y satisfies

Una aserción de tipo con as le dice al compilador que trate un valor como otro tipo. No convierte nada ni comprueba nada: solo cambia lo que cree el compilador.

import type { Empleado } from './modelos';

const datos: unknown = JSON.parse('{"empId": 100}');
const empleado = datos as Empleado;       // Compila, y es mentira
console.log(empleado.nombre.toUpperCase());

Ese programa no imprime nada: lanza TypeError porque nombre no existe, aunque el tipo diga lo contrario. Solo se permite as entre tipos relacionados. Si no lo están, el compilador protesta, y forzarlo con as unknown as X es una señal de alarma en una revisión de código.

const edad = 'treinta' as number; // ❌ TS2352 string y number no se solapan
console.log(edad);

satisfies resuelve un problema distinto. Si anotas una variable, la compruebas, pero pierdes el tipo preciso. Si no la anotas, conservas la precisión, pero no se comprueba nada. satisfies hace las dos cosas:

type Rol = 'ADMIN' | 'USER';

interface Permisos {
  leer: boolean;
  borrar: boolean;
}

// Anotada: se comprueba, pero "tabla" solo sabe que es un Record
const anotada: Record<Rol, Permisos | 'todo'> = {
  ADMIN: 'todo',
  USER: { leer: true, borrar: false },
};
console.log(anotada.USER.leer); // ❌ TS2339 podría ser 'todo'

// Con satisfies: se comprueba y conserva lo que has escrito
const tabla = {
  ADMIN: 'todo',
  USER: { leer: true, borrar: false },
} satisfies Record<Rol, Permisos | 'todo'>;
console.log(tabla.USER.leer);

const erronea = {
  ADMIN: 'todo',
  INVITADO: 'nada', // ❌ TS2353 INVITADO no es un Rol
} satisfies Partial<Record<Rol, Permisos | 'todo'>>;
console.log(erronea);

En Angular es muy cómodo para definir datos constantes, como la lista de rutas: export const routes = [...] satisfies Routes;.

Forma ¿Comprueba? Tipo resultante Riesgo
const x: T = valor Sí T Pierde precisión
const x = valor satisfies T Sí El del valor Ninguno
const x = valor as T Solo que estén relacionados T Puede mentir
const x = valor as const No Literal y readonly Ninguno

5. Genéricos

5.1 El problema

Quieres una función que devuelva el primer elemento de un array. Sin genéricos solo hay dos opciones, y ambas son malas:

function primeroAny(lista: any[]): any {
  return lista[0];
}

const x = primeroAny(['Ana']);    // x es any: se ha perdido el tipo
console.log(x.noExiste);          // Compila, y no debería

Un genérico es un tipo que actúa como parámetro. Igual que una función recibe valores, un genérico recibe tipos. Se declara entre < >, y por costumbre se usan nombres como T, de type:

function primero<T>(lista: T[]): T | undefined {
  return lista[0];
}

const a = primero(['Ana', 'Luis']);       // T se infiere como string
const b = primero([6500, 2100]);          // T se infiere como number
const c = primero<boolean>([]);           // T explícito: boolean

console.log(a?.toUpperCase(), b?.toFixed(2), c);
ANA 6500.00 undefined

T conecta la entrada con la salida. Esa es la clave: un genérico tiene sentido cuando un tipo aparece al menos dos veces y hay que relacionarlas. Normalmente el compilador lo infiere de los argumentos, y solo hay que escribirlo cuando no hay de dónde inferirlo, como en primero<boolean>([]).

Ahí está la razón de tantos <...> en Angular. Cuando escribes signal<Empleado[]>([]), la lista vacía no aporta información, y por eso se da el tipo a mano.

5.2 Varios parámetros

function parejas<A, B>(izquierda: A[], derecha: B[]): [A, B][] {
  return izquierda.map((valor, i) => [valor, derecha[i] as B]);
}

const resultado = parejas(['Ana', 'Luis'], [6500, 2100]);
console.log(resultado[0]);

function transformar<E, S>(lista: readonly E[], f: (elemento: E) => S): S[] {
  return lista.map(f);
}

const longitudes = transformar(['Ana', 'Luis'], (n) => n.length);
console.log(longitudes);
[ 'Ana', 6500 ]
[ 3, 4 ]

En transformar, TypeScript infiere E del primer argumento y S de lo que devuelve la función. No hace falta escribir ningún tipo en la llamada.

5.3 Interfaces y tipos genéricos

Los listados de la API EMPRESA tienen todos la misma envoltura. Cambia únicamente lo que hay dentro. Es el caso perfecto para una interfaz genérica:

// paginacion.ts
export interface Pagina<T> {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
}

export type EstadoCarga<T> =
  | { tipo: 'inactivo' }
  | { tipo: 'cargando' }
  | { tipo: 'listo'; datos: T }
  | { tipo: 'error'; mensaje: string };
import type { Departamento } from './modelos';
import type { EstadoCarga, Pagina } from './paginacion';

const pagina: Pagina<Departamento> = {
  contenido: [{ deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid', presupuesto: 500000 }],
  pagina: 1,
  tamano: 10,
  total: 7,
  paginas: 1,
};

// contenido[0] es Departamento: el tipo atraviesa la envoltura
console.log(pagina.contenido[0]?.localidad);

const estado: EstadoCarga<Pagina<Departamento>> = { tipo: 'listo', datos: pagina };
if (estado.tipo === 'listo') {
  console.log(estado.datos.total);
}
Madrid
7

5.4 Restricciones con extends

Dentro de una función genérica, T puede ser cualquier cosa, así que no puedes usar nada de él. Una restricción exige una forma mínima:

function mal<T>(elemento: T): number {
  return elemento.length; // ❌ TS2339 T podría no tener length
}

function bien<T extends { length: number }>(elemento: T): number {
  return elemento.length;
}

console.log(mal(1), bien('Ana'), bien([1, 2, 3]));
bien(25); // ❌ TS2345 number no tiene length

Una restricción muy frecuente es keyof, que da la unión de las claves de un tipo y se explica en la sección 6.3. Con ella se escribe una función que recibe el nombre de una propiedad, y el compilador la comprueba:

import type { Empleado } from './modelos';

function ordenarPor<T, K extends keyof T>(lista: readonly T[], clave: K): T[] {
  return [...lista].sort((a, b) => (a[clave] < b[clave] ? -1 : a[clave] > b[clave] ? 1 : 0));
}

function columna<T, K extends keyof T>(lista: readonly T[], clave: K): T[K][] {
  return lista.map((elemento) => elemento[clave]);
}

const plantilla = [
  { nombre: 'Luis', salario: 2100 },
  { nombre: 'Ana', salario: 6500 },
];

console.log(ordenarPor(plantilla, 'nombre')[0]?.nombre);
console.log(columna(plantilla, 'salario'));              // number[]
ordenarPor(plantilla, 'sueldo'); // ❌ TS2345 no es una clave de T

const vacia: Empleado[] = [];
console.log(columna(vacia, 'email'));                    // string[]
Ana
[ 2100, 6500 ]
[]

5.5 Valores por defecto y clases genéricas

Un parámetro de tipo puede tener un valor por defecto:

interface Respuesta<T = void> {
  ok: boolean;
  datos: T;
}

const sinDatos: Respuesta = { ok: true, datos: undefined };
const conDatos: Respuesta<string[]> = { ok: true, datos: ['Ana'] };

console.log(sinDatos.ok, conDatos.datos);
true [ 'Ana' ]

Y las clases también pueden ser genéricas. Esta caché en memoria funciona con cualquier entidad, siempre que le digas cómo obtener su identificador:

class Cache<T, Id = number> {
  readonly #elementos = new Map<Id, T>();
  readonly #obtenerId: (elemento: T) => Id;

  constructor(obtenerId: (elemento: T) => Id) {
    this.#obtenerId = obtenerId;
  }

  guardar(elemento: T): void {
    this.#elementos.set(this.#obtenerId(elemento), elemento);
  }

  buscar(id: Id): T | undefined {
    return this.#elementos.get(id);
  }

  get tamano(): number {
    return this.#elementos.size;
  }
}

interface Departamento {
  deptId: number;
  nombre: string;
}

const cache = new Cache<Departamento>((d) => d.deptId);
cache.guardar({ deptId: 10, nombre: 'DIRECCIÓN' });

console.log(cache.buscar(10)?.nombre, cache.buscar(99), cache.tamano);
DIRECCIÓN undefined 1

5.6 Tipos que transportan otros: Promise, Map, Observable

Ya usas genéricos sin saberlo. Son tipos que envuelven a otro:

Tipo Qué representa Dónde aparece en Angular
T[], Array<T> Lista En todas partes
Map<K, V>, Set<T> Colecciones Cachés, datos agrupados
Promise<T> Un valor que llegará resource, loaders, async/await
Observable<T> Un flujo de valores HttpClient, Router (módulo 14)
Signal<T>, WritableSignal<T> Un valor reactivo Todo el estado (módulo 05)

6. Tipos que se calculan a partir de otros

Una de las ideas más potentes de TypeScript es que los tipos se pueden derivar unos de otros. En vez de escribir a mano una interfaz casi igual a otra, y olvidarte de actualizar una de las dos, describes la relación entre ellas.

6.1 Utility types para objetos

La API EMPRESA necesita formas distintas del mismo dato. Para crear un empleado no se envía empId, porque lo pone el servidor. Para modificarlo, solo van los campos que cambian. Para mostrarlo en una lista, bastan unos pocos. No hacen falta cuatro interfaces:

import type { Empleado } from './modelos';

// Para el POST: todo menos lo que genera el servidor
type EmpleadoNuevo = Omit<Empleado, 'empId' | 'departamento'>;

// Para una modificación parcial: todo opcional
type CambiosEmpleado = Partial<EmpleadoNuevo>;

// Para una fila de tabla: solo lo que se muestra
type FilaEmpleado = Pick<Empleado, 'empId' | 'nombre' | 'apellidos' | 'salario'>;

// Para un dato congelado en memoria: nada se puede asignar
type EmpleadoFijo = Readonly<Empleado>;

// Todas las propiedades obligatorias, incluso las opcionales
type EmpleadoCompleto = Required<Empleado>;

function aplicarCambios(e: Empleado, cambios: CambiosEmpleado): Empleado {
  return { ...e, ...cambios };
}

const fila: FilaEmpleado = { empId: 100, nombre: 'Ana', apellidos: 'García López', salario: 6500 };
const nuevo: EmpleadoNuevo = {
  nombre: 'Eva', apellidos: 'Soler Ruiz', email: 'eva.soler@empresa.es', telefono: '600100999',
  fechaAlta: '2026-11-02', oficio: 'ANALISTA', salario: 3000, comision: null, jefeId: 100, deptId: 30,
};
const fijo: EmpleadoFijo = { ...nuevo, empId: 200 };
fijo.salario = 1; // ❌ TS2540 Readonly afecta a todas las propiedades

const completo: EmpleadoCompleto = { ...fijo }; // ❌ TS2322 departamento es obligatorio aquí

console.log(aplicarCambios(fijo, { salario: 3200 }).salario, fila.nombre, completo);

Empleado en el centro, con flechas a Omit, Partial, Pick y Readonly; cada resultado muestra qué propiedades conserva y cómo cambian

Figura 4. Un solo modelo y sus derivados. Si Empleado cambia, los demás cambian con él.

Utility type Resultado Uso típico
Partial<T> Todas opcionales Actualizaciones parciales, estado de formularios
Required<T> Todas obligatorias Configuración ya con valores por defecto
Readonly<T> Todas de solo lectura Datos que no deben cambiar
Pick<T, K> Solo esas claves Vistas reducidas
Omit<T, K> Todas menos esas DTO de creación
Record<K, V> Objeto con claves K y valores V Diccionarios

⚠️ Importante: Partial, Readonly y compañía solo actúan en el primer nivel. Si Pedido contiene lineas, Partial<Pedido> hace opcional lineas, pero no lo que hay dentro de cada línea.

6.2 Record

Record<K, V> define un objeto con unas claves y un tipo de valor. Si las claves son una unión de literales, el compilador exige todas: es otra forma de garantizar que no se olvida ningún caso.

import type { EstadoPedido } from './modelos';

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

const INCOMPLETO: Record<EstadoPedido, string> = { // ❌ TS2739 faltan claves
  PENDIENTE: 'Pendiente',
  ENVIADO: 'Enviado',
};

const pesos: Record<string, number> = { kg: 1000 };   // Claves libres: es una firma de índice

console.log(ETIQUETAS.ENVIADO, INCOMPLETO.CANCELADO, pesos['kg']);

6.3 keyof, typeof y acceso indexado

Tres operadores de tipos en los que se apoya todo lo anterior:

interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
}

// keyof: unión de las claves de un tipo
type ClaveDepartamento = keyof Departamento;   // 'deptId' | 'nombre' | 'localidad'

// Acceso indexado: el tipo de una propiedad
type TipoNombre = Departamento['nombre'];      // string

// typeof en posición de tipo: el tipo de un valor existente
const CONFIG = { url: 'http://localhost:3000/api', reintentos: 3, activa: true };
type Config = typeof CONFIG;

const otra: Config = { url: '/api', reintentos: 1, activa: false };
const clave: ClaveDepartamento = 'localidad';
const nombre: TipoNombre = 'VENTAS';

console.log(otra.url, clave, nombre);
/api localidad VENTAS

No confundas el typeof de JavaScript, que se ejecuta y devuelve una cadena, con el typeof de TypeScript en posición de tipo, que se borra al compilar.

6.4 Tipos para funciones y promesas

function crearSesion(usuario: string, recordar: boolean) {
  return { usuario, recordar, expira: 3600 };
}

async function cargarNombres(): Promise<string[]> {
  return ['Ana', 'Luis'];
}

type Sesion = ReturnType<typeof crearSesion>;      // { usuario: string; recordar: boolean; expira: number }
type Argumentos = Parameters<typeof crearSesion>;  // [usuario: string, recordar: boolean]
type Nombres = Awaited<ReturnType<typeof cargarNombres>>;   // string[]

type Rol = 'ADMIN' | 'USER' | 'INVITADO';
type RolConSesion = Exclude<Rol, 'INVITADO'>;      // 'ADMIN' | 'USER'
type SoloInvitado = Extract<Rol, 'INVITADO'>;      // 'INVITADO'
type Telefono = NonNullable<string | null | undefined>;   // string

const sesion: Sesion = crearSesion('ana', true);
const args: Argumentos = ['luis', false];
const nombres: Nombres = await cargarNombres();
const rol: RolConSesion = 'USER';
const invitado: SoloInvitado = 'INVITADO';
const telefono: Telefono = '600100100';

console.log(sesion.expira, args[0], nombres, rol, invitado, telefono);
3600 luis [ 'Ana', 'Luis' ] USER INVITADO 600100100

ReturnType resulta muy útil con funciones de librerías que no exportan su tipo de retorno.

6.5 Tipos mapeados

Un tipo mapeado recorre las claves de un tipo y genera otro. Es lo que hay dentro de Partial y Readonly. Se lee como un for de tipos:

// Así está definido Partial en TypeScript
type MiParcial<T> = {
  [K in keyof T]?: T[K];
};

// Cada campo de un formulario pasa a tener su estado de error
type ErroresDe<T> = {
  [K in keyof T]?: string;
};

// -readonly y -? quitan modificadores
type Editable<T> = {
  -readonly [K in keyof T]: T[K];
};

interface Departamento {
  readonly deptId: number;
  nombre: string;
}

const parcial: MiParcial<Departamento> = { nombre: 'VENTAS' };
const errores: ErroresDe<Departamento> = { nombre: 'Obligatorio' };
const editable: Editable<Departamento> = { deptId: 10, nombre: 'I+D' };
editable.deptId = 20;     // Ya no es readonly

console.log(parcial, errores, editable);
{ nombre: 'VENTAS' } { nombre: 'Obligatorio' } { deptId: 20, nombre: 'I+D' }

6.6 Tipos condicionales

Un tipo condicional elige un tipo según una condición. Tiene la misma forma que el operador ternario. Casi nunca los escribirás en una aplicación, pero aparecen al leer los tipos de una librería:

type EsTexto<T> = T extends string ? 'texto' : 'otro';

const a: EsTexto<string> = 'texto';
const b: EsTexto<number> = 'otro';

// infer captura una parte del tipo: así funciona Awaited, en versión simple
type ElementoDe<T> = T extends (infer E)[] ? E : never;

const c: ElementoDe<string[]> = 'hola';

console.log(a, b, c);
texto otro hola

6.7 Tipos de plantilla de texto

Igual que las plantillas de JavaScript, pero con tipos. Generan uniones de cadenas y validan formatos sencillos:

type Metodo = 'get' | 'post';
type Recurso = 'empleados' | 'pedidos';

type Accion = `${Metodo}-${Recurso}`;
// 'get-empleados' | 'get-pedidos' | 'post-empleados' | 'post-pedidos'

type RutaApi = `/api/${string}`;

const accion: Accion = 'post-pedidos';
const ruta: RutaApi = '/api/empleados';
const mala: RutaApi = '/empleados'; // ❌ TS2322 no empieza por /api/

// Combinado con un mapeo: renombra claves. as dentro de un mapeo cambia el nombre
type Getters<T> = {
  [K in keyof T & string as `obtener${Capitalize<K>}`]: () => T[K];
};

const lector: Getters<{ nombre: string; salario: number }> = {
  obtenerNombre: () => 'Ana',
  obtenerSalario: () => 6500,
};

console.log(accion, ruta, mala, lector.obtenerSalario());

💡 Consejo: No te obsesiones con estos tipos avanzados. En una aplicación Angular usarás a diario los de la sección 6.1 y keyof. Los condicionales y los de plantilla son para leer librerías o para escribir utilidades puntuales. Si un tipo cuesta más de entender que el código que protege, simplifícalo.


7. Clases

Las clases son el centro de Angular: componentes, directivas, pipes y servicios son clases. TypeScript les añade comprobaciones muy útiles. Lo básico de la sintaxis lo tienes en el Curso de JavaScript.

7.1 Propiedades y constructor

class Pedido {
  readonly id: number;
  estado = 'PENDIENTE';          // Con valor: el tipo se infiere
  notas?: string;                // Opcional: puede quedar sin valor
  private lineas: string[] = [];

  constructor(id: number) {
    this.id = id;                // readonly se puede asignar en el constructor
  }

  anadir(linea: string): this {  // Devolver this permite encadenar
    this.lineas.push(linea);
    return this;
  }

  get total(): number {          // Se lee como propiedad, sin paréntesis
    return this.lineas.length;
  }

  static vacio(): Pedido {
    return new Pedido(0);
  }
}

const pedido = new Pedido(5001).anadir('Portátil').anadir('Ratón');
console.log(pedido.id, pedido.estado, pedido.total, Pedido.vacio().id);
pedido.id = 2; // ❌ TS2540 readonly no se reasigna

Con strict, cada propiedad debe recibir valor en su declaración o en el constructor. Es la comprobación strictPropertyInitialization:

class SinIniciar {
  nombre: string; // ❌ TS2564 nadie le da valor
}

class Iniciada {
  nombre = '';                     // Solución 1: un valor inicial
  apellidos?: string;              // Solución 2: admitir que puede faltar
  email!: string;                  // Solución 3: "te aseguro que llegará"
}

console.log(new SinIniciar().nombre, new Iniciada().email);

El ! en una propiedad se llama aserción de asignación definitiva, y como el otro !, no comprueba nada. En Angular clásico lo verás a menudo en @Input() nombre!: string. Con la API moderna ya no hace falta, porque input.required<string>() es un valor que se crea en la propia declaración, como se verá en el módulo 06.

7.2 Propiedades de parámetro

TypeScript tiene un atajo: un modificador en un parámetro del constructor declara la propiedad y la asigna.

class Producto {
  constructor(
    readonly productoId: number,
    public nombre: string,
    private stock: number,
  ) {}

  hayExistencias(): boolean {
    return this.stock > 0;
  }
}

const raton = new Producto(6, 'Ratón inalámbrico', 40);
console.log(raton.nombre, raton.hayExistencias());
npx tsc; node salida/propiedades.js
Ratón inalámbrico true

Esto genera código, las asignaciones, y por eso node archivo.ts lo rechaza. Es la forma clásica de inyectar dependencias en Angular, constructor(private http: HttpClient), y la verás en código antiguo. El Angular moderno usa inject(), por un motivo que se aclara en la sección 7.7.

7.3 Visibilidad: public, protected y private

Modificador Accesible desde Uso en Angular
public (por defecto) Cualquier sitio API pública del componente o servicio
protected La clase y sus subclases Lo que usa la plantilla
private Solo la clase Detalles internos
class Cuenta {
  public titular = 'Ana';
  protected saldo = 100;
  private clave = '1234';

  comprobar(intento: string): boolean {
    return intento === this.clave;
  }
}

class CuentaPremium extends Cuenta {
  verSaldo(): number {
    return this.saldo;            // protected: visible en la subclase
  }
  verClave(): string {
    return this.clave; // ❌ TS2341 private no llega a las subclases
  }
}

const cuenta = new CuentaPremium();
console.log(cuenta.titular, cuenta.verSaldo(), cuenta.comprobar('1234'));
console.log(cuenta.saldo); // ❌ TS2445 protected no se ve desde fuera

7.4 private frente a #privado

JavaScript tiene sus propios campos privados, con #. Parece lo mismo que private, pero son dos cosas muy distintas:

  • private es de TypeScript: lo comprueba el compilador y se borra. En ejecución es una propiedad normal.
  • #campo es de JavaScript: lo impone el motor en ejecución. Fuera de la clase no existe.
class Secreto {
  private blando = 'lo protege el compilador';
  #duro = 'lo protege el motor';

  leerDuro(): string {
    return this.#duro;
  }
}

const s = new Secreto();

console.log(Object.keys(s));
console.log(JSON.stringify(s));
console.log((s as any).blando);    // Una conversión a any se salta private
console.log((s as any).duro, (s as any)['#duro']);   // Con # no hay forma
console.log(s.leerDuro());
console.log(s.#duro); // ❌ TS18013 un campo # no existe fuera de la clase

Comparación: el campo private sigue existiendo en el objeto en ejecución, y lo ven Object.keys, JSON.stringify y un cast a any; el campo con almohadilla no es accesible desde fuera

Figura 5. private desaparece al compilar. # es una barrera real en ejecución.

Para ver la salida, quita la línea marcada, que ni siquiera es JavaScript válido:

class Secreto {
  private blando = 'lo protege el compilador';
  #duro = 'lo protege el motor';

  leerDuro(): string {
    return this.#duro;
  }
}

const s = new Secreto();

console.log(Object.keys(s));
console.log(JSON.stringify(s));
console.log((s as any).blando);
console.log((s as any).duro, (s as any)['#duro']);
console.log(s.leerDuro());
[ 'blando' ]
{"blando":"lo protege el compilador"}
lo protege el compilador
undefined undefined
lo protege el motor
  private #campo
Quién lo impone El compilador El motor de JavaScript
Existe en ejecución Sí, como propiedad normal Sí, pero solo accesible desde dentro
Se salta con as any Sí No
Aparece en JSON.stringify y Object.keys Sí No
Se puede espiar en una prueba Sí, con as any No

En Angular, ambos se usan. El curso sigue la costumbre del código oficial, que usa private y protected. Elige # cuando la barrera deba ser real, por ejemplo en una librería o para evitar que un dato acabe en un JSON.

7.5 Qué puede ver una plantilla 🆕 v22.2

🆕 Desde Angular 22.2. No existe en la LTS (Angular 21): una plantilla que lee un miembro private falla con TS2341. Si usas la LTS: declara protected lo que use la plantilla, una forma que también es válida en 22.2 y que es la que seguimos en el curso.

Una plantilla se compila contra la clase, así que debe poder acceder a lo que usa:

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-saludo',
  template: `<p>{{ titulo() }}, {{ nombre() }}</p>`,
})
export class Saludo {
  protected readonly titulo = signal('Hola');   // Para la plantilla
  readonly nombre = signal('Ana');              // Público: también para otras clases
  private contador = 0;                         // Interno

  incrementar(): number {
    return ++this.contador;
  }
}
Miembro Plantilla en Angular 22.2 Plantilla en Angular 21
public Sí Sí
protected Sí Sí
private Sí No, error TS2341
#campo No, error NG5002 No, error NG5002

El curso declara protected lo que solo usa la plantilla. Así, leyendo la clase, se distingue la API pública que ofrece el componente de lo que es solo para su vista. Encontrarás más sobre esto en el módulo 04.

7.6 Herencia, abstract, override e implements

Una clase abstracta no se puede instanciar: es una base con huecos que las subclases deben rellenar. implements comprueba que una clase cumple una interfaz:

interface Identificable {
  obtenerId(): number;
}

abstract class Repositorio<T extends Identificable> implements Identificable {
  protected elementos: T[] = [];

  abstract nombre(): string;       // Sin cuerpo: la subclase lo debe escribir

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

  describir(): string {
    return `${this.nombre()}: ${this.elementos.length} elementos`;
  }

  obtenerId(): number {
    return 1;
  }
}

class Linea implements Identificable {
  constructor(readonly numero: number) {}
  obtenerId(): number {
    return this.numero;
  }
}

class RepositorioLineas extends Repositorio<Linea> {
  nombre(): string {
    return 'Líneas';
  }

  override describir(): string {   // override: declara que sobrescribes a propósito
    return `[${super.describir()}]`;
  }

  guardar(elemento: Linea): void { // ❌ TS4114 falta override
    super.guardar(elemento);
  }
}

const repositorio = new RepositorioLineas();
repositorio.guardar(new Linea(1));
console.log(repositorio.describir());

const base = new Repositorio<Linea>(); // ❌ TS2511 una clase abstracta no se instancia
console.log(base);

override es obligatorio porque Angular activa noImplicitOverride. Evita un error muy sutil: si renombras un método en la clase base, la subclase no sigue “sobrescribiendo” sin que nadie lo note. Con override, el compilador protesta porque el método ya no existe arriba.

implements es el motivo de una costumbre de Angular. Los métodos del ciclo de vida funcionan solo por su nombre, pero se declaran con su interfaz para detectar erratas:

import { Component, OnDestroy, OnInit } from '@angular/core';

@Component({ selector: 'app-reloj', template: `<p>Reloj</p>` })
export class Reloj implements OnInit, OnDestroy {
  ngOnInit(): void {
    console.log('Iniciado');
  }

  ngOnDestroy(): void {
    console.log('Destruido');
  }
}

@Component({ selector: 'app-mal', template: `<p>Mal</p>` })
export class Mal implements OnInit { // ❌ TS2420 con ngOnInt no se cumple OnInit
  ngOnInt(): void {
    console.log('Nunca se ejecutará');
  }
}

Sin implements OnInit, el método ngOnInt sería un método más, y Angular no lo llamaría. No habría ningún error y el fallo sería muy difícil de encontrar.

7.7 Orden de inicialización: por qué inject() va en la propiedad

Las propiedades de clase se inicializan antes que el cuerpo del constructor, y con target ES2022, antes también que las propiedades de parámetro. Por eso este código, que parece razonable, no compila:

class Cliente {
  obtener(): string[] {
    return ['Ana'];
  }
}

class Servicio {
  datos = this.cliente.obtener(); // ❌ TS2729 cliente aún no tiene valor

  constructor(private readonly cliente: Cliente) {}
}

console.log(new Servicio(new Cliente()).datos);

Las propiedades de una clase se crean con la semántica nativa de JavaScript, en el orden en que aparecen. Las propiedades de parámetro se asignan después, cuando empieza el constructor. Si ejecutas ese código, falla con TypeError porque cliente es undefined.

Esta es una de las razones por las que el Angular moderno pide las dependencias con inject(). Una propiedad normal se inicializa en orden, de arriba abajo, y la siguiente ya puede usarla:

import { Component, inject, signal } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({ selector: 'app-cabecera', template: `<h1>{{ titulo() }}</h1>` })
export class Cabecera {
  private readonly servicioTitulo = inject(Title);
  // servicioTitulo ya tiene valor: está declarada antes
  protected readonly titulo = signal(this.servicioTitulo.getTitle());
}

inject() se explica en el módulo 08. Aquí importa la regla: declara primero lo que otros usan.

⚠️ Importante: Este orden también afecta a la herencia. El constructor de la clase base se ejecuta antes de que se inicialicen las propiedades de la subclase. Si la base llama a un método sobrescrito que usa una propiedad de la subclase, la encontrará en undefined.


8. Decoradores

8.1 Qué es un decorador

Un decorador es una función que se aplica a una clase o a un miembro de la clase usando la sintaxis @nombre. Se ejecuta una sola vez, al definir la clase, no al crear instancias. Sirve para añadir información o comportamiento desde fuera, sin mezclarlo con el código de la clase.

Es la pieza más visible de Angular, y conviene saber que por dentro no tiene nada de magia.

function Registrado(constructor: Function): void {
  console.log(`Clase definida: ${constructor.name}`);
}

@Registrado
class ApiEmpleados {
  constructor() {
    console.log('Instancia creada');
  }
}

console.log('Programa en marcha');
new ApiEmpleados();
new ApiEmpleados();
npx tsc; node salida/decorador.js
Clase definida: ApiEmpleados
Programa en marcha
Instancia creada
Instancia creada

El decorador se ejecuta antes que el resto del programa, al evaluar la clase, y solo una vez.

8.2 Fábricas de decoradores

Casi nunca se escribe @Component a secas: se pasa un objeto. Eso es una fábrica de decoradores, una función que recibe configuración y devuelve el decorador. Todos los ejemplos de esta sección se ejecutan con npx tsc; node salida/ejemplo.js:

function Etiqueta(texto: string) {
  return function (constructor: Function): void {
    console.log(`${texto}: ${constructor.name}`);
  };
}

@Etiqueta('Servicio')
class ApiPedidos {}

console.log(new ApiPedidos());
Servicio: ApiPedidos
ApiPedidos {}

8.3 Decoradores de método y de propiedad

Un decorador de método recibe el prototipo, el nombre y el descriptor del método, y puede sustituirlo. Un decorador de propiedad recibe el prototipo y el nombre. Este ejemplo mide el tiempo de un método y anota una propiedad:

function Registrar(_objetivo: object, nombre: string, descriptor: PropertyDescriptor): void {
  const original = descriptor.value as (...args: unknown[]) => unknown;

  descriptor.value = function (this: unknown, ...args: unknown[]): unknown {
    console.log(`Llamada a ${nombre}(${args.join(', ')})`);
    const resultado = original.apply(this, args);
    console.log(`Resultado: ${String(resultado)}`);
    return resultado;
  };
}

function Campo(objetivo: object, nombre: string): void {
  console.log(`Propiedad decorada: ${objetivo.constructor.name}.${nombre}`);
}

class Calculadora {
  @Campo
  iva = 0.21;

  @Registrar
  conIva(importe: number): number {
    return importe * (1 + this.iva);
  }
}

new Calculadora().conIva(100);
Propiedad decorada: Calculadora.iva
Llamada a conIva(100)
Resultado: 121

Fíjate en que el nombre Calculadora.iva se imprime al definir la clase, antes de que exista ninguna instancia.

8.4 Una versión mínima de @Component

¿Qué hace @Component? Su trabajo esencial es guardar información sobre una clase, para que otra parte del sistema la use después. Podemos imitarlo en pocas líneas:

interface ConfiguracionComponente {
  selector: string;
  plantilla: (instancia: any) => string;
}

// Un registro global que hace de "framework"
const componentes = new Map<string, new () => object>();
const definiciones = new Map<Function, ConfiguracionComponente>();

function Componente(configuracion: ConfiguracionComponente) {
  return function <T extends new () => object>(clase: T): void {
    definiciones.set(clase, configuracion);
    componentes.set(configuracion.selector, clase);
  };
}

function pintar(selector: string): string {
  const Clase = componentes.get(selector);
  if (!Clase) {
    throw new Error(`Componente desconocido: ${selector}`);
  }
  const definicion = definiciones.get(Clase);
  return definicion ? definicion.plantilla(new Clase()) : '';
}

@Componente({
  selector: 'app-tarjeta',
  plantilla: (c: Tarjeta) => `<p>${c.nombre} gana ${c.salario} €</p>`,
})
class Tarjeta {
  nombre = 'Ana';
  salario = 6500;
}

console.log(pintar('app-tarjeta'));
<p>Ana gana 6500 €</p>

La clase Tarjeta no sabe nada del framework. El decorador la ha registrado, y pintar crea la instancia y usa la plantilla. Angular hace esto mismo a una escala mucho mayor.

8.5 Qué hace de verdad Angular con @Component

Hay una diferencia importante con nuestra imitación: Angular no espera a la ejecución. Su compilador lee los decoradores al compilar, convierte la plantilla en funciones de JavaScript y sustituye el decorador por campos estáticos de la clase. Este componente:

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<p>{{ titulo() }}</p>`,
})
export class App {
  protected readonly titulo = signal('EMPRESA');
}

queda, tras ng build --configuration development, de esta forma. Es la salida real del fichero main.js, en la que solo se han juntado las líneas de signal:

var App = class _App {
  titulo = signal("EMPRESA", ...ngDevMode ? [{ debugName: "titulo" }] : []);
  static ɵfac = function App_Factory(__ngFactoryType__) {
    return new (__ngFactoryType__ || _App)();
  };
  static ɵcmp = /* @__PURE__ */ ɵɵdefineComponent({ type: _App, selectors: [["app-root"]], decls: 2, vars: 1, template: function App_Template(rf, ctx) {
    if (rf & 1) {
      ɵɵdomElementStart(0, "p");
      ɵɵtext(1);
      ɵɵdomElementEnd();
    }
    if (rf & 2) {
      ɵɵadvance();
      ɵɵtextInterpolate(ctx.titulo());
    }
  }, encapsulation: 2 });
};

Fíjate en lo que ha pasado. No queda @Component, ni protected, ni readonly, porque son cosas del compilador. La plantilla ya no es texto: es una función que crea el <p> y otra rama que actualiza su contenido leyendo ctx.titulo(), donde ctx es la instancia del componente.

El decorador Component con su selector y plantilla entra en el compilador de Angular y sale una clase sin decorador, con los campos estáticos ɵfac y ɵcmp que contienen funciones de creación y actualización de la vista

Figura 6. En ejecución ya no hay decorador: quedan campos estáticos con el código de la plantilla.

Tres detalles que conviene entender:

  • ɵfac es la fábrica: la función que crea la instancia.
  • ɵcmp es la definición del componente. Contiene el selector y la plantilla convertida en dos fases: creación de elementos (rf & 1) y actualización de valores (rf & 2).
  • La letra griega ɵ (theta) marca API interna de Angular. Nunca la uses en tu código.

Como todo esto se decide al compilar, Angular puede dar errores de plantilla en la build, y puede eliminar lo que no usas. Es la misma idea que el borrado de tipos: el trabajo se hace antes de llegar al navegador.

8.6 Los dos tipos de decoradores

Existen dos especificaciones, y es normal confundirse:

  Experimentales Estándar (TC39)
Se activan con "experimentalDecorators": true Sin ninguna opción
Firma (constructor), (objetivo, nombre, descriptor) (valor, contexto)
Decoradores de parámetro Sí No
Usados por Angular Sí No

El tsconfig.json que genera ng new lleva "experimentalDecorators": true, tanto en Angular 21 como en 22. A pesar del nombre, no significa que Angular sea experimental, sino que usa la versión histórica de la sintaxis, y es la que debes emplear en tus decoradores dentro de un proyecto Angular.

Los ejemplos de esta sección usan esa misma firma. Si quitas la opción de tu tsconfig.json, TypeScript aplicará la firma estándar, y los decoradores de método y de propiedad dejarán de compilar con los errores TS1241 y TS1240.

8.7 Decoradores de Angular

Decorador Se aplica a Módulo
@Component Clase con vista 03
@Directive Clase que añade comportamiento a un elemento 07
@Pipe Clase que transforma valores en plantillas 07
@Injectable Clase que recibe dependencias 08
@NgModule Agrupación clásica, solo código antiguo 28

Los decoradores de propiedad @Input, @Output, @ViewChild y @HostListener son la forma clásica de lo que hoy se escribe con funciones: input(), output(), viewChild() y la propiedad host. La razón del cambio tiene que ver con los tipos: una función como input<string>() devuelve un valor con su tipo completo, y no necesita la intervención del compilador para conocerlo.

8.8 @Service 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: escribe @Injectable({ providedIn: 'root' }), que hace lo mismo.

Angular 22 añade un decorador específico para servicios, que se estudia en el módulo 08:

import { Service } from '@angular/core';

@Service()
export class Registro {
  anotar(mensaje: string): void {
    console.log(mensaje);
  }
}

9. Módulos

9.1 import y export

Cada fichero .ts con un import o un export es un módulo: lo que declara es privado salvo que lo exporte. Es la sintaxis estándar de JavaScript, con un añadido: también se pueden exportar tipos.

// utilidades.ts
export const IVA = 0.21;

export function conIva(importe: number): number {
  return importe * (1 + IVA);
}

export interface Importe {
  neto: number;
  bruto: number;
}

const interno = 'no se exporta';
console.log(interno.length);
// usa-utilidades.ts
import { conIva, IVA, type Importe } from './utilidades.ts';
import * as utilidades from './utilidades.ts';

const importe: Importe = { neto: 100, bruto: conIva(100) };
console.log(IVA, importe.bruto, utilidades.IVA);

En Angular se usan casi siempre las exportaciones con nombre (export class Empleados), que son las que genera ng generate. export default existe y resulta cómodo con la carga perezosa (loadComponent), como verás en el módulo 10, pero tiene un inconveniente: cada fichero que lo importa puede ponerle un nombre distinto, y eso complica buscar y renombrar.

9.2 import type

Una importación que solo trae tipos no debe quedar en el JavaScript final. import type lo garantiza, y deja claro al leerla que ese fichero no se usa en ejecución:

import type { Empleado } from './modelos';

function nombreCorto(e: Pick<Empleado, 'nombre'>): string {
  return e.nombre.slice(0, 3);
}

console.log(nombreCorto({ nombre: 'Ana' }));

La opción isolatedModules, activa en Angular, existe porque la herramienta que transforma cada fichero puede trabajar con uno solo, sin ver los demás. Si un fichero reexporta un tipo sin marcarlo, esa herramienta no sabría si debe borrarlo. Por eso, para reexportar un tipo, debes decirlo:

export type { Empleado } from './modelos';
export { Empleado as MiEmpleado } from './modelos'; // ❌ TS1205 hay que usar export type

Además, un import type no se puede usar como valor:

import type { Component } from '@angular/core';

console.log(Component); // ❌ TS1361 se importó solo como tipo

⚠️ Importante: En Angular, no importes con import type una clase que vayas a inyectar con inject(MiServicio), porque ahí la clase se usa como valor en ejecución. Es la diferencia entre una interface, que no existe en ejecución, y una class, que es a la vez un tipo y un valor.

class Empleado {
  constructor(readonly nombre: string) {}
}

// Como tipo: en la anotación
const ana: Empleado = new Empleado('Ana');
// Como valor: new Empleado y console.log(Empleado)
console.log(ana.nombre, typeof Empleado);
npx tsc; node salida/valor-tipo.js
Ana function

9.3 Ficheros índice

Un fichero índice (barrel) reexporta el contenido de una carpeta para acortar las importaciones:

// indice.ts
export * from './utilidades.ts';
export type * from './modelos';
// usa-indice.ts
import { conIva, type Empleado } from './indice.ts';

const ana: Pick<Empleado, 'salario'> = { salario: 6500 };
console.log(conIva(ana.salario));

En proyectos Angular se llaman index.ts, nombre que se resuelve solo. Cuidado con abusar de ellos: pueden crear dependencias circulares, y dificultar que el bundler descarte código sin usar. Úsalos para la API pública de una librería o de una funcionalidad, no por sistema.

9.4 Cómo se resuelven los módulos

Un import de ruta relativa empieza por ./ o ../. Sin ese prefijo, se busca un paquete en node_modules. El tsconfig de Angular usa "module": "preserve", lo que significa: “deja los import tal como están, porque de agruparlos se encarga el bundler”. Con ese modo se resuelven los módulos igual que lo hace un bundler moderno, y por eso no se escribe extensión.

Cuando las rutas relativas se alargan (../../../modelos/empleado), se definen alias en tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@modelos/*": ["./src/app/modelos/*"],
      "@servicios/*": ["./src/app/servicios/*"]
    }
  }
}
import type { Empleado } from '@modelos/empleado';

⚠️ Importante: Los tutoriales antiguos añaden también "baseUrl": "./" junto a paths. En TypeScript 6 baseUrl está obsoleto y produce el error TS5101. En paths usa rutas que empiecen por ./, y funcionará en las dos versiones.

9.5 Ficheros de declaración .d.ts

Un fichero .d.ts contiene solo tipos, sin código. Es lo que publican las librerías para describir su JavaScript. Dentro aparece declare, que le dice al compilador “esto existe en ejecución, créeme”:

// globales.d.ts
declare const VERSION_API: string;

interface Window {
  datosIniciales?: { usuario: string };
}

La segunda declaración usa la fusión de interfaces: amplía el tipo Window de la librería estándar. Con declare ya puedes usar VERSION_API en tu código, aunque la variable la cree otro <script>.


10. strict y el tsconfig de Angular

10.1 El tsconfig.json que genera ng new

Este es el contenido de un proyecto recién creado con Angular 22.2, y conviene entenderlo línea a línea:

{
  "compileOnSave": false,
  "compilerOptions": {
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "target": "ES2022",
    "module": "preserve"
  },
  "angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true
  },
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.spec.json" }
  ]
}
Opción Qué hace
noImplicitOverride Obliga a escribir override
noPropertyAccessFromIndexSignature Obliga a x['clave'] en firmas de índice
noImplicitReturns Todas las ramas de una función deben devolver algo
noFallthroughCasesInSwitch Prohíbe caer de un case al siguiente sin break
skipLibCheck No comprueba los .d.ts de las dependencias, y compila más rápido
isolatedModules Garantiza que cada fichero se pueda transformar por separado
experimentalDecorators Activa los decoradores que usa Angular
importHelpers Toma las funciones auxiliares de tslib y no las repite en cada fichero
target Versión de JavaScript que se genera
module preserve mantiene los import, de los que se ocupa el bundler
references Un tsconfig para la aplicación y otro para las pruebas

Los dos ficheros hijos solo cambian lo que hay que incluir. tsconfig.app.json toma src/**/*.ts sin las pruebas, con "types": []. tsconfig.spec.json toma los *.spec.ts, con "types": ["vitest/globals"], que es lo que hace disponibles describe e it sin importarlos.

10.2 strict por defecto 🔄 v22.0

🔄 Cambia en Angular 22.0. Angular 22 usa TypeScript 6, donde strict está activo sin escribirlo, y por eso ng new ya no lo incluye en el tsconfig.json. En la LTS (Angular 21, TypeScript 5.9) el valor por defecto es false. Si usas la LTS: mantén "strict": true en compilerOptions, que ya lo escribe ng new, y tendrás las mismas comprobaciones.

strict es una opción que activa una familia completa. Si copias un tsconfig.json antiguo sin esa línea a Angular 21, perderás todas estas comprobaciones sin ningún aviso:

Opción incluida Qué comprueba Error típico
strictNullChecks null y undefined no son valores de cualquier tipo TS18047, TS18048
noImplicitAny Prohíbe el any que se cuela sin querer TS7006
strictPropertyInitialization Las propiedades reciben valor TS2564
strictFunctionTypes Comprueba con rigor los parámetros de los callbacks TS2322
strictBindCallApply Comprueba bind, call y apply TS2345
noImplicitThis this debe tener un tipo conocido TS2683
useUnknownInCatchVariables En catch (e), e es unknown, no any TS18046
alwaysStrict Modo estricto de JavaScript  
strictBuiltinIteratorReturn El retorno de los iteradores nativos no es any TS2322

useUnknownInCatchVariables merece un ejemplo, porque aparece en todos los try/catch. En JavaScript se puede lanzar cualquier cosa, no solo errores. Por eso e es unknown y hay que comprobarlo:

function mensajeDe(accion: () => void): string {
  try {
    accion();
    return 'Correcto';
  } catch (e) {
    console.log(e.message); // ❌ TS18046 e es unknown
    return e instanceof Error ? e.message : String(e);
  }
}

console.log(mensajeDe(() => JSON.parse('{')).length > 0);

10.3 Otras comprobaciones de Angular

function clasificar(salario: number) { // ❌ TS7030 falta un return
  if (salario > 3000) {
    return 'alto';
  }
}

function etiquetar(salario: number): string { // ❌ TS2366 con retorno declarado, otro código
  if (salario > 3000) {
    return 'alto';
  }
}

function detalle(estado: string): string {
  let texto = '';
  switch (estado) {
    case 'ENVIADO': // ❌ TS7029 cae al case siguiente
      texto = 'En reparto';
    case 'ENTREGADO':
      texto += ' y entregado';
      break;
  }
  return texto;
}

console.log(clasificar(1), etiquetar(1), detalle('ENVIADO'));

La opción noImplicitReturns protege de olvidar un return, que en JavaScript devolvería undefined. Es otra razón para declarar el retorno: con : string, el olvido se detecta aunque quiten la opción, con el código TS2366. noFallthroughCasesInSwitch evita el break olvidado, un error clásico en cualquier lenguaje con switch.

10.4 strictTemplates 🔄 v22.0

🔄 Cambia en Angular 22.0. strictTemplates queda activo sin escribirlo, y ng new ya no lo incluye en el tsconfig.json. Si usas la LTS: déjalo en "angularCompilerOptions" como "strictTemplates": true, que es lo que escribe ng new en Angular 21, y tendrás la misma comprobación.

strict comprueba el TypeScript, y strictTemplates las plantillas. Con ella, una expresión de plantilla se comprueba con el mismo rigor que el código:

En la plantilla Resultado con strictTemplates
{{ empleado.sueldo }} Error TS2339: la propiedad no existe
[salario]="'alto'" en un input<number>() Error TS2322: string no es number
{{ encontrado.nombre }} con Empleado \| undefined Error TS2532: puede ser undefined
(input)="f($event)", con f(e: string) Error TS2345: $event es un InputEvent

Las otras dos opciones de angularCompilerOptions son más concretas. strictInjectionParameters da error si no se puede resolver un parámetro de constructor inyectable, y strictInputAccessModifiers respeta private y protected en las entradas al enlazarlas desde una plantilla.

10.5 Comprobaciones aún más estrictas

Puedes endurecer el proyecto con opciones que no activa strict ni ng new:

const salarios = [6500, 2100];
const nombres: { [id: string]: string } = { '100': 'Ana' };

// Con noUncheckedIndexedAccess, los dos son number | undefined y string | undefined
console.log(salarios[5], nombres['999']);
undefined undefined
Opción Efecto ¿Merece la pena?
noUncheckedIndexedAccess lista[i] incluye undefined Sí, aunque añade comprobaciones en bucles con índice
exactOptionalPropertyTypes x?: string no admite un undefined explícito Solo si la distinción te importa
noUnusedLocals, noUnusedParameters Avisa de lo que sobra Mejor con el linter

10.6 Cambios por defecto en TypeScript 6 🔄 v22.0

🔄 Cambia en Angular 22.0. TypeScript 6 altera varios valores por defecto y marca como obsoletas opciones antiguas. Si usas la LTS: con TypeScript 5.9 estas opciones siguen sin dar error, pero conviene hacer el mismo cambio: quitar baseUrl y escribir types y strict de forma explícita te ahorrará trabajo al actualizar.

Si heredas un proyecto, estos son los cambios que notarás:

Cambio Antes Ahora
strict false true
types Se incluyen todos los @types visibles []: hay que listar los que se usan
baseUrl Permitido Error TS5101, se silencia con "ignoreDeprecations": "6.0"

Por eso la configuración de pruebas lista "types": ["vitest/globals"], y si en un proyecto de Node escribes process sin "types": ["node"], el compilador dirá que no lo conoce.


11. Asincronía y datos externos

11.1 Tipar promesas

Una función async siempre devuelve una Promise. El tipo que declaras es el de lo que resuelve:

function esperar(ms: number): Promise<void> {
  return new Promise((resolver) => setTimeout(resolver, ms));
}

async function obtenerNombre(id: number): Promise<string> {
  await esperar(10);
  return id === 100 ? 'Ana' : 'Desconocido';
}

const [uno, dos] = await Promise.all([obtenerNombre(100), obtenerNombre(1)]);
console.log(uno, dos);

const resultados = await Promise.allSettled([obtenerNombre(100), Promise.reject(new Error('Fallo'))]);
for (const r of resultados) {
  console.log(r.status === 'fulfilled' ? r.value : r.reason);
}

Un error frecuente es olvidar el await. El compilador lo detecta en cuanto usas el valor:

async function obtenerSalario(): Promise<number> {
  return 6500;
}

const salario = obtenerSalario();
console.log(salario.toFixed(2)); // ❌ TS2339 una Promise no tiene toFixed

11.2 fetch devuelve lo que declares, y eso es peligroso

response.json() devuelve Promise<any>. Si lo asignas a un tipo concreto, el compilador se fía, y de ahí el problema del principio de este módulo:

import type { Empleado } from './modelos';
import type { Pagina } from './paginacion';

async function listar(): Promise<Pagina<Empleado>> {
  const respuesta = await fetch('http://localhost:3000/api/empleados?tamano=2');
  return (await respuesta.json()) as Pagina<Empleado>;
}

const pagina = await listar();
for (const e of pagina.contenido) {
  console.log(e.empId, e.nombre, e.salario);
}
console.log(`${pagina.contenido.length} de ${pagina.total}`);
100 Ana 6500
101 Luis 4800
2 de 24

Para ejecutar este ejemplo necesitas la API EMPRESA en marcha, con node server.mjs. La descarga está en recursos/empresa-api.zip.

HttpClient.get<T>() hace lo mismo, aunque de forma más disimulada: T no se comprueba, es un as encubierto. Lo verás en el módulo 12. Mientras la API y el modelo estén sincronizados, no pasa nada. Pero si alguien renombra salario a sueldo en el servidor, el compilador seguirá feliz y la aplicación mostrará undefined.

11.3 Validar en la frontera

La solución profesional es comprobar lo que entra por la frontera de la aplicación, y a partir de ahí confiar en los tipos. Un predicado de tipo basta para datos sencillos:

// validar.ts
import type { Empleado } from './modelos';

export function esEmpleado(valor: unknown): valor is Empleado {
  if (typeof valor !== 'object' || valor === null) {
    return false;
  }
  const o = valor as Record<string, unknown>;
  return (
    typeof o['empId'] === 'number' &&
    typeof o['nombre'] === 'string' &&
    typeof o['salario'] === 'number' &&
    (o['comision'] === null || typeof o['comision'] === 'number')
  );
}
import { esEmpleado } from './validar.ts';

const bueno: unknown = { empId: 100, nombre: 'Ana', salario: 6500, comision: null };
const malo: unknown = { empId: 100, nombre: 'Ana', sueldo: 6500 };

console.log(esEmpleado(bueno), esEmpleado(malo));

if (esEmpleado(bueno)) {
  console.log(bueno.nombre.toUpperCase());
}
true false
ANA

Para modelos grandes se usan librerías de validación como Zod o Valibot, que además generan el tipo a partir del esquema. Las verás al estudiar validateStandardSchema en el módulo 18.

💡 Consejo: No hace falta validar todo. Si la API y el cliente son del mismo equipo, confiar en el modelo es una decisión razonable. Valida lo que venga de terceros, de localStorage, de la URL o de un formulario.

11.4 Errores tipados

Como catch da unknown, conviene definir una clase de error propia y comprobarla con instanceof. Este módulo se usa en el caso práctico de la sección siguiente:

// api-empresa.ts
import type { ErrorApi } from './modelos';

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

export class ErrorHttp extends Error {
  readonly status: number;
  readonly detalles: { [campo: string]: string };

  constructor({ status, mensaje, detalles = {} }: ErrorApi) {
    super(mensaje);
    this.name = 'ErrorHttp';
    this.status = status;
    this.detalles = detalles;
  }
}

export async function pedir<T>(ruta: string): Promise<T> {
  const respuesta = await fetch(`${BASE}${ruta}`);
  const cuerpo: unknown = await respuesta.json();
  if (!respuesta.ok) {
    throw new ErrorHttp(cuerpo as ErrorApi);
  }
  return cuerpo as T;
}

export function textoDeError(error: unknown): string {
  if (error instanceof ErrorHttp) {
    return `Error ${error.status}: ${error.message}`;
  }
  if (error instanceof Error) {
    return `No se pudo contactar con la API: ${error.message}`;
  }
  return 'Error desconocido';
}
import { pedir, textoDeError } from './api-empresa.ts';
import type { Departamento } from './modelos';

try {
  const ventas = await pedir<Departamento>('/departamentos/30');
  console.log(ventas.nombre);
  await pedir<Departamento>('/departamentos/999');
} catch (e) {
  console.log(textoDeError(e));
}
INFORMÁTICA
Error 404: Departamento 999 no encontrado

12. Leer las declaraciones de Angular

Saber leer un .d.ts te hace independiente de la documentación. En VS Code, pulsa F12 sobre cualquier función de Angular, o haz clic con Ctrl, y llegarás a su declaración. Ya tienes todo lo necesario para entenderla. Estas son declaraciones reales de @angular/core 22.2.

declare function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T>;

signal es genérica. T se infiere del valor inicial, y se usa dos veces: entra por el parámetro y sale envuelta. Con signal(0) se obtiene WritableSignal<number>, y con signal<Empleado[]>([]) se indica a mano.

type Signal<T> = (() => T) & {
    [SIGNAL]: unknown;
};

Signal<T> es una intersección: una función sin parámetros que devuelve T, y un objeto con una clave especial. Por eso se lee con paréntesis, contador().

interface WritableSignal<T> extends Signal<T> {

Una señal escribible es una señal con más métodos, como set y update.

declare function computed<T>(computation: () => T, options?: CreateComputedOptions<T>): Signal<T>;

computed recibe una función y devuelve un Signal, no un WritableSignal. Por eso una señal calculada no tiene set, y escribirle es un error de compilación.

type ResourceStatus = 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local';

Es una unión de literales, no un enum: lo que aconsejamos en la sección 2.5.

Y esta es la firma principal de HttpClient.get, una de sus muchas sobrecargas:

get<T>(url: string, options?: {
    observe?: 'body';
    responseType?: 'json';
} & HttpClientCommonOptions): Observable<T>;

Aquí se juntan genéricos, opcionales, literales e intersecciones. T no sale de los parámetros, así que hay que darlo a mano: http.get<Empleado[]>(url). Si no pasas options, se entiende que quieres el cuerpo en JSON. Las sobrecargas restantes cambian el tipo de retorno según observe y responseType.

Puedes comprobar lo que has aprendido con este ejemplo, que usa estos mismos tipos:

import { computed, signal, type Signal, type WritableSignal } from '@angular/core';
import type { Empleado } from './modelos';

const lista: WritableSignal<Empleado[]> = signal<Empleado[]>([]);
const cuantos: Signal<number> = computed(() => lista().length);

cuantos.set(3); // ❌ TS2339 un Signal no tiene set

const contador = signal(0);
contador.set('uno'); // ❌ TS2345 T se infirió como number

contador.update((valor) => valor + 1);
console.log(contador(), cuantos());

13. Caso práctico: la lista de empleados de EMPRESA

Vamos a juntar lo aprendido en una aplicación Angular real: una lista de empleados que muestra sus estados de carga con una unión discriminada. Usa funciones de plantilla que se explican más adelante, como @let, @switch y @for. No te preocupes por ellas: el foco está en los tipos.

Crea el proyecto como en el módulo 01 y arranca la API EMPRESA. Copia en src/app/ los ficheros modelos.ts, paginacion.ts y api-empresa.ts de las secciones anteriores. Sustituye src/app/app.ts, app.html y app.css por los siguientes:

// app.ts
import { Component, computed, signal } from '@angular/core';
import { pedir, textoDeError } from './api-empresa';
import type { Empleado } from './modelos';
import type { EstadoCarga, Pagina } from './paginacion';

type Fila = Pick<Empleado, 'empId' | 'nombre' | 'apellidos' | 'oficio' | 'salario'>;

@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {
  protected readonly estado = signal<EstadoCarga<Pagina<Empleado>>>({ tipo: 'inactivo' });

  protected readonly filas = computed<Fila[]>(() => {
    const actual = this.estado();
    return actual.tipo === 'listo' ? actual.datos.contenido : [];
  });

  protected readonly masaSalarial = computed(() =>
    this.filas().reduce((total, fila) => total + fila.salario, 0),
  );

  protected cargar(ruta = '/empleados?tamano=6&orden=-salario'): void {
    void this.ejecutar(ruta);
  }

  private async ejecutar(ruta: string): Promise<void> {
    this.estado.set({ tipo: 'cargando' });
    try {
      const datos = await pedir<Pagina<Empleado>>(ruta);
      this.estado.set({ tipo: 'listo', datos });
    } catch (e) {
      this.estado.set({ tipo: 'error', mensaje: textoDeError(e) });
    }
  }
}
<!-- app.html -->
<h1>Empleados de EMPRESA</h1>

<div class="acciones">
  <button id="cargar" (click)="cargar()">Cargar</button>
  <button id="fallar" (click)="cargar('/empleados/9999')">Provocar un error</button>
</div>

@let actual = estado();

@switch (actual.tipo) {
  @case ('inactivo') {
    <p>Pulsa Cargar para pedir los datos.</p>
  }
  @case ('cargando') {
    <p>Cargando...</p>
  }
  @case ('error') {
    <p class="error">{{ actual.mensaje }}</p>
  }
  @case ('listo') {
    <table>
      <thead>
        <tr><th>Id</th><th>Empleado</th><th>Oficio</th><th>Salario</th></tr>
      </thead>
      <tbody>
        @for (fila of filas(); track fila.empId) {
          <tr>
            <td>{{ fila.empId }}</td>
            <td>{{ fila.apellidos }}, {{ fila.nombre }}</td>
            <td>{{ fila.oficio }}</td>
            <td>{{ fila.salario }} €</td>
          </tr>
        }
      </tbody>
    </table>
    <p>
      Mostrando {{ filas().length }} de {{ actual.datos.total }}.
      Masa salarial: {{ masaSalarial() }} €
    </p>
  }
}
/* app.css */
:host {
  display: block;
  max-width: 760px;
  margin: 1.5rem auto;
  font-family: 'Segoe UI', Arial, sans-serif;
  color: #37474f;
}

h1 {
  color: #1a237e;
  font-size: 1.5rem;
}

.acciones {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

button {
  padding: 0.5rem 1rem;
  border: 1px solid #283593;
  border-radius: 6px;
  background: #e8eaf6;
  color: #1a237e;
  cursor: pointer;
}

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

th,
td {
  padding: 0.5rem;
  border-bottom: 1px solid #c5cae9;
  text-align: left;
}

th {
  background: #e8eaf6;
  color: #1a237e;
}

.error {
  padding: 0.75rem;
  border-left: 4px solid #ff7043;
  background: #fbe9e7;
}

Al pulsar Cargar aparece la tabla:

Aplicación Angular con el título Empleados de EMPRESA, dos botones y una tabla con seis empleados ordenados por salario, y la masa salarial debajo

Figura 7. El estado listo: la plantilla solo llega a actual.datos dentro de su @case.

Y con Provocar un error se pide un empleado que no existe:

La misma aplicación, con un mensaje de error 404 que indica que el empleado 9999 no se ha encontrado

Figura 8. El estado error, con el mensaje que devuelve la API a través de ErrorHttp.

Lo interesante de este ejemplo son las conexiones entre tipos:

  • signal<EstadoCarga<Pagina<Empleado>>> necesita el tipo a mano: el valor inicial { tipo: 'inactivo' } no dice qué datos llegarán.
  • computed<Fila[]> acepta actual.datos.contenido, que es Empleado[], porque Fila es un subconjunto de Empleado. Es tipado estructural.
  • El compilador de Angular estrecha también en la plantilla. Dentro de @case ('listo'), actual tiene datos. Por eso se usa @let: el estrechamiento funciona sobre una variable, y no sobre la llamada estado(), que podría devolver cada vez algo distinto.
  • cargar es protected: lo usa la plantilla. ejecutar es private: es un detalle interno.

Si intentas leer una propiedad que no corresponde al caso, la aplicación no compila. Esto es lo que ves en el navegador durante ng serve si escribes {{ actual.mensaje }} dentro de @case ('listo'):

Error de compilación de Angular en el navegador: TS2339, la propiedad mensaje no existe en el tipo

Figura 9. El error de tipos de una plantilla aparece en la *build, antes de que nadie pueda pulsar nada.*


14. Errores frecuentes y cómo leerlos

Un mensaje de TypeScript tiene siempre la misma forma, y conviene leerlo de abajo arriba cuando es largo: la última línea suele señalar la causa concreta.

departamento.ts(15,7): error TS2741: Property 'presupuesto' is missing in type '{ deptId: number; nombre: string; localidad: string; }' but required in type 'Departamento'.

Es decir: fichero, línea y columna, código y descripción. Los códigos sirven para buscar ayuda, y TS2741 encontrará más que el texto del mensaje.

Código Mensaje resumido Causa habitual Solución
TS2322 Type ‘X’ is not assignable to type ‘Y’ Un valor del tipo equivocado Corrige el valor o el tipo
TS2345 Argument of type ‘X’ is not assignable… Igual, en una llamada Revisa el parámetro
TS2339 Property ‘x’ does not exist on type ‘Y’ Propiedad inexistente o unión sin estrechar Corrige el nombre o estrecha antes
TS2551 Property ‘x’ does not exist. Did you mean ‘y’? Errata Acepta la sugerencia
TS2741 Property ‘x’ is missing in type… Falta una propiedad obligatoria Añádela o hazla opcional
TS2353 Object literal may only specify known properties Propiedad de más en un literal Quítala o añádela al tipo
TS18047 ‘x’ is possibly ‘null’ Falta comprobar null Usa if, ?. o ??
TS18048 ‘x’ is possibly ‘undefined’ Falta comprobar undefined Usa if, ?. o ??
TS18046 ‘x’ is of type ‘unknown’ Se usa un unknown sin comprobar Estrecha con typeof o instanceof
TS7006 Parameter ‘x’ implicitly has an ‘any’ type Parámetro sin anotar Anótalo
TS2564 Property ‘x’ has no initializer Propiedad sin valor Dale un valor o usa ?
TS2729 Property ‘x’ is used before its initialization Orden de inicialización Reordena o usa inject()
TS2341 Property ‘x’ is private Acceso a un private Hazlo protected
TS4114 This member must have an ‘override’ modifier Falta override Añádelo
TS4111 Property comes from an index signature Acceso con punto Usa x['clave']
TS2307 Cannot find module ‘x’ Ruta mal escrita o paquete sin instalar Revisa la ruta
TS2304 Cannot find name ‘x’ Falta un import o el tipo en types Importa, o revisa types

Y los fallos de concepto que se repiten en quien empieza:

Error Por qué ocurre Qué hacer
Poner any para quitar un error El error sigue ahí, oculto Usa unknown y estrecha
Abusar de ! Es una promesa sin comprobar Comprueba con if
Confiar en as con datos de una API No valida nada Valida en la frontera
Declarar Date en un modelo de JSON JSON solo trae cadenas Usa string y convierte al mostrar
Escribir if (x) con números Descarta el 0 Compara con null
Copiar interfaces casi iguales Se desincronizan Deriva con Omit, Pick o Partial
Usar interface como valor No existe en ejecución Usa una clase o un InjectionToken (módulo 08)
Mutar lo que hay dentro de una señal readonly no lo impide, y la señal no se entera Crea un objeto nuevo (módulo 05)

15. Ejercicios

Resuélvelos en tu carpeta practica-ts y comprueba con npx tsc.

Ejercicio 1: Tipar funciones

✏️ Ejercicio: Escribe tres funciones tipadas: salarioAnual(salario, pagas = 14), nombreVisible(nombre, apellidos?), que devuelva "apellidos, nombre" o solo nombre, y maximo(...valores), que devuelva el mayor de los números o undefined si no hay ninguno.

Ver solución
function salarioAnual(salario: number, pagas = 14): number {
  return salario * pagas;
}

function nombreVisible(nombre: string, apellidos?: string): string {
  return apellidos ? `${apellidos}, ${nombre}` : nombre;
}

function maximo(...valores: number[]): number | undefined {
  return valores.length > 0 ? Math.max(...valores) : undefined;
}

console.log(salarioAnual(2000), salarioAnual(2000, 12));
console.log(nombreVisible('Ana'), nombreVisible('Ana', 'García'));
console.log(maximo(1, 9, 4), maximo());
28000 24000
Ana García, Ana
9 undefined

El retorno de maximo incluye undefined, y eso obliga a quien la llama a tratar el caso de la lista vacía. Sin esa comprobación, Math.max() devolvería -Infinity.

Ejercicio 2: Corregir errores de strict

✏️ Ejercicio: Este código no compila con strict. Corrígelo sin usar any, ! ni as. Si no hay departamento debe devolver 'Sin asignar'.

interface Dep { id: number; nombre: string }
const deps: Dep[] = [{ id: 10, nombre: 'DIRECCIÓN' }];

function nombreDe(id) {
  const d = deps.find((x) => x.id === id);
  return d.nombre;
}
Ver solución
interface Dep {
  id: number;
  nombre: string;
}

const deps: Dep[] = [{ id: 10, nombre: 'DIRECCIÓN' }];

function nombreDe(id: number): string {
  const d = deps.find((x) => x.id === id);
  return d?.nombre ?? 'Sin asignar';
}

console.log(nombreDe(10), nombreDe(99));
DIRECCIÓN Sin asignar

Había dos errores: id tenía un any implícito (TS7006), y find puede devolver undefined (TS18048). ?. protege el acceso y ?? da el valor alternativo.

Ejercicio 3: Notificaciones con unión discriminada

✏️ Ejercicio: Define Notificacion, una unión con tres casos: exito (con texto), aviso (con texto y segundos) y fallo (con codigo numérico). Escribe renderizar(n), que devuelva una cadena para cada caso, con comprobación de que no falta ninguno.

Ver solución
type Notificacion =
  | { nivel: 'exito'; texto: string }
  | { nivel: 'aviso'; texto: string; segundos: number }
  | { nivel: 'fallo'; codigo: number };

function renderizar(n: Notificacion): string {
  switch (n.nivel) {
    case 'exito':
      return `OK: ${n.texto}`;
    case 'aviso':
      return `Aviso (${n.segundos} s): ${n.texto}`;
    case 'fallo':
      return `Error ${n.codigo}`;
    default: {
      const imposible: never = n;
      return imposible;
    }
  }
}

console.log(renderizar({ nivel: 'exito', texto: 'Guardado' }));
console.log(renderizar({ nivel: 'aviso', texto: 'Sesión a punto de caducar', segundos: 30 }));
console.log(renderizar({ nivel: 'fallo', codigo: 404 }));
OK: Guardado
Aviso (30 s): Sesión a punto de caducar
Error 404

Si añades un cuarto nivel a la unión, la línea const imposible: never = n dejará de compilar y te avisará.

Ejercicio 4: Agrupar con genéricos

✏️ Ejercicio: Escribe agruparPor(elementos, clave), que reciba una lista de objetos y el nombre de una propiedad, y devuelva un Map con cada valor de esa propiedad y sus elementos. El tipo de las claves del Map debe ser el de la propiedad, y una clave inexistente debe dar error.

Ver solución
function agruparPor<T, K extends keyof T>(elementos: readonly T[], clave: K): Map<T[K], T[]> {
  const grupos = new Map<T[K], T[]>();
  for (const elemento of elementos) {
    const valor = elemento[clave];
    const grupo = grupos.get(valor);
    if (grupo) {
      grupo.push(elemento);
    } else {
      grupos.set(valor, [elemento]);
    }
  }
  return grupos;
}

const plantilla = [
  { nombre: 'Ana', deptId: 10 },
  { nombre: 'Luis', deptId: 20 },
  { nombre: 'Eva', deptId: 20 },
];

const porDepartamento = agruparPor(plantilla, 'deptId');   // Map<number, ...>
console.log(porDepartamento.get(20)?.map((e) => e.nombre));
console.log(porDepartamento.size);
[ 'Luis', 'Eva' ]
2

T[K] es lo que da precisión: las claves del Map son number porque deptId lo es.

Ejercicio 5: DTO derivados

✏️ Ejercicio: A partir de Producto de modelos.ts, define sin repetir propiedades: ProductoNuevo (sin productoId), CambiosProducto (todo opcional, pero sin productoId), ResumenProducto (productoId, nombre y precio) y ErroresProducto (cada campo de ProductoNuevo puede tener un mensaje). Escribe también resumir(p).

Ver solución
import type { Producto } from './modelos';

type ProductoNuevo = Omit<Producto, 'productoId'>;
type CambiosProducto = Partial<ProductoNuevo>;
type ResumenProducto = Pick<Producto, 'productoId' | 'nombre' | 'precio'>;
type ErroresProducto = Partial<Record<keyof ProductoNuevo, string>>;

function resumir({ productoId, nombre, precio }: Producto): ResumenProducto {
  return { productoId, nombre, precio };
}

const portatil: Producto = {
  productoId: 1, nombre: 'Portátil UltraBook 14', descripcion: 'Ligero', categoriaId: 2,
  precio: 899, stock: 25, fechaAlta: '2024-01-10', descatalogado: false,
};

const cambios: CambiosProducto = { precio: 849 };
const errores: ErroresProducto = { precio: 'Debe ser positivo' };

console.log(resumir(portatil), cambios, errores);
{ productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899 } { precio: 849 } { precio: 'Debe ser positivo' }

Si mañana se añade un campo a Producto, los cuatro tipos se actualizan solos.

Ejercicio 6: Repositorio abstracto en memoria

✏️ Ejercicio: Crea una clase abstracta genérica Repositorio<T> con todos(), buscar(id), guardar(elemento) y el método abstracto idDe(elemento). Haz que guardar sustituya el elemento si ya existe. Crea RepositorioDepartamentos, y evita que quien llame a todos() pueda modificar la lista interna.

Ver solución
abstract class Repositorio<T> {
  private elementos: T[] = [];

  protected abstract idDe(elemento: T): number;

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

  buscar(id: number): T | undefined {
    return this.elementos.find((e) => this.idDe(e) === id);
  }

  guardar(elemento: T): void {
    const id = this.idDe(elemento);
    const existe = this.buscar(id) !== undefined;
    this.elementos = existe
      ? this.elementos.map((e) => (this.idDe(e) === id ? elemento : e))
      : [...this.elementos, elemento];
  }
}

interface Departamento {
  deptId: number;
  nombre: string;
}

class RepositorioDepartamentos extends Repositorio<Departamento> {
  protected override idDe(d: Departamento): number {
    return d.deptId;
  }
}

const repositorio = new RepositorioDepartamentos();
repositorio.guardar({ deptId: 10, nombre: 'DIRECCIÓN' });
repositorio.guardar({ deptId: 10, nombre: 'GERENCIA' });
repositorio.guardar({ deptId: 20, nombre: 'VENTAS' });

console.log(repositorio.todos().length, repositorio.buscar(10)?.nombre);
2 GERENCIA

readonly T[] impide push a quien recibe la lista. Observa también que guardar crea un array nuevo en lugar de modificarlo, el estilo que necesitarás con señales. En la subclase hace falta override porque implementa un método declarado en la base.

Ejercicio 7: Un contador protegido de verdad

✏️ Ejercicio: Crea una clase ContadorStock que guarde unidades en un campo #unidades, que no pueda quedar en negativo y que tenga retirar(n) (lanza un error si no hay suficientes) y un getter unidades. Comprueba que JSON.stringify no muestra el campo.

Ver solución
class ContadorStock {
  #unidades: number;

  constructor(iniciales: number) {
    if (iniciales < 0) {
      throw new RangeError('El stock inicial no puede ser negativo');
    }
    this.#unidades = iniciales;
  }

  get unidades(): number {
    return this.#unidades;
  }

  retirar(cantidad: number): void {
    if (cantidad > this.#unidades) {
      throw new RangeError(`Solo quedan ${this.#unidades} unidades`);
    }
    this.#unidades -= cantidad;
  }
}

const stock = new ContadorStock(5);
stock.retirar(3);
console.log(stock.unidades, JSON.stringify(stock));

try {
  stock.retirar(10);
} catch (e) {
  console.log(e instanceof RangeError ? e.message : 'Otro error');
}
2 {}
Solo quedan 2 unidades

Al exponer solo un getter, nadie puede asignar stock.unidades, y la regla de negocio queda dentro de la clase.

Ejercicio 8: Decorador @Reintentar

✏️ Ejercicio: Escribe una fábrica de decoradores de método Reintentar(veces) que, si un método síncrono lanza un error, lo vuelva a llamar hasta veces intentos y, si sigue fallando, relance el último error. Compila con tsc.

Ver solución
function Reintentar(veces: number) {
  return function (_objetivo: object, _nombre: string, descriptor: PropertyDescriptor): void {
    const original = descriptor.value as (...args: unknown[]) => unknown;

    descriptor.value = function (this: unknown, ...args: unknown[]): unknown {
      let ultimoError: unknown;
      for (let intento = 1; intento <= veces; intento++) {
        try {
          return original.apply(this, args);
        } catch (e) {
          ultimoError = e;
          console.log(`Intento ${intento} fallido`);
        }
      }
      throw ultimoError;
    };
  };
}

class Conexion {
  llamadas = 0;

  @Reintentar(3)
  abrir(): string {
    this.llamadas++;
    if (this.llamadas < 3) {
      throw new Error('Sin respuesta');
    }
    return 'Conectado';
  }
}

console.log(new Conexion().abrir());
Intento 1 fallido
Intento 2 fallido
Conectado

this: unknown es un parámetro falso que solo declara el tipo de this: se borra al compilar. Sin él, noImplicitThis protestaría.

Ejercicio 9: Validar pedidos de la API

✏️ Ejercicio: Escribe esPedido(valor), un predicado que compruebe pedidoId, total, estado (uno de los válidos) y que lineas sea un array. Úsalo con la respuesta real de /api/pedidos/5001, y muestra el total por líneas.

Ver solución
import type { EstadoPedido, Pedido } from './modelos';

const ESTADOS: readonly EstadoPedido[] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];

function esEstado(valor: unknown): valor is EstadoPedido {
  return typeof valor === 'string' && (ESTADOS as readonly string[]).includes(valor);
}

function esPedido(valor: unknown): valor is Pedido {
  if (typeof valor !== 'object' || valor === null) {
    return false;
  }
  const o = valor as Record<string, unknown>;
  return (
    typeof o['pedidoId'] === 'number' &&
    typeof o['total'] === 'number' &&
    esEstado(o['estado']) &&
    Array.isArray(o['lineas'])
  );
}

const respuesta = await fetch('http://localhost:3000/api/pedidos/5001');
const cuerpo: unknown = await respuesta.json();

if (esPedido(cuerpo)) {
  for (const linea of cuerpo.lineas) {
    console.log(`${linea.cantidad} x ${linea.producto}: ${linea.importe} €`);
  }
  console.log(`Total: ${cuerpo.total} € (${cuerpo.estado})`);
} else {
  console.log('La respuesta no es un pedido válido');
}
1 x Portátil Gaming 16: 1499 €
2 x Ratón inalámbrico: 39.8 €
2 x Monitor 24 FHD: 264.1 €
Total: 1802.9 € (ENTREGADO)

Hay un detalle en esEstado. includes en un readonly EstadoPedido[] solo admite EstadoPedido, y aquí tenemos un string. Por eso se amplía el array a readonly string[], una conversión siempre segura.

Ejercicio 10: Generar el contrato de un servicio

✏️ Ejercicio: Define un tipo ServicioDe<T, N>, donde N es el nombre de una entidad como 'Empleado', que produzca los métodos listarEmpleado(): Promise<T[]> y borrarEmpleado(id: number): Promise<void> usando plantillas de texto. Implementa un objeto que lo cumpla.

Ver solución
type ServicioDe<T, N extends string> = {
  [K in `listar${N}`]: () => Promise<T[]>;
} & {
  [K in `borrar${N}`]: (id: number) => Promise<void>;
};

interface Departamento {
  deptId: number;
  nombre: string;
}

const servicio: ServicioDe<Departamento, 'Departamento'> = {
  listarDepartamento: async () => [{ deptId: 10, nombre: 'DIRECCIÓN' }],
  borrarDepartamento: async (id) => {
    console.log(`Borrado ${id}`);
  },
};

console.log((await servicio.listarDepartamento())[0]?.nombre);
await servicio.borrarDepartamento(10);
DIRECCIÓN
Borrado 10

K recorre una unión de un solo elemento, generada con la plantilla de texto. Es la técnica que usan algunas librerías para generar nombres de método, como withEntities de NgRx, que verás en el módulo 19.

Ejercicio 11 (propuesto): Filtros de empleados

✏️ Ejercicio: Define FiltrosEmpleado, un tipo con q, orden, pagina, tamano y deptId, todos opcionales, en el que orden solo admita 'salario', '-salario', 'nombre' o '-nombre'. Escribe crearConsulta(filtros), que devuelva la cadena de consulta de la URL con URLSearchParams. Pruébala contra la API.

Ejercicio 12 (propuesto): Departamentos en el caso práctico

✏️ Ejercicio: Amplía el caso práctico de la sección 13 con un selector de departamento. Carga la lista desde /api/departamentos con su propia señal EstadoCarga, y filtra los empleados con el parámetro deptId. Reutiliza EstadoCarga<T> y pedir<T> sin modificarlos.


Resumen

Concepto API Idea clave
Borrado de tipos tsc Los tipos solo existen al compilar; no hay ninguno en ejecución
Inferencia const x = 1 Anota parámetros y retornos públicos, y deja inferir el resto
Valor desconocido unknown Mejor que any: obliga a comprobar
Imposible never Conjunto vacío, útil para detectar casos olvidados
Literales 'ADMIN' \| 'USER', as const Listas cerradas de valores, preferibles a enum
Forma de un objeto interface Importa la forma y no el nombre: tipado estructural
Alias type Uniones y transformaciones
Valores vacíos ?., ??, ! Comprueba siempre que puedas; ! no comprueba nada
Estrechamiento typeof, in, instanceof El compilador sigue el flujo del programa
Unión discriminada { tipo: 'listo'; datos: T } Hace imposibles los estados ilegales
Predicado x is T Enseña al compilador una comprobación propia
Comprobar sin perder precisión satisfies Valida y conserva el tipo exacto
Aserción as T No convierte ni comprueba: úsala con cuidado
Genéricos <T>, extends, keyof Conectan la entrada con la salida
Utility types Partial, Pick, Omit, Record Deriva tipos en lugar de copiarlos
Tipos mapeados [K in keyof T] Un for de tipos
Visibilidad private, protected Solo en compilación; el curso usa protected para la plantilla
Privado real #campo Lo impone el motor de JavaScript
Sobrescritura override, abstract Angular exige override
Inicialización Propiedades antes que constructor Declara primero lo que otros usan
Decorador @Component Función aplicada a una clase, que Angular resuelve al compilar
Solo tipos import type No deja rastro en el JavaScript final
Configuración strict, strictTemplates Activos por defecto en 22; escríbelos en la LTS
Datos externos Validar con predicados Un tipo no valida lo que llega de una API