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,neveryvoid - Modelar datos con
interfaceytype, y razonar con tipado estructural - Combinar tipos con uniones e intersecciones, y estrecharlos con
typeof,in,instanceofy 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,abstractyoverride, 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 typey alias de rutas - Leer el
tsconfig.jsonde Angular y saber qué comprueba cada opción destrict - Tipar datos asíncronos y validar lo que llega de una API
- Leer las declaraciones
.d.tsde 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:
letyconst, 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).
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.
tscemite 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ónEmpleadono 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 dejang newen 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
typescripta mano a una versión mayor de la que pide tu Angular: la ordenng updateya 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 estraduce 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. Connpx tsc --watchel 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.
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:
anyes contagioso. Si una función devuelveany, todo lo que hagas con ese valor pierde la comprobación, y el error reaparece lejos.JSON.parsedevuelveany, igual queresponse.json(). Cuando no sepas qué tipo poner, escribeunknown: 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:
readonlysolo 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 reasignarlineas, pero sí hacerlineas.push(...). Si quieres proteger también el contenido, usareadonly 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, noDate. Al leer JSON llega una cadena, y unDateen la interfaz sería mentira: el compilador lo aceptaría yempleado.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
readonlyse reserva a los identificadores. Declara una intención: unempIdno 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:
typeofyinstanceoffuncionan porque se ejecutan de verdad. No existex instanceof EmpleadosiEmpleadoes unainterface, 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
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
esEstadoPedidodevolviese siempretrue, 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);
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,Readonlyy compañía solo actúan en el primer nivel. SiPedidocontienelineas,Partial<Pedido>hace opcionallineas, 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:
privatees de TypeScript: lo comprueba el compilador y se borra. En ejecución es una propiedad normal.#campoes 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
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
privatefalla con TS2341. Si usas la LTS: declaraprotectedlo 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.
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:
ɵfaces la fábrica: la función que crea la instancia.ɵcmpes 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 typeuna clase que vayas a inyectar coninject(MiServicio), porque ahí la clase se usa como valor en ejecución. Es la diferencia entre unainterface, que no existe en ejecución, y unaclass, 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 apaths. En TypeScript 6baseUrlestá obsoleto y produce el error TS5101. Enpathsusa 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
strictestá activo sin escribirlo, y por esong newya no lo incluye en eltsconfig.json. En la LTS (Angular 21, TypeScript 5.9) el valor por defecto esfalse. Si usas la LTS: mantén"strict": trueencompilerOptions, que ya lo escribeng 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.
strictTemplatesqueda activo sin escribirlo, yng newya no lo incluye en eltsconfig.json. Si usas la LTS: déjalo en"angularCompilerOptions"como"strictTemplates": true, que es lo que escribeng newen 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
baseUrly escribirtypesystrictde 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:

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:

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[]>aceptaactual.datos.contenido, que esEmpleado[], porqueFilaes un subconjunto deEmpleado. Es tipado estructural.- El compilador de Angular estrecha también en la plantilla. Dentro de
@case ('listo'),actualtienedatos. Por eso se usa@let: el estrechamiento funciona sobre una variable, y no sobre la llamadaestado(), que podría devolver cada vez algo distinto. cargaresprotected: lo usa la plantilla.ejecutaresprivate: 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'):

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 solonombre, ymaximo(...valores), que devuelva el mayor de los números oundefinedsi 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 usarany,!nias. 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(contexto),aviso(contextoysegundos) yfallo(concodigonumérico). Escriberenderizar(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 unMapcon cada valor de esa propiedad y sus elementos. El tipo de las claves delMapdebe 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
Productodemodelos.ts, define sin repetir propiedades:ProductoNuevo(sinproductoId),CambiosProducto(todo opcional, pero sinproductoId),ResumenProducto(productoId,nombreyprecio) yErroresProducto(cada campo deProductoNuevopuede tener un mensaje). Escribe tambiénresumir(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>contodos(),buscar(id),guardar(elemento)y el método abstractoidDe(elemento). Haz queguardarsustituya el elemento si ya existe. CreaRepositorioDepartamentos, y evita que quien llame atodos()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
ContadorStockque guarde unidades en un campo#unidades, que no pueda quedar en negativo y que tengaretirar(n)(lanza un error si no hay suficientes) y un getterunidades. Comprueba queJSON.stringifyno 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 hastavecesintentos y, si sigue fallando, relance el último error. Compila contsc.
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 compruebepedidoId,total,estado(uno de los válidos) y quelineassea 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>, dondeNes el nombre de una entidad como'Empleado', que produzca los métodoslistarEmpleado(): Promise<T[]>yborrarEmpleado(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 conq,orden,pagina,tamanoydeptId, todos opcionales, en el queordensolo admita'salario','-salario','nombre'o'-nombre'. EscribecrearConsulta(filtros), que devuelva la cadena de consulta de la URL conURLSearchParams. 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/departamentoscon su propia señalEstadoCarga, y filtra los empleados con el parámetrodeptId. ReutilizaEstadoCarga<T>ypedir<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 |