Parte 1: TypeScript sin framework¶
Proyecto: 01-typescript-intro/. Esta página es una parte de la
guía de estudio; allí están el mapa general y la ruta
recomendada. La Parte 2, sobre Angular, está en 02-bases.
Introducción: qué es TypeScript¶
El problema que resuelve¶
JavaScript no revisa tipos hasta que el código se ejecuta. Un error como
llamar .toUpperCase() sobre un número sólo aparece cuando esa línea corre,
quizás en producción.
// JavaScript: no avisa nada al escribirlo
const hp = 39
hp.toUpperCase() // falla al ejecutar: un número no tiene toUpperCase
TypeScript agrega un sistema de tipos que se revisa antes de ejecutar. El mismo error aparece en el editor, mientras escribes. En esta guía, runtime significa «cuando el código ya se está ejecutando».
Definición simple¶
TypeScript es un superset de JavaScript: es JavaScript con tipos encima.
Casi todo el JavaScript que ya conoces también es TypeScript válido. Los tipos
sirven para dos cosas: saber si el código tiene errores antes de
ejecutarlo, y que se lea mejor (con ver hp: number ya sabes qué guarda esa
variable).
Cómo funciona por dentro¶
En este proyecto trabajan dos herramientas, y cada una hace una cosa distinta.
tsc son las siglas de T*ypeScript C*ompiler: el compilador
de TypeScript. Es el programa oficial del lenguaje y viene dentro del paquete
typescript de npm. Sabe hacer dos trabajos:
- Revisar los tipos: lee todo el proyecto y avisa de cada error.
- Generar JavaScript: convierte los archivos
.tsen archivos.js.
Aquí sólo se usa para lo primero (la opción noEmit de tsconfig.json apaga
lo segundo). Se ejecuta con npx tsc; npx corre un programa instalado en el
proyecto sin tener que instalarlo en toda la computadora.
Vite no es una sigla: es «rápido» en francés, y se pronuncia «vit». Es una herramienta de desarrollo que también hace dos trabajos:
- Servidor de desarrollo (
npm run dev): muestra la aplicación en el navegador y la actualiza al instante cada vez que guardas un archivo. - Empaquetador (
npm run build): junta y comprime todo el código en pocos archivos listos para publicar, en la carpetadist/.
Para ser rápido, Vite convierte TypeScript a JavaScript sin revisar los tipos: simplemente los quita. Por eso hacen falta las dos herramientas.
archivo.ts
|
+--> tsc ............ revisa tipos, informa errores (no genera archivos: noEmit)
|
+--> Vite ........... quita los tipos y entrega JavaScript al navegador
|
v
el navegador ejecuta JavaScript
Idea clave: los tipos se borran al compilar (type erasure). Son como las
notas a lápiz en un borrador: ayudan mientras escribes, pero no salen en la
versión final. En el navegador no queda ningún string, interface ni
type. Por eso TypeScript no puede revisar los datos que llegan cuando la
aplicación ya está corriendo (la respuesta de una API, lo que alguien escribe
en un formulario). Para eso hace falta código de verdad: una función que
compruebe el dato (type guard) o una librería de validación.
Babel, transpiladores y por qué aquí no hacen falta¶
Los navegadores sólo entienden JavaScript, y los más viejos sólo entienden JavaScript viejo. Un transpilador (o transformador) es un programa que traduce código a otro código equivalente: de JavaScript moderno a uno más antiguo, o de TypeScript a JavaScript.
Babel es el transpilador más conocido. Nació para poder escribir JavaScript moderno y que igual funcionara en navegadores viejos. Se arma con plugins y presets: uno por cada cosa que quieres traducir, y hay que configurarlos a mano.
JavaScript moderno --Babel--> JavaScript que entiende un navegador viejo
TypeScript --tsc---> JavaScript (la versión que pidas en "target")
Con TypeScript ese paso extra no hace falta: el compilador ya sabe traducir la
sintaxis moderna a la versión de JavaScript que le indiques en target.
Escribes con las características nuevas del lenguaje y sale código compatible,
sin instalar ni configurar Babel.
Ojo: traducir sintaxis no es lo mismo que agregar funciones que el navegador
no tiene. Si usas un método nuevo (por ejemplo array.at(-1)) en un navegador
que no lo trae, ningún transpilador lo inventa; para eso existen los
polyfills, que se explican a continuación.
En este repositorio quien traduce es Vite, con su transpilador integrado
(tsc sólo revisa). En Angular lo hace el CLI, que por dentro también usa
Vite para el servidor de desarrollo: tampoco configuras nada.
Polyfills¶
Un polyfill es un pedazo de código que le agrega a un navegador una función que todavía no trae. El nombre viene de Polyfilla, una marca de masilla para tapar agujeros en la pared: un polyfill «rellena el hueco» de lo que le falta al navegador.
Funciona así: al cargar la página, el polyfill pregunta «¿este navegador ya tiene esta función?». Si la tiene, no hace nada. Si no, la escribe usando JavaScript que el navegador sí entiende.
// Simplified polyfill for Array.prototype.at
if (!Array.prototype.at) {
Array.prototype.at = function (index) {
const position = index < 0 ? this.length + index : index
return this[position]
}
}
['a', 'b', 'c'].at(-1) // 'c', even in a browser that never had .at()
Transpilador o polyfill: cuál resuelve qué. La diferencia está en si lo nuevo es una forma de escribir o una función que existe:
| Lo nuevo es... | Ejemplos | Lo resuelve |
|---|---|---|
| Sintaxis (una forma de escribir) | a?.b, a ?? b, =>, class, async/await |
Un transpilador |
| Una función u objeto que no existe | array.at(), Promise, fetch(), structuredClone() |
Un polyfill |
Un navegador viejo ni siquiera puede leer a?.b: falla antes de ejecutar
nada, así que hay que reescribirlo (transpilar). En cambio array.at(-1) se
lee sin problema; lo que falla es que .at no existe, y eso se arregla
agregándolo (polyfill).
Ejemplos de polyfills conocidos:
Promiseyfetch(): durante años no existieron en Internet Explorer, y casi todo proyecto cargaba un polyfill para cada uno.Array.prototype.includes(),Object.entries(),String.prototype.padStart(): métodos que fueron llegando versión a versión.structuredClone()yArray.prototype.at(): más recientes; faltan en navegadores de hace pocos años.- core-js: la librería más usada. Trae polyfills para casi todo el JavaScript moderno, y se puede cargar sólo la parte que necesitas.
Qué no puede hacer un polyfill. No todo se puede imitar con JavaScript. Si la función depende de algo que el navegador no tiene por dentro (acceso a la cámara, una API de gráficos nueva), no hay polyfill posible.
TypeScript no agrega polyfills. La opción target de tsconfig.json sólo
traduce sintaxis, y la opción lib sólo le dice al compilador qué funciones
dar por existentes para no marcar error. Si escribes array.at(-1) con
lib: ["ES2023"], compila; que funcione en el navegador es asunto tuyo.
En este repositorio no hay polyfills: se apunta a navegadores modernos
(target: "es2023"). En Angular las versiones antiguas traían un archivo
polyfills.ts lleno de imports de core-js para soportar Internet Explorer;
hoy sólo soporta navegadores modernos y en angular.json la lista
polyfills suele tener una sola entrada, zone.js.
El precio de un polyfill es que es código extra que el navegador descarga. Agrega sólo los que necesiten los navegadores que de verdad quieres soportar.
Cómo se aplica en este repositorio¶
npm run devlevanta Vite. Vite no revisa tipos: si hay un error de tipos, la página carga igual.npx tsces quien revisa tipos. Ejecútalo seguido.- TypeScript 6.0 activa el modo
strictpor defecto, aunquetsconfig.jsonno lo diga. Eso incluyenoImplicitAnyystrictNullChecks. noUnusedLocalsestá activo: una variable de ejemplo que no se usa rompe el build. Por eso cada tópico termina conexport { ... }.
Comparación con Angular¶
Angular está escrito en TypeScript y usa el mismo compilador, al que le suma el suyo propio para revisar también los templates HTML. Todo lo que aprendas aquí lo vas a usar tal cual en componentes, servicios y formularios.
Errores comunes¶
- Creer que si la página carga, el código no tiene errores de tipos. Vite no
revisa tipos: usa
npx tsc. - Creer que una anotación valida datos en runtime.
const user: User = await res.json()no comprueba nada; sólo le dice al compilador que confíe.
Resumen¶
TypeScript revisa antes de ejecutar y desaparece al ejecutar. tsc revisa,
Vite compila.
Preguntas de entrevista (senior)¶
1. Si los tipos se borran en runtime, ¿cómo garantizas que la respuesta de una API cumple una interfaz?¶
Respuesta:
No se puede con tipos solos. Hay que comprobar el dato cuando llega, justo en
la entrada de la aplicación: con un type guard escrito a mano
(function isUser(x: unknown): x is User) o con una librería de esquemas
(Zod, Valibot) que genere el tipo a partir del esquema, así el tipo y la
validación nunca dicen cosas distintas. Una vez comprobado en la entrada, el
resto de la aplicación puede confiar en el tipo.
2. ¿Qué diferencia hay entre revisar tipos y transpilar? ¿Por qué Vite no revisa tipos?¶
Respuesta:
Transpilar es quitar la sintaxis de TypeScript y producir JavaScript; se puede
hacer archivo por archivo, muy rápido (esbuild, SWC, Oxc). Revisar tipos
requiere entender el programa completo, porque un tipo puede venir de otro
archivo. Vite separa las dos tareas para que el servidor de desarrollo sea
rápido; la revisión se hace con tsc (en el editor, en CI o en npm run build).
3. ¿Qué activa strict y por qué conviene en un proyecto nuevo?¶
Respuesta:
Agrupa varias revisiones, entre ellas noImplicitAny (prohíbe parámetros sin
tipo que caen en any), strictNullChecks (null y undefined dejan de
ser asignables a cualquier tipo) y strictPropertyInitialization. En un
proyecto nuevo cuesta poco activarlo; en uno existente se activa de a una
opción para no tener miles de errores juntos. Angular genera proyectos con
strict activo.
4. ¿Qué es isolatedModules / verbatimModuleSyntax y por qué existen?¶
Respuesta:
Las dos existen por la misma razón: las herramientas que convierten archivo
por archivo (como Vite) no ven los demás archivos, así que no pueden saber si
un import trae un tipo o un valor real. isolatedModules hace que tsc avise
cuando escribes algo que esas herramientas no pueden convertir mirando un solo
archivo. verbatimModuleSyntax va un paso más allá: obliga a escribir
import type { X } para los tipos, y así la herramienta puede borrar ese
import sin mirar nada más. Este repositorio tiene activo
verbatimModuleSyntax.
Configuración del proyecto: package.json, package-lock.json y tsconfig.json¶
Tres archivos en la raíz de 01-typescript-intro/ deciden qué se instala,
qué versión exacta se usa y cómo se revisa el código. Todo proyecto de
JavaScript o TypeScript los tiene (o equivalentes), incluidos los de Angular.
package.json qué necesita el proyecto y qué comandos tiene (lo editas tú)
package-lock.json qué versión EXACTA quedó instalada de cada paquete (lo escribe npm)
tsconfig.json cómo revisa TypeScript el código (lo editas tú)
node_modules/ los paquetes instalados (no se sube a git)
package.json: la ficha del proyecto¶
{
"name": "typescript-intro",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
| Campo | Qué hace |
|---|---|
name, version |
Identifican el paquete. Sólo importan si se publica en npm |
private: true |
Impide publicarlo en npm por accidente (npm publish falla) |
type: "module" |
Los archivos .js del proyecto se tratan como módulos ES (import/export) |
scripts |
Comandos con nombre; se ejecutan con npm run <nombre> |
devDependencies |
Paquetes que se usan para desarrollar y compilar, no dentro de la app |
scripts. npm run dev ejecuta vite, buscando el programa dentro de
node_modules/.bin (por eso no hace falta instalar Vite en toda la
computadora). En build, el && significa "ejecuta lo segundo sólo si lo
primero terminó bien": si tsc encuentra un error de tipos, vite build no
se ejecuta y no se genera dist/.
dependencies y devDependencies. Este proyecto sólo tiene
devDependencies, porque TypeScript y Vite se usan para trabajar, pero no
viajan dentro de la aplicación final. En un proyecto Angular, en cambio,
@angular/core o rxjs van en dependencies porque forman parte de la app,
y @angular/cli o typescript en devDependencies.
Rangos de versión. El símbolo delante del número indica qué versiones acepta npm al instalar:
| Escrito | Acepta | Traducción |
|---|---|---|
6.0.2 |
sólo 6.0.2 |
Versión fija |
~6.0.2 |
>=6.0.2 <6.1.0 |
Sólo correcciones (el último número) |
^8.3.0 |
>=8.3.0 <9.0.0 |
Correcciones y funciones nuevas, no cambios mayores |
^0.2.3 |
>=0.2.3 <0.3.0 |
Con versión 0.x, ^ se comporta como ~ |
Las versiones siguen la idea de semver (MAYOR.MENOR.PARCHE): un cambio
de MAYOR puede romper tu código, MENOR agrega cosas, PARCHE corrige errores.
Por eso el ^ es el valor por defecto de npm. TypeScript usa ~ porque no
sigue semver estrictamente: sus versiones "menores" (5.8, 5.9, 6.0…) suelen
traer revisiones nuevas que pueden marcar errores en código que antes
compilaba.
En este proyecto, ~6.0.2 instaló 6.0.3, y ^8.3.0 instaló 8.3.2.
¿Dónde quedó anotado? En el lockfile.
package-lock.json: la foto exacta de lo instalado¶
package.json dice "acepto cualquier Vite 8"; package-lock.json dice
"instalé exactamente Vite 8.3.2, descargado de esta dirección, con este
hash". npm lo crea y lo actualiza solo.
"node_modules/vite": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
"integrity": "sha512-...",
"dev": true
}
version: la versión exacta elegida dentro del rango.resolved: de dónde se descargó.integrity: un hash del archivo descargado. Si alguien altera el paquete en el camino, el hash no coincide y npm se niega a instalarlo.- Todo el árbol, no sólo lo directo. El proyecto pide 2 paquetes, pero el lockfile registra 41: también las dependencias de esas dependencias.
- Paquetes por plataforma. Vite usa rolldown, que trae una versión
compilada para cada sistema. El lockfile anota 15 variantes (Windows, macOS,
Linux, Android…) marcadas como opcionales, pero en esta computadora sólo se
instaló
@rolldown/binding-win32-x64-msvc.
Por qué se sube a git. Sin lockfile, dos personas que instalan el mismo
día de diferencia pueden obtener versiones distintas (dentro del rango), y
aparece el clásico "en mi máquina funciona". Con lockfile, todos instalan lo
mismo. node_modules/, en cambio, no se sube: se reconstruye a partir del
lockfile.
npm install o npm ci:
| Comando | Qué hace | Cuándo |
|---|---|---|
npm install |
Instala respetando el lockfile; si package.json cambió, lo actualiza |
Al trabajar en tu máquina |
npm ci |
Borra node_modules e instala exactamente el lockfile; falla si no coincide con package.json |
En CI, Docker o para reproducir un error |
Reglas prácticas: no edites el lockfile a mano, y si tiene un conflicto de
merge, resuelve package.json y vuelve a ejecutar npm install para que lo
regenere.
tsconfig.json: las reglas del compilador¶
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM"],
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"experimentalDecorators": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
Acepta comentarios (/* ... */), aunque un JSON común no los permita.
Qué JavaScript se espera y en qué entorno corre:
| Opción | Valor | Qué significa |
|---|---|---|
target |
es2023 |
Hasta qué versión de JavaScript traducir la sintaxis. Ver Polyfills en la Introducción |
module |
esnext |
Usar import/export modernos en el resultado |
lib |
ES2023, DOM |
Qué funciones da por existentes: las de JavaScript 2023 y las del navegador (document, window) |
types |
vite/client |
Agrega los tipos de Vite: import.meta.env y poder importar imágenes o CSS |
include |
src |
Qué carpeta revisa tsc |
Modo bundler (el código lo procesa Vite, no tsc):
| Opción | Qué significa |
|---|---|
moduleResolution: bundler |
Resuelve imports como lo hace un empaquetador: permite rutas sin extensión (from './06-function-destructuring') |
allowImportingTsExtensions |
Permite también escribir la extensión (from './a.ts'). Exige noEmit |
verbatimModuleSyntax |
Los imports de tipos deben llevar type. Ver Módulo 7 |
moduleDetection: force |
Todo archivo es un módulo, aunque no tenga import/export. Ver Módulo 1, el caso de name |
noEmit |
tsc sólo revisa; no genera archivos .js |
allowArbitraryExtensions |
Permite importar archivos de cualquier extensión si existe su archivo de tipos (estilos.d.css.ts) |
skipLibCheck |
No revisa los archivos de tipos (.d.ts) de node_modules: compila más rápido |
Revisiones extra (linting):
| Opción | Qué marca como error |
|---|---|
noUnusedLocals |
Variables declaradas y nunca usadas. Por eso las lecciones terminan con export { ... } |
noUnusedParameters |
Parámetros de función que no se usan |
noFallthroughCasesInSwitch |
Un case con código que sigue al siguiente sin break ni return |
Lo que no aparece, pero cuenta:
strict: no está escrito, pero TypeScript 6.0 lo activa por defecto. IncluyenoImplicitAnyystrictNullChecks.erasableSyntaxOnly: el proyecto original lo traía activo y se quitó a propósito para poder usarenum.
Decoradores: experimentalDecorators: true se agregó para el Módulo 10.
Sin esta opción, Vite deja los @decorador sin traducir y la página falla en
el navegador. Con ella usa la versión antigua de los decoradores, la misma que
usa Angular.
Comparación con Angular¶
Un proyecto Angular tiene los mismos tres archivos, con algunas diferencias:
package.jsontienedependencies(los paquetes@angular/*,rxjs) y scripts como"start": "ng serve"y"build": "ng build".- La configuración de TypeScript se reparte: un
tsconfig.jsonbase y otros que lo extienden (tsconfig.app.jsonpara la aplicación,tsconfig.spec.jsonpara las pruebas). tsconfig.jsontiene además una secciónangularCompilerOptions, que configura el compilador de templates de Angular (por ejemplo,strictTemplatespara revisar tipos dentro del HTML).angular.jsones el equivalente a la configuración de Vite: dice cómo compilar, servir y probar.
Errores comunes¶
- Subir
node_modules/a git, o no subirpackage-lock.json. - Editar
package-lock.jsona mano. - Usar
npm installen CI: puede actualizar el lockfile en lugar de fallar. - Creer que
libagrega funciones al navegador. Sólo le dice a TypeScript que existen. - Instalar con
npm install -gherramientas que el proyecto ya declara: se ejecutan connpxo connpm run.
Resumen¶
package.json declara qué se necesita y en qué rango; package-lock.json
registra la versión exacta instalada y se sube a git; tsconfig.json define
cómo revisa TypeScript. En este proyecto tsc sólo revisa y Vite compila.
Preguntas de entrevista (senior)¶
1. ¿Qué diferencia práctica hay entre dependencies y devDependencies en una aplicación frontend?¶
Respuesta:
En una aplicación que se empaqueta (Angular, Vite), el bundle final incluye lo que el código importa, sin importar en qué sección esté el paquete; el empaquetador no lee esas secciones. La separación importa en otros lugares:
- Librerías publicadas en npm: quien instala tu librería recibe tus
dependencies, pero no tusdevDependencies. - Servidores y Docker:
npm ci --omit=devinstala sólodependencies, para que la imagen sea más chica. - Auditorías de seguridad y licencias: las herramientas suelen separar lo que llega a producción de lo que sólo se usa para construir.
Por claridad, igual conviene clasificar bien: lo que usa la app en
dependencies, las herramientas en devDependencies.
2. Si package.json dice ^8.3.0, ¿qué garantiza el lockfile y qué no?¶
Respuesta:
^8.3.0 acepta cualquier 8.x desde 8.3.0. El lockfile fija la versión
elegida (8.3.2) y la de todas las dependencias transitivas, con su hash.
Mientras se instale con npm ci, todas las máquinas y el CI usan exactamente
el mismo árbol.
No garantiza:
- Que esa versión no tenga errores: sólo que es la misma para todos.
- Nada a quien instale tu paquete como librería: npm ignora el
package-lock.jsonde las dependencias. Sólo respeta el del proyecto raíz (o unnpm-shrinkwrap.json). - Que
npm installno lo cambie: sipackage.jsonse modificó,npm installvuelve a resolver y reescribe el lockfile.
3. ¿Cuándo usarías npm ci en lugar de npm install?¶
Respuesta:
npm ci en todo entorno automático (CI, Docker, deploy) y para reproducir
exactamente el estado de otra persona. Borra node_modules, instala tal cual
el lockfile, nunca lo modifica, y falla si el lockfile no coincide con
package.json. Ese fallo es útil: avisa que alguien cambió package.json y
no subió el lockfile actualizado. npm install es para el día a día en tu
máquina, cuando agregas o actualizas dependencias y quieres que el lockfile
cambie.
4. skipLibCheck: true: ¿qué ganas y qué arriesgas?¶
Respuesta:
Ganas velocidad: tsc deja de revisar todos los archivos .d.ts, que en
node_modules pueden ser miles. También evita errores que no puedes
arreglar, como dos librerías con tipos incompatibles entre sí. Arriesgas no
enterarte de errores dentro de esos archivos de tipos, incluidos los .d.ts
propios del proyecto, y que aparezcan como comportamientos raros (un tipo que
termina siendo any). Es el valor por defecto en las plantillas de Vite y
Angular; la práctica común es dejarlo activo en aplicaciones y desactivarlo en
librerías que publican sus propios .d.ts.
5. moduleResolution: "bundler" vs "nodenext": ¿cuándo cada uno?¶
Respuesta:
Definen cómo TypeScript encuentra el archivo de un import, y deben imitar a
quien realmente va a cargar el código:
bundler: para código que procesa un empaquetador (Vite, esbuild, webpack, Angular CLI). Permite rutas relativas sin extensión (from './utils'), porque el empaquetador las completa.nodenext(onode16): para código que ejecuta Node.js directamente como módulos ES, por ejemplo un servidor o una herramienta de línea de comandos. Node exige la extensión en las rutas relativas, y por eso TypeScript también la exige (from './utils.js', aunque el archivo seautils.ts).
Elegir mal produce código que compila pero no carga: con bundler en un
proyecto de Node, los imports sin extensión fallan al ejecutar.
6. ¿Qué diferencia hay entre target, lib y module?¶
Respuesta:
target: hasta qué versión de JavaScript se traduce la sintaxis del resultado (?.,class,async). Si es viejo,tscreescribe esas construcciones.lib: qué funciones y objetos asume TypeScript que existen (Array.prototype.at,document). No agrega código: si el entorno no los tiene, hace falta un polyfill.module: en qué formato se escriben los imports y exports del resultado (esnextpara módulos ES,commonjspara elrequireantiguo de Node).
Un error común es subir lib para que compile algo como array.at() sin
comprobar que los navegadores objetivo lo soportan: tsc deja de quejarse,
pero el error aparece en runtime.
Módulo 1: Tipos básicos, literales y uniones¶
Archivo: 01-typescript-intro/src/topics/01-basic-types.ts
El problema que resuelve¶
Una variable que guarda la vida de un personaje debería aceptar números y
quizás algunos estados fijos ('FULL', 'DEAD'), pero no cualquier texto.
Sin tipos, hpPoints = 'FUL' (con un error de tipeo) pasaría sin aviso.
Definición simple¶
- Anotación de tipo: decirle al compilador qué tipo tiene algo
(
const name: string). - Tipos primitivos:
string,number,boolean,bigint,symbol,null,undefined. - Tipo literal: un tipo que acepta un único valor exacto (
'FULL'y nada más). - Unión (
|): se lee «o».number | stringes «un número o un texto». - Alias de tipo (
type): ponerle nombre a un tipo para no repetirlo.
El código del repositorio¶
const name: string = 'Titus'
const isAlive: boolean = true
type LifeStatus = 'FULL' | 'DEAD' | 'INJURED'
let hpPoints: number | LifeStatus = 39
hpPoints = 'FULL'
LifeStatuses una unión de tres tipos literales. Sólo esos tres textos son válidos.hpPointsacepta cualquiernumbero uno de esos tres textos.hpPoints = 'FUL'daría error entsc.
Por qué una unión, y no un enum ni una interface¶
Los comentarios del archivo plantean tres preguntas de diseño.
1. ¿Unión o tipo literal? No son opciones distintas: se combinan. Cada
texto ('FULL') es un tipo literal, y | los une:
'FULL' tipo literal: acepta un solo valor
'FULL' | 'DEAD' | 'INJURED' unión de tres tipos literales -> LifeStatus
number | LifeStatus unión de number con esa unión -> tipo de hpPoints
Un tipo literal solo (let hp: 'FULL') sería demasiado estricto: la variable
sólo podría valer 'FULL'. La unión es lo que permite elegir entre varios
valores.
2. ¿Por qué no un enum? La razón más concreta se ve en el propio
archivo. Con un enum de textos, la línea hpPoints = 'FULL' deja de
compilar:
enum LifeStatusEnum { FULL = 'FULL', DEAD = 'DEAD', INJURED = 'INJURED' }
let hpPoints: number | LifeStatusEnum = 39
hpPoints = 'FULL' // error: el texto 'FULL' no es un valor del enum
hpPoints = LifeStatusEnum.FULL // así sí
Un enum de textos sólo acepta sus propios miembros, aunque el texto sea
idéntico. Con la unión, el valor es un string común y corriente. Otras
diferencias: la unión no genera código en runtime (desaparece al compilar) y
el enum sí genera un objeto. La comparación completa está en la pregunta 4.
3. ¿Por qué type y no interface? Porque LifeStatus no describe un
objeto. Una interface sólo puede describir formas de objetos (con sus
propiedades y métodos); no puede nombrar una unión ni un primitivo. type
puede nombrar cualquier tipo:
type LifeStatus = 'FULL' | 'DEAD' | 'INJURED' // unión: sólo con type
type Hp = number // primitivo: sólo con type
interface Character { name: string; hp: Hp } // objeto: interface o type
Para objetos, la elección entre interface y type es en gran parte de
estilo, pero hay diferencias reales; están en la tabla del Módulo 2.
Cómo funciona por dentro¶
Inferencia. Si no anotas, TypeScript deduce el tipo del valor inicial:
const a = 'Titus' // tipo: 'Titus' (literal: un const no puede cambiar)
let b = 'Titus' // tipo: string (se "ensancha": un let puede cambiar)
A esto se le llama widening (ensanchar): pasar de un valor exacto a un tipo
más amplio. En el archivo, const name: string es string y no 'Titus'
porque la anotación lo ensancha a propósito.
Narrowing (estrechamiento). Es lo contrario: ir descartando opciones hasta
saber qué hay. El tipo declarado de hpPoints es
number | LifeStatus, pero después de hpPoints = 'FULL' el compilador sabe
que en ese punto vale exactamente 'FULL'. TypeScript sigue el flujo del
código y estrecha el tipo:
function describe(hp: number | LifeStatus) {
if (typeof hp === 'number') {
return hp.toFixed(0) // aquí hp es number
}
return hp.toLowerCase() // aquí hp es LifeStatus
}
any, unknown y never:
unknown "puede ser cualquier cosa; demuéstrame qué es antes de usarlo" (seguro)
any "puede ser cualquier cosa; no reviso nada" (apaga el sistema)
never "esto no puede ocurrir" (ningún valor entra aquí)
Comparación con JavaScript¶
typeof existe en JavaScript y devuelve un texto en runtime. TypeScript lo
reutiliza para estrechar tipos dentro de un if. Lo que TypeScript agrega
(el tipo LifeStatus) desaparece al compilar; el typeof queda.
Detalle de este proyecto: name¶
En el navegador ya existe una variable global name (window.name). Si este
archivo no fuera un módulo, const name chocaría con ella.
moduleDetection: "force" hace que cada archivo sea un módulo con su propio
alcance, y por eso no hay conflicto.
Errores comunes¶
- Usar
anypara "salir del paso". Usaunknowny estrecha. - Anotar todo. Si el valor inicial deja claro el tipo, la inferencia basta; anota en los bordes (parámetros, retornos públicos, datos externos).
- Creer que
numberdistingue enteros de decimales. Es un único tipo de punto flotante (0.1 + 0.2 !== 0.3). Para enteros grandes existebigint.
Resumen¶
Anota lo necesario, deja que la inferencia haga el resto, y usa uniones de
literales para limitar los valores posibles. Para nombrar una unión se usa
type; interface es sólo para objetos.
Preguntas de entrevista (senior)¶
1. Diferencia entre any, unknown y never. ¿Cuándo usarías cada uno?¶
Respuesta:
any desactiva la revisión: se puede asignar a todo y usar como sea; se
contagia a lo que toca. unknown acepta cualquier valor pero no deja usarlo
hasta estrecharlo (typeof, instanceof, un type guard); es el tipo
correcto para datos externos y para catch (e). never es el tipo donde no
entra ningún valor: lo que devuelve una función que siempre lanza un error, o
el caso «imposible» de un switch que ya cubrió todas las opciones
(pregunta 3). any sólo se justifica en migraciones o con librerías
mal tipadas, y aislado.
2. ¿Qué tipo infiere TypeScript para const x = 'a' y para let x = 'a'? ¿Por qué?¶
Respuesta:
const infiere el literal 'a' porque el valor no puede cambiar. let
infiere string (widening) porque se espera reasignarlo. Lo mismo pasa con
las propiedades de un objeto: { status: 'FULL' } infiere status: string,
salvo que se use as const o se anote el tipo.
3. ¿Cómo garantizas que un switch sobre una unión cubre todos los casos?¶
Respuesta:
Con un chequeo exhaustivo usando never:
function label(s: LifeStatus): string {
switch (s) {
case 'FULL': return 'Sano'
case 'DEAD': return 'Muerto'
case 'INJURED': return 'Herido'
default: {
const unreachable: never = s
return unreachable
}
}
}
Si mañana se agrega 'POISONED' a LifeStatus, s deja de ser never en el
default y el compilador marca el error en todos los switch que faltan.
4. Unión de literales vs enum vs objeto as const: ¿qué eliges y por qué?¶
Respuesta:
- Unión de literales: cero código en runtime, se lee bien, buen autocompletado. No da una lista de valores para iterar.
enum: genera un objeto real en runtime (se puede iterar), pero es sintaxis propia de TypeScript que no se puede borrar sin más; herramientas comoerasableSyntaxOnlyo el modo de "sólo quitar tipos" de Node la rechazan. Unenumde textos no acepta el texto literal ('FULL'), sóloLifeStatusEnum.FULL, lo que complica recibir datos de una API. Unenumnumérico rechaza un literal fuera de rango (const e: E = 42da error desde TypeScript 5.0), pero sigue aceptando cualquier variable de tiponumber.const STATUS = { FULL: 'FULL', ... } as const+type Status = typeof STATUS[keyof typeof STATUS]: da valores en runtime y un tipo derivado, sin sintaxis especial.
En código nuevo suele preferirse la unión o el objeto as const; enum sigue
siendo común en código Angular existente. En este repositorio enum está
permitido a propósito.
5. ¿Qué cambia strictNullChecks?¶
Respuesta:
Sin él, null y undefined se pueden asignar a cualquier tipo, y
user.name.length compila aunque user pueda ser null. Con él, hay que
declararlo (string | null) y estrecharlo antes de usarlo (if (user),
user?.name, ??). Es la opción que más errores reales evita.
6. ¿Se puede escribir LifeStatus con una interface? ¿Qué cosas sólo puede hacer type?¶
Respuesta:
No. Una interface sólo describe objetos, y ni siquiera puede extender una
unión: interface X extends LifeStatus {} da
An interface can only extend an object type or intersection of object types with statically known members.
Sólo type puede nombrar:
- Uniones (
'FULL' | 'DEAD') y primitivos (type Hp = number). - Tuplas (
type Point = [number, number]). - Tipos mapeados (
type ReadonlyCharacter = { readonly [K in keyof Character]: Character[K] }). - Tipos condicionales (
type IsString<T> = T extends string ? true : false). - Tipos derivados de valores (
type Status = typeof STATUS[keyof typeof STATUS]).
A cambio, sólo interface permite la fusión de declaraciones (declaration
merging). Por eso una regla práctica común es: interface para formas de
objetos que otros pueden extender, type para todo lo demás.
Módulo 2: Arrays tipados e interfaces¶
Archivo: 01-typescript-intro/src/topics/02-object-interface.ts
El problema que resuelve¶
Un personaje tiene varias propiedades (nombre, vida, habilidades). Sin una
descripción de esa forma, nada impide escribir geralt.hp = 'cien', olvidar
hometown o equivocarse en el nombre de una propiedad.
Definición simple¶
- Array tipado:
string[]es una lista donde todos los elementos sonstring. - Interfaz (
interface): describe la forma de un objeto, es decir, qué propiedades tiene y de qué tipo. - Propiedad opcional (
?): puede estar o no estar.
El código del repositorio¶
const skills: string[] = ['Bash', 'Counter', 'Healing', 'Magic', 'Stealth']
interface Character {
name: string
hp: number
skills: string[]
hometown: string
age?: number
}
const geralt: Character = {
name: 'Geralt of Rivia',
hp: 100,
skills: ['Bash', 'Counter'],
hometown: 'Rivia'
}
geralt.age = 100
- Sin la anotación,
['Bash', true, 42]se infiere como(string | number | boolean)[]. La anotaciónstring[]obliga a que todos sean texto. geraltno tieneageal crearse, y es válido porqueagees opcional.geralt.age = 100funciona aunquegeraltseaconst:constimpide reasignar la variable, no modificar el objeto.
Cómo funciona por dentro¶
Tipado estructural. TypeScript compara formas, no nombres. Es la regla de
«si camina como pato y hace cuac, es un pato»: cualquier objeto con las
propiedades de Character cuenta como un Character, aunque nunca se haya
declarado así.
Nominal (Java, C#) Estructural (TypeScript)
"¿se declaró Character?" "¿tiene name, hp, skills, hometown?"
Revisión de propiedades sobrantes. Si escribes un objeto literal
directamente donde se espera un Character, TypeScript rechaza propiedades
que no existen en la interfaz:
const c: Character = { ..., extra: 1 } // error: 'extra' no existe en Character
const raw = { ..., extra: 1 }
const c2: Character = raw // sin error: raw ya no es un literal "fresco"
La revisión sólo aplica a objetos escritos ahí mismo, entre llaves (lo que se llama un literal «fresco»), porque en ese caso una propiedad de más casi siempre es un error de tipeo. Con una variable intermedia, el tipado estructural acepta el objeto porque tiene todo lo que pide la interfaz.
Opcional no es lo mismo que undefined:
interface A { age?: number } // la propiedad puede faltar
interface B { age: number | undefined } // la propiedad debe existir, aunque valga undefined
Comparación con Angular¶
En Angular las interfaces se usan para describir los datos: la respuesta de
una API (http.get<Character[]>(...)), las entradas de un componente
(@Input() character!: Character o input<Character>()) y los modelos de
formularios. Recuerda que en http.get<Character[]> la interfaz no valida
nada en runtime (ver la Introducción).
interface o type¶
interface Character { name: string }
type Character2 = { name: string }
Para objetos, las dos sirven casi igual. Diferencias que importan:
interface |
type |
|---|---|
| Se puede declarar dos veces y se fusiona | Un nombre, una declaración |
Se extiende con extends |
Se combina con & (intersección) |
| Sólo describe objetos (y funciones, clases) | Describe cualquier tipo: uniones, tuplas, etc. |
Errores comunes¶
- Pensar que
consthace el objeto inmutable. Para eso existereadonly(readonly name: string,readonly string[]) oObject.freezeen runtime. - Usar
any[]porque "el array tiene de todo". Si los elementos son de tipos distintos con posiciones fijas, es una tupla:[string, number]. - Dejar todo opcional para evitar errores. Cada
?obliga a revisarundefineden todos los lugares donde se usa.
Resumen¶
Las interfaces describen formas; TypeScript compara formas, no nombres. Los literales escritos directamente reciben una revisión extra de propiedades.
Preguntas de entrevista (senior)¶
1. interface vs type: ¿cuándo usas cada uno?¶
Respuesta:
type es obligatorio para uniones, tuplas, tipos mapeados y condicionales.
Para formas de objetos ambos sirven; muchas guías prefieren interface porque
los errores son más legibles, extends falla temprano cuando hay propiedades
incompatibles (una intersección & puede producir never en silencio) y el
compilador trabaja más rápido con interfaces en proyectos grandes. interface además
permite declaration merging, útil para extender tipos de librerías
(por ejemplo, agregar propiedades a Window). Lo importante es ser
consistente en el proyecto.
2. ¿Por qué const c: Character = { ..., extra: 1 } falla pero pasando por una variable intermedia no?¶
Respuesta:
Por la revisión de propiedades sobrantes (excess property checking), que
sólo aplica a literales de objeto "frescos" asignados directamente a un tipo.
Con una variable intermedia rige el tipado estructural normal: el objeto tiene
todo lo que Character exige, así que es asignable. Es una ayuda para
detectar errores de tipeo, no una garantía de que el objeto no tenga
propiedades extra; al ejecutarse puede tenerlas.
3. ¿Qué es el tipado estructural y cómo simularías tipado nominal?¶
Respuesta:
Dos tipos son compatibles si sus formas lo son, sin importar el nombre. Esto
causa problemas cuando dos conceptos tienen la misma forma (UserId y
OrderId, ambos string). Se simula tipado nominal con branded types, que
consisten en pegarle al tipo una etiqueta que sólo existe para el compilador:
type UserId = string & { readonly __brand: 'UserId' }
const toUserId = (s: string) => s as UserId
Así un OrderId no se puede pasar donde se espera un UserId.
4. ¿Diferencia entre age?: number y age: number | undefined?¶
Respuesta:
Con ? la propiedad puede faltar del todo; con | undefined debe estar
presente, aunque sea con valor undefined. Por defecto TypeScript también
permite asignar undefined explícito a una propiedad ?; con
exactOptionalPropertyTypes eso se prohíbe, y la diferencia importa para
'age' in obj, Object.keys o al serializar a JSON.
5. const, readonly y Object.freeze: ¿qué protege cada uno?¶
Respuesta:
const impide reasignar la variable, no cambiar el objeto. readonly (y
ReadonlyArray<T> / readonly T[]) impide modificar en tiempo de compilación,
pero desaparece en runtime y es superficial (las propiedades anidadas siguen
siendo mutables). Object.freeze protege en runtime y también es superficial.
En Angular, tratar los datos como inmutables importa para la detección de
cambios con OnPush y con signals: si mutas un objeto en lugar de crear uno
nuevo, la referencia no cambia y la vista puede no actualizarse.
Módulo 3: Funciones y parámetros¶
Archivo: 01-typescript-intro/src/topics/03-functions.ts
El problema que resuelve¶
Una función es un contrato: recibe ciertos datos y devuelve un resultado. En
JavaScript nada impide llamar addNumbers('5', 10) (devuelve '510', un
texto) u olvidar un argumento (el resultado es NaN). TypeScript revisa ese
contrato en cada llamada.
Definición simple¶
- Tipo de parámetro: qué recibe la función (
a: number). - Tipo de retorno: qué devuelve (
): number). - Parámetro opcional (
b?: number): se puede omitir; si se omite valeundefined. - Parámetro por defecto (
base: number = 2): si se omite, toma ese valor. void: la función no devuelve nada útil.
Funciones tradicionales¶
function addNumbers(a: number, b: number): number {
return a + b
}
const result = addNumbers(5, 10)
console.log({ result }) // { result: 15 }
Parámetros sin tipo. TypeScript no deduce el tipo de un parámetro a
partir de cómo se llama la función, así que function addNumbers(a, b) {}
deja a y b como any implícito. En este proyecto eso es un error, no
sólo una mala práctica: strict exige ponerles tipo.
void no es undefined. Una función sin return, como
function addNumbers(a, b) {}, tiene retorno void. En runtime devuelve
undefined, pero para el compilador void significa "no uses este
resultado", y no se puede asignar a una variable de tipo undefined:
const noop = () => {}
const r: undefined = noop() // error: void no es lo mismo que undefined
console.log({ result }). No es TypeScript, es una abreviatura de
JavaScript moderno (ES2015): { result } equivale a { result: result }. Se
usa para que la consola muestre el nombre de la variable junto a su valor.
Funciones flecha¶
const addNumbersArrow = (a: number, b: number): number => a + b
const addNumbersArrowToString = (a: number, b: number): string => {
return `The result is: ${a + b}`
}
- Con una sola expresión, el
returnes implícito (=> a + b). Con llaves hay que escribirreturn. - Las comillas invertidas son un template literal:
${a + b}inserta el resultado dentro del texto. - La diferencia importante con
functionesthis: una función flecha no tiene su propiothis, usa el del lugar donde se escribió. Ver la pregunta 1.
Parámetros obligatorios, opcionales y por defecto¶
Si una función declara tres parámetros, hay que pasar los tres:
function multiply(firstNumber: number, secondNumber: number, base: number): number {
return firstNumber * secondNumber * base
}
multiply(2) // error: faltan dos argumentos
La versión del repositorio hace opcional el segundo y le da un valor por defecto al tercero:
function multiply(firstNumber: number, secondNumber?: number, base: number = 2): number {
return firstNumber * (secondNumber ?? 1) * base
}
multiply(2) // 2 * 1 * 2 = 4
parámetro si se omite dentro de la función
firstNumber error number
secondNumber? undefined number | undefined -> por eso el ?? 1
base = 2 toma 2 number
??(nullish coalescing) usa el valor de la derecha sólo si el de la izquierda esnulloundefined.- Un parámetro opcional no puede ir antes de uno obligatorio:
function f(a?: number, b: number)da error. - Un parámetro por defecto sí puede ir antes de uno obligatorio, pero
entonces deja de ser omitible: hay que pasar
undefineda mano para usar el valor por defecto (f(undefined, 2)). Por eso se ponen al final.
Funciones dentro de interfaces: métodos¶
Una interfaz puede describir también las funciones que tiene un objeto:
interface Character {
name: string
hp: number
showHp: () => void
}
const geralt: Character = {
name: 'Geralt',
hp: 70,
showHp() {
console.log(`Current HP of ${this.name} is: ${this.hp}`)
}
}
showHp: () => voiddice: "este objeto tiene una propiedadshowHpque es una función sin parámetros y cuyo resultado no se usa".- Todo objeto declarado como
Characterestá obligado a tenershowHp. Si falta,tscda error al crear el objeto, y por esogeralt.showHp()se puede llamar sin revisar si existe. - Hay dos formas de escribirlo en una interfaz: como propiedad
(
showHp: () => void) o como método (showHp(): void). Parecen iguales pero no lo son: ver la pregunta 7.
this dentro del método. showHp() { ... } es la forma corta de un
método. Al llamarlo como geralt.showHp(), this es geralt, y TypeScript
sabe que this es un Character. Si se escribiera como flecha, this no
sería el objeto:
const bad: Character = {
name: 'Geralt',
hp: 70,
showHp: () => console.log(this.name) // error: aquí this no es el objeto
}
Una flecha usa el this de afuera; en el nivel superior de un módulo ese
this es undefined.
¿Por qué interface y no type? Para un objeto, los dos sirven. Poder
extenderse o implementarse no es exclusivo de las interfaces: una clase
puede hacer implements de un type que describa un objeto
(class Sq implements Shape funciona con type Shape = { area(): number }),
y una interfaz puede hacer extends de un type. Las diferencias reales son
dos: las interfaces se pueden fusionar (dos declaraciones con el mismo
nombre se combinan en una), y extends informa mejor los conflictos entre
propiedades que una intersección (&). Usar interface para objetos es una
convención común. La tabla completa está en interface o type del Módulo 2.
Funciones que modifican objetos y early return¶
const MAX_HP = 100
const healCharacter = (character: Character, amount: number) => {
if (character.hp >= MAX_HP) { return }
character.hp = Math.min(character.hp + amount, MAX_HP)
}
healCharacter(geralt, 20) // 70 -> 90
healCharacter(geralt, 20) // 90 -> 100 (Math.min evita llegar a 110)
healCharacter(geralt, 20) // ya está en 100: early return, sigue en 100
geralt.showHp() // "Current HP of Geralt is: 100"
- El tipo atrapa errores de tipeo.
character.healtPoints += amountda error porquehealtPointsno existe enCharacter. En JavaScript esa línea crearía una propiedad nueva conNaNy no avisaría nada. - Early return (o guard clause): salir de la función al principio cuando
no hay nada que hacer, en lugar de envolver todo en un
if. Hace el código más fácil de leer; el beneficio no es de rendimiento sino de claridad. - La función recibe el mismo objeto, no una copia.
healCharacterno devuelve nada: modifica directamente elgeraltque le pasaron.
El error que tenía la primera versión. El ejemplo empezó con un tope que no funcionaba:
if (character.hp === 100) { return }
character.hp += amount
La condición sólo detenía la curación si el HP valía exactamente 100:
hp inicial 70
healCharacter(+20) 70 !== 100 -> suma -> 90
healCharacter(+20) 90 !== 100 -> suma -> 110 <- supera el máximo
La versión actual lo corrige así:
>=en lugar de===: también se detiene si el HP ya llegó por encima del máximo por otro camino.Math.min(hp + amount, MAX_HP): devuelve el menor de los dos valores, así que la suma nunca pasa del máximo.- Además,
MAX_HPreemplaza el100escrito a mano (un "número mágico"): si el máximo cambia, se cambia en un solo lugar.
TypeScript no detectó el error original: los tipos revisan qué datos usas, no si la lógica es correcta. Ambas versiones compilan igual. Para eso están las pruebas.
Detalle de este proyecto: export { }¶
El archivo termina con export { } vacío. Esa línea convierte un archivo en
módulo; en este proyecto no hace falta, porque moduleDetection: "force" ya
trata cada archivo como módulo, pero no hace daño.
Comparación con Angular¶
En Angular casi todo son métodos de clase y funciones flecha:
- Los métodos de un componente usan
thispara leer su estado. Si pasas un método como callback (setTimeout(this.save, 0)), pierde suthis; con una flecha (setTimeout(() => this.save(), 0)) lo conserva. - Los callbacks de RxJS y de signals (
map(x => x * 2),computed(() => ...)) son funciones flecha, y TypeScript infiere sus parámetros por contexto.
Errores comunes¶
- Usar
||en lugar de??para valores por defecto:secondNumber || 1convierte un0legítimo en1, ymultiply(2, 0)daría4en lugar de0. - Pensar que un parámetro opcional ya tiene valor. Dentro de la función es
number | undefinedy hay que manejar elundefined. - Escribir una flecha con llaves y olvidar el
return:(a, b) => { a + b }devuelveundefined. - Definir un método de objeto como flecha (
showHp: () => console.log(this.name)):thisno es el objeto. - Comparar con
===contra un límite (hp === 100) cuando lo que se quiere es un tope: usa>=yMath.min.
Resumen¶
Tipa siempre los parámetros; el retorno se puede inferir, pero anotarlo
documenta el contrato. Opcionales y por defecto van al final, y para valores
por defecto se usa ??. Los métodos de un objeto se escriben con la forma
corta (showHp() {}) para que this sea el objeto. Los tipos no revisan la
lógica: un tope mal escrito compila igual.
Preguntas de entrevista (senior)¶
1. ¿Qué diferencias hay entre una función flecha y una función tradicional, además de la sintaxis?¶
Respuesta:
this: la flecha toma elthisdel lugar donde se escribió (a eso se le llamathisléxico); la tradicional lo recibe según cómo se la llama. Por eso un método pasado como callback pierde suthisy una flecha no.arguments: la flecha no tiene su propio objetoarguments; se usan parámetros rest (...args).new: una flecha no se puede usar como constructor y no tieneprototype.- Hoisting: una declaración
functionse puede llamar antes de la línea donde está escrita; una flecha asignada aconstno.
En una clase, un campo flecha (save = () => {...}) se crea por cada
instancia, mientras que un método vive una sola vez en el prototipo. Es un
costo menor, pero existe.
2. ¿Por qué const cb: () => void = () => 42 compila si la función devuelve un número?¶
Respuesta:
Un tipo de función con retorno void significa "el que llama va a ignorar el
resultado", no "la función no puede devolver nada". Gracias a esa regla
funciona [1, 2].forEach(n => nums.push(n)): push devuelve un número, pero
forEach espera un callback void y simplemente lo descarta. La regla es
distinta cuando se declara una función con retorno void explícito
(function f(): void { return 42 }), que sí da error.
3. secondNumber?: number, secondNumber: number | undefined y secondNumber = 1: ¿en qué se diferencian?¶
Respuesta:
secondNumber?: number: el que llama puede omitirlo; dentro valenumber | undefined.secondNumber: number | undefined: el que llama debe pasarlo, aunque seaundefinedexplícito.secondNumber = 1: el que llama puede omitirlo y dentro siempre esnumber. Desde fuera la firma se ve igual que la opcional (secondNumber?: number).
En la mayoría de los casos el valor por defecto es lo más limpio, porque elimina
el undefined dentro de la función.
4. ¿Cuándo anotarías el tipo de retorno y cuándo dejarías que se infiera?¶
Respuesta:
La inferencia del retorno es confiable. Conviene anotarlo cuando:
- La función es parte de una API pública (exportada, un servicio): el tipo anotado es el contrato, y un cambio accidental en el cuerpo da error en la función y no en todos los lugares que la usan.
- La función es recursiva (se llama a sí misma): muchas veces TypeScript no logra deducir el retorno y da error hasta que lo anotas.
- El proyecto usa
isolatedDeclarations, que exige retornos explícitos en lo exportado para generar archivos.d.tssin revisar todo el programa.
En funciones internas cortas y callbacks, la inferencia basta.
5. ¿Qué son las sobrecargas de funciones y cuándo preferirías una unión?¶
Respuesta:
Las sobrecargas declaran varias firmas para una sola implementación, para que el tipo de retorno dependa del argumento:
function parse(value: string): number
function parse(value: number): string
function parse(value: string | number): string | number {
return typeof value === 'string' ? Number(value) : String(value)
}
Sirven cuando la relación entre entrada y salida importa al que llama. Si el
retorno es el mismo para todas las entradas, un parámetro de tipo unión
(value: string | number) es más simple. La implementación no es visible desde
fuera: sólo se pueden usar las firmas declaradas arriba. Por eso, si el
argumento es string | number, la llamada da error porque no encaja con
ninguna firma, aunque la implementación lo acepte. Una alternativa
moderna son los genéricos con tipos condicionales, que se verán más adelante.
6. ¿Cómo se determina this en JavaScript y qué hace TypeScript para ayudar?¶
Respuesta:
En una función tradicional o un método, this depende de cómo se llama,
no de dónde se escribió:
geralt.showHp():thisesgeralt.const fn = geralt.showHp; fn():thisesundefined(en módulos y modo estricto), ythis.namefalla en runtime.fn.call(otro)ofn.bind(otro):thisesotro.- En una flecha,
thises el del lugar donde se escribió.
TypeScript ayuda de dos formas: con noImplicitThis (incluido en strict)
marca usos de this cuyo tipo no conoce, y permite declarar un parámetro
falso this para exigir cómo se llama una función:
function showHp(this: Character) { ... }. Ese parámetro desaparece al
compilar. Lo que TypeScript no detecta es extraer un método de una clase y
llamarlo suelto (const fn = obj.method); para eso hay reglas de lint como
unbound-method.
7. En una interfaz, ¿qué diferencia hay entre showHp: () => void y showHp(): void?¶
Respuesta:
Se usan igual, pero el compilador las revisa distinto. En simple: con la
sintaxis de propiedad, TypeScript no te deja guardar una función que acepta
menos cosas de las que promete la interfaz; con la sintaxis de método, sí.
El nombre técnico: con strictFunctionTypes (incluido en strict), los
parámetros de una propiedad función se revisan de forma estricta
(contravariante) y los de un método de forma más permisiva (bivariante):
interface WithProp { handle: (x: string | number) => void }
interface WithMethod { handle(x: string | number): void }
const onlyString = (x: string) => console.log(x)
const p: WithProp = { handle: onlyString } // error: number no es string
const m: WithMethod = { handle: onlyString } // compila, y puede fallar en runtime
onlyString no sabe manejar números, así que el error de p es correcto; la
sintaxis de método lo deja pasar por compatibilidad con código antiguo (por
ejemplo, los métodos de Array). Por eso muchas guías de estilo recomiendan la
sintaxis de propiedad, como la de este ejemplo.
8. ¿Es mejor que healCharacter modifique el objeto o que devuelva uno nuevo?¶
Respuesta:
Modificar el objeto (mutación) es simple y no crea objetos nuevos, pero tiene
efectos a distancia: cualquier otra parte del código que tenga una referencia a
geralt ve el cambio sin haberlo pedido, y es más difícil de probar. Devolver
uno nuevo (return { ...character, hp: Math.min(character.hp + amount, MAX_HP) })
hace la función pura: misma entrada, misma salida, sin efectos.
En Angular esto importa en la práctica: con ChangeDetectionStrategy.OnPush y
con signals, la vista se actualiza cuando cambia la referencia. Si se muta
el objeto, la referencia es la misma y la pantalla puede no actualizarse. Por
eso en estado de UI se prefiere la inmutabilidad
(character.update(c => ({ ...c, hp: c.hp + 20 }))). Se puede reforzar con
readonly en la interfaz para que tsc impida la mutación.
Módulo 4: Interfaces anidadas (tarea)¶
Archivo: 01-typescript-intro/src/topics/04-homework-types.ts
El enunciado¶
Crear una interfaz
SuperHeroconname(string),age(number),address(un objeto constreet,countryycity) y un métodoshowAddressque devuelva un texto con el nombre y la dirección del héroe. Después crear un objeto que cumpla la interfaz y llamar ashowAddress.
Esta tarea junta lo visto en los módulos 2 y 3: interfaces, propiedades de tipo objeto y funciones dentro de una interfaz.
El problema que resuelve¶
Los datos reales casi nunca son planos: un héroe tiene una dirección, un pedido tiene un cliente, un cliente tiene una dirección. Hace falta describir objetos dentro de objetos, y poder reutilizar la descripción de la parte interna (la dirección) en otros lugares.
Definición simple¶
- Interfaz anidada: una propiedad cuyo tipo es otra interfaz
(
address: Address). - Composición: armar un tipo grande a partir de tipos pequeños, como piezas de Lego.
- Convención de nombres: reglas de escritura que no exige el compilador pero que todo el equipo sigue para que el código se lea igual.
La solución del repositorio¶
interface Address {
street: string
country: string
city: string
}
interface SuperHero {
name: string
age: number
address: Address
showAddress: () => string
}
const superHeroe: SuperHero = {
name: "Spiderman",
age: 30,
address: {
street: "Main St",
country: "USA",
city: "NY",
},
showAddress() {
return this.name + ", " + this.address.city + ", " + this.address.country
},
}
console.log(superHeroe.showAddress()) // Spiderman, NY, USA
SuperHero
├── name: string
├── age: number
├── address: Address ──> Address
│ ├── street: string
│ ├── country: string
│ └── city: string
└── showAddress: () => string
Cómo funciona por dentro¶
Interfaz separada o tipo en línea. La dirección se podría escribir dentro
de SuperHero:
interface SuperHero {
address: { street: string; country: string; city: string } // tipo en línea
}
Funciona igual, pero separarla en Address tiene ventajas: se reutiliza (un
Villain o un Company también tienen dirección), tiene nombre propio en los
mensajes de error y se puede usar como tipo de un parámetro
(function formatAddress(a: Address)).
Las revisiones llegan a los objetos internos. TypeScript revisa cada
nivel, también dentro de address:
address: { street: 's', country: 'c' }
// error: falta city
address: { street: 's', country: 'c', city: 'x', zip: '1' }
// error: zip no existe en Address
El tipo de retorno del método también se revisa. showAddress: () => string
obliga a devolver un texto. Si el método no tiene return, da error.
this dentro de showAddress. Igual que en el Módulo 3: con la forma
corta showAddress() { ... }, this es el objeto y TypeScript sabe que es un
SuperHero. Con una flecha, this no sería el héroe.
Convenciones de nombres¶
En TypeScript, todos los tipos se escriben en UpperCamelCase (también llamado PascalCase), no sólo las interfaces:
| Qué se nombra | Convención | Ejemplo |
|---|---|---|
Interfaces, alias type, clases, enum |
PascalCase | SuperHero, LifeStatus |
| Variables, funciones, métodos, propiedades | camelCase | showAddress, hpPoints |
| Constantes fijas de configuración | UPPER_SNAKE_CASE | MAX_HP |
Miembros de un enum |
PascalCase o UPPER_SNAKE_CASE | Status.Full, FULL |
Así, con sólo leer SuperHero sabes que es un tipo, y con superHero que es
un valor. No se usa el prefijo I (ISuperHero): la guía de estilo de
Angular y la de Google para TypeScript lo desaconsejan.
Detalles de estilo en este archivo¶
El código compila y funciona, pero tiene tres diferencias con las convenciones del resto del repositorio:
- Comillas dobles (
"Spiderman"). El repositorio usa comillas simples. superHeroemezcla inglés y español. Siguiendo la regla de código en inglés, seríasuperHero.- Concatenación con
+. Un template literal (Módulo 3) se lee mejor:
showAddress() {
return `${this.name}, ${this.address.city}, ${this.address.country}`
}
Comparación con Angular¶
Así se modelan los datos de una API en Angular: una interfaz por entidad y
otras para sus partes (Address, Order, OrderItem). Pero hay una trampa:
los datos que llegan de una API son JSON, y JSON no tiene funciones. Si
una interfaz de datos incluye un método, como showAddress, el objeto que
devuelve http.get<SuperHero>() no lo tendrá. Ver la pregunta 4.
Errores comunes¶
- Repetir la misma forma en línea en varias interfaces en lugar de extraerla a un tipo con nombre.
- Creer que
Readonly<SuperHero>oPartial<SuperHero>llegan a los objetos internos. Sólo actúan sobre el primer nivel (preguntas 2 y 3). - Poner métodos en interfaces que describen datos de una API.
Resumen¶
Las interfaces se componen: una propiedad puede tener como tipo otra interfaz, y TypeScript revisa todos los niveles. Todos los tipos van en PascalCase y todos los valores en camelCase.
Preguntas de entrevista (senior)¶
1. ¿Cuándo extraes un tipo anidado a su propia interfaz y cuándo lo dejas en línea?¶
Respuesta:
Se extrae cuando se reutiliza en más de un lugar, cuando tiene significado
propio en el dominio (una Address existe aunque no haya héroe), cuando se
usa como parámetro o retorno de una función, o cuando los mensajes de error
con el tipo en línea se vuelven ilegibles. Se deja en línea cuando es pequeño,
se usa en un solo lugar y no tiene sentido fuera de su contenedor (por
ejemplo, las opciones de una única función). Si después hace falta el tipo
interno, se puede obtener sin duplicarlo: type Address = SuperHero['address'].
2. Readonly<SuperHero>: ¿impide modificar hero.address.city?¶
Respuesta:
No. Readonly<T> es superficial: marca como readonly sólo las
propiedades del primer nivel.
const hero: Readonly<SuperHero> = superHeroe
hero.name = 'Batman' // error: name es de sólo lectura
hero.address.city = 'LA' // compila: address es readonly, pero su contenido no
Para proteger todos los niveles hay que marcar readonly también en
Address, o escribir un tipo recursivo (DeepReadonly<T>) con tipos
mapeados y condicionales. Object.freeze tiene la misma limitación en
runtime. Para estado de UI en Angular, la práctica habitual es no mutar y
crear objetos nuevos con spread en cada nivel que cambia.
3. Quieres actualizar sólo la ciudad de un héroe. ¿Sirve Partial<SuperHero>?¶
Respuesta:
No del todo, porque Partial<T> también es superficial: hace opcional
address, pero si la pasas, tiene que ser una Address completa.
const patch: Partial<SuperHero> = { address: { city: 'LA' } }
// error: Type '{ city: string; }' is missing the following properties from type 'Address': street, country
Opciones: un tipo específico para la operación
({ address?: Partial<Address> }), un DeepPartial<T> recursivo, o derivar
tipos con Pick y Omit
(type HeroSummary = Pick<SuperHero, 'name' | 'age'>). En APIs suele ser más
claro un tipo por operación (UpdateHeroAddress) que un DeepPartial
genérico, porque documenta exactamente qué se puede cambiar.
4. Una interfaz con métodos describe la respuesta de una API. ¿Qué puede salir mal?¶
Respuesta:
JSON sólo transporta datos: textos, números, booleanos, arrays y objetos. No tiene funciones. El código compila, pero falla al ejecutarse:
const hero = JSON.parse(body) as SuperHero // compila: as no valida nada
hero.showAddress() // falla al ejecutar: el método no existe
Lo mismo pasa con http.get<SuperHero>() en Angular: el genérico es sólo una
promesa al compilador. La solución es separar datos de comportamiento:
una interfaz sólo con datos para lo que viene de la API (SuperHeroDto) y
funciones aparte que trabajan sobre ella
(formatAddress(hero: SuperHeroDto): string), o una clase que se construye a
partir del DTO después de validarlo. Para validar en runtime, ver la Introducción.
5. ¿Por qué PascalCase para tipos y camelCase para valores? ¿Usarías el prefijo I?¶
Respuesta:
TypeScript separa dos mundos: el de los tipos (se borran al compilar) y el
de los valores (existen al ejecutar). Un mismo nombre puede existir en los dos (por ejemplo, una clase
es a la vez un tipo y un valor). La convención de mayúsculas hace visible en
cuál estás: SuperHero es un tipo, superHero es un valor. El prefijo I
(ISuperHero) viene de C#; la guía de estilo de Angular y la de Google para
TypeScript lo desaconsejan porque al que usa el tipo no le importa si es una interface o
un type, y si cambias uno por el otro tendrías que renombrarlo en todo el
código. Lo importante, en cualquier caso, es que el equipo sea consistente; un
linter (@typescript-eslint/naming-convention) puede exigirlo.
Módulo 5: Desestructuración de objetos y arrays¶
Archivo: 01-typescript-intro/src/topics/05-basic-destructuring.ts
El problema que resuelve¶
Para usar varias propiedades de un objeto hay que repetir su nombre una y otra vez:
console.log(audioPlayer.audioVolume, audioPlayer.songDuration, audioPlayer.details.author)
La desestructuración saca esas propiedades a variables con una sola línea, y el código que sigue queda más corto y más fácil de leer.
Definición simple¶
Desestructurar es extraer valores de un objeto (o de un array) y
guardarlos en variables, escribiendo a la izquierda del = la forma de lo que
quieres sacar. Es JavaScript (ES2015), no algo propio de TypeScript;
TypeScript sólo agrega los tipos.
El código del repositorio¶
interface Details {
author: string
year: number
}
interface AudioPlayer {
audioVolume: number
songDuration: number
songTitle: string
details: Details
}
const audioPlayer: AudioPlayer = {
audioVolume: 90,
songDuration: 36,
songTitle: 'Falling away from me',
details: { author: 'Korn', year: 1999 }
}
const songTitle = 'Freak on a Leash'
const { audioVolume, songDuration, songTitle: anotherTitle, details: { author, year } } = audioPlayer
Esa última línea crea cinco variables:
patrón variable creada valor
audioVolume audioVolume 90
songDuration songDuration 36
songTitle: anotherTitle anotherTitle 'Falling away from me'
details: { author, year } author 'Korn'
year 1999
(details NO se crea)
Cómo funciona por dentro¶
1. Extraer con el mismo nombre. { audioVolume } busca la propiedad
audioVolume y crea una variable con ese nombre. TypeScript infiere su tipo
(number) desde AudioPlayer; no hace falta anotar nada.
2. Renombrar. songTitle: anotherTitle se lee "toma la propiedad
songTitle y guárdala en una variable llamada anotherTitle". Aquí hace
falta porque ya existe una variable songTitle; sin renombrar, se declararía
dos veces:
const songTitle = 'Freak on a Leash'
const { songTitle } = audioPlayer
// error: songTitle ya existe
Cuidado: dentro de un patrón, los dos puntos renombran; no anotan el tipo.
const { songTitle: string } = audioPlayer
// NO declara un string: crea una variable llamada `string`
const { songTitle }: AudioPlayer = audioPlayer // así se anota (casi nunca hace falta)
3. Desestructuración anidada. details: { author, year } se lee "entra en
details y saca author y year". Los dos puntos aquí tampoco son un tipo:
indican en qué propiedad entrar. El patrón crea author y year, pero no
details. Por eso el archivo puede declarar details después sin conflicto.
4. La alternativa en dos pasos. El archivo muestra la misma extracción sin anidar:
const { details } = audioPlayer
const { author: songAuthor } = details
Con objetos profundos, dos líneas simples se leen mejor que un patrón anidado de tres niveles.
Desestructuración de arrays¶
Los arrays se desestructuran con corchetes y por posición, no por nombre. El nombre de cada variable lo eliges tú:
const dbz: string[] = ['Goku', 'Vegeta', 'Trunks']
const [first, second, third] = dbz // 'Goku', 'Vegeta', 'Trunks'
const [ , vegeta] = dbz // salta la posición 0: 'Vegeta'
const [ , , trunks = 'Not Found'] = ['Goku', 'Vegeta']
// la posición 2 no existe, así que usa el valor por defecto: 'Not Found'
objeto { a, b } = obj busca por NOMBRE de propiedad el orden no importa
array [a, b] = arr toma por POSICIÓN el nombre no importa
- Una coma sin nombre (
[ , vegeta]) salta esa posición. - El valor por defecto (
= 'Not Found') funciona igual que en objetos: sólo se usa si el valor esundefined, y una posición que no existe esundefined. - Un detalle de tipos: como
dbzesstring[](un array de largo desconocido), TypeScript le da afirst,secondythirdel tipostring, aunque el array podría tener menos elementos. Con una tupla (Módulo 6 y la sección de tipos de datos), TypeScript sí sabe cuántos hay.
Lo que el archivo no muestra, pero conviene saber¶
Valores por defecto. Se usan cuando la propiedad vale undefined:
const { audioVolume = 50 } = settings
Ojo: el valor por defecto sólo se aplica con undefined. Si la propiedad vale
null o 0, se queda con ese valor (ver la pregunta 2).
Resto (...). Junta en un objeto nuevo las propiedades que no extrajiste:
const { details, ...rest } = audioPlayer
// rest: { audioVolume: number; songDuration: number; songTitle: string }
TypeScript calcula el tipo de rest sin details.
En parámetros de funciones. Es el uso más común en código real:
function play({ songTitle, audioVolume = 50 }: AudioPlayer) {
console.log(`Playing ${songTitle} at ${audioVolume}%`)
}
El tipo va después del patrón completo (}: AudioPlayer), no dentro.
Intercambiar dos variables con desestructuración de arrays, sin una variable temporal:
let x = 1
let y = 2
;[x, y] = [y, x] // x = 2, y = 1
El ; al principio evita que JavaScript lea la línea como continuación de la
anterior, porque este repositorio no usa punto y coma.
Comparación con Angular¶
En Angular aparece todo el tiempo: en parámetros
(map(({ id, name }) => ...) en RxJS), al leer respuestas de API y al leer
signals (const { name } = this.user()). Con signals hay un detalle
importante: desestructurar toma una foto del valor en ese momento. Si el
signal cambia después, name no se entera. Para que se actualice, la lectura
tiene que estar dentro de un computed() o en el template.
Errores comunes¶
- Leer
{ songTitle: string }como una anotación de tipo. Es un renombre. - Creer que el patrón anidado crea también la variable del objeto padre
(
details). No la crea. - Desestructurar una propiedad anidada que puede no existir: si
detailsfuera opcional,const { details: { author } } = objfalla. TypeScript lo avisa y, si se ignora, el programa falla al ejecutar porque no se puede sacarauthorde algo que no existe. - Creer que desestructurar copia los objetos.
const { details } = audioPlayercopia la referencia: si modificasdetails.author, cambia tambiénaudioPlayer.details.author.
Resumen¶
{ a } extrae, { a: b } renombra, { a: { b } } entra en a y extrae b.
Los dos puntos dentro del patrón nunca son un tipo: el tipo va después del
patrón completo, y casi siempre se infiere solo. Los arrays se desestructuran
por posición ([a, , c]), y una coma vacía salta un elemento.
Preguntas de entrevista (senior)¶
1. ¿Cómo tipas una función que desestructura su parámetro? ¿Por qué { title: string } no funciona?¶
Respuesta:
Porque dentro de un patrón de desestructuración, : significa "renombrar":
function f({ title: string }) crea una variable llamada string y deja el
parámetro sin tipo (con strict, eso da error de any implícito). El tipo va
después del patrón completo:
function play({ songTitle, audioVolume = 50 }: AudioPlayer) { ... }
// o con un tipo en línea:
function play({ songTitle }: { songTitle: string }) { ... }
En funciones con muchas opciones es común definir una interfaz para el
parámetro (PlayOptions), porque el patrón y el tipo en línea juntos se
vuelven difíciles de leer.
2. ¿Cuándo se aplica un valor por defecto en una desestructuración? ¿Qué pasa con null?¶
Respuesta:
Sólo cuando el valor es undefined (o la propiedad no existe):
const { a = 1 } = { a: undefined } // a = 1
const { b = 1 } = { b: null } // b = null
const { c = 1 } = { c: 0 } // c = 0
Es la misma regla que en los parámetros por defecto de una función. Es
distinto de ??, que también reemplaza null. Las APIs suelen devolver
null para "sin valor", así que un default en la desestructuración no lo
cubre: hace falta const b = data.b ?? 1. TypeScript lo refleja en el tipo:
si b era number | null, sigue siendo number | null.
3. ¿Desestructurar copia el objeto? ¿Qué implicaciones tiene?¶
Respuesta:
Copia los valores de primer nivel. Para primitivos (number, string) es
una copia real; para objetos se copia la referencia:
const { details } = audioPlayer
details.author = 'X'
console.log(audioPlayer.details.author) // 'X': es el mismo objeto
Lo mismo pasa con el resto (...rest) y con el spread ({ ...obj }): son
copias superficiales. Para actualizar estado de forma inmutable (por
ejemplo, en un signal de Angular) hay que hacer spread en cada nivel que
cambia: { ...player, details: { ...player.details, author: 'X' } }. Para
copias profundas existe structuredClone().
4. ¿Cómo quitarías una propiedad de un objeto sin mutarlo, y qué tipo obtienes?¶
Respuesta:
Con desestructuración y resto:
const { details, ...withoutDetails } = audioPlayer
withoutDetails es un objeto nuevo sin details, y TypeScript le asigna un
tipo equivalente a Omit<AudioPlayer, 'details'>. Es la forma habitual de
quitar datos sensibles antes de enviar o registrar un objeto
(const { password, ...safeUser } = user). La variable extraída (details,
password) queda sin usar, pero noUnusedLocals de TypeScript no la
reporta: entiende que está ahí para excluirla del resto. Quien sí suele
avisar es ESLint (no-unused-vars), salvo que se active su opción
ignoreRestSiblings.
5. ¿Qué riesgo tiene desestructurar el valor de un signal o un observable en Angular?¶
Respuesta:
Desestructurar lee el valor una vez. const { name } = this.user() guarda
el nombre de ese momento; si el signal user cambia, name queda
desactualizado y la vista no reacciona. La lectura tiene que ocurrir donde
Angular pueda seguirla: dentro de un computed(() => this.user().name), de
un effect() o en el template. Con RxJS pasa lo mismo: se desestructura
dentro del map(({ name }) => ...), para que se ejecute con cada valor nuevo,
no una vez fuera del flujo.
Módulo 6: Parámetro objeto y tuplas de retorno¶
Archivo: 01-typescript-intro/src/topics/06-function-destructuring.ts
El problema que resuelve¶
Dos problemas comunes al diseñar funciones:
- Muchos parámetros.
taxCalculation(products, 0.15, true, 'USD')no se entiende sin abrir la función: ¿qué estrue? ¿y si cambio el orden? - Devolver más de un valor. Una función de JavaScript devuelve un solo valor. Si necesitas el total y el impuesto, hay que agruparlos.
Definición simple¶
- Parámetro objeto (options object): la función recibe un solo objeto y cada dato va con su nombre.
- Tupla: un array de largo fijo donde cada posición tiene un tipo
conocido.
[number, number]es "exactamente dos números".
El código del repositorio¶
export interface Product {
description: string
price: number
}
interface TaxCalculationOptions {
tax: number
products: Product[]
}
export function taxCalculation(options: TaxCalculationOptions): [number, number] {
const { products, tax } = options
let total = 0
products.forEach(({ price }) => {
total += price
})
return [total, total * tax]
}
const shoppingCart = [phone, tablet]
const tax = 0.15
const results = taxCalculation({ products: shoppingCart, tax })
const [total, taxAmount] = results
console.log(`Total: ${total}, Tax: ${taxAmount}`) // Total: 400, Tax: 60
entrada (un objeto con nombres) salida (una tupla por posición)
{ products: [...], tax: 0.15 } ──> [ 400 , 60 ]
│ └── índice 1: impuesto
└──────── índice 0: total
Cómo funciona por dentro¶
1. Parámetro objeto. Una regla práctica muy difundida (no es una regla de TypeScript) es no pasar de tres parámetros. Con un objeto:
- Cada valor va con su nombre en la llamada:
{ products, tax }se lee solo. - El orden deja de importar.
- Agregar una opción nueva opcional (
currency?: string) no rompe las llamadas existentes.
La función desestructura el objeto en la primera línea. También se puede hacer directamente en la firma, como en el Módulo 5:
export function taxCalculation({ products, tax }: TaxCalculationOptions): [number, number] {
2. Abreviatura de propiedades al llamar. { products: shoppingCart, tax }
no es desestructuración: está creando un objeto. tax es la forma
corta de tax: tax, porque la variable y la propiedad se llaman igual.
products necesita la forma larga porque la variable se llama
shoppingCart. Se distingue por el lado del =:
const { tax } = options a la IZQUIERDA del = -> desestructura (saca)
fn({ tax }) como VALOR -> crea un objeto (mete)
3. Desestructurar en el callback. forEach(({ price }) => ...) saca
price de cada producto. Los paréntesis son obligatorios: sin ellos, { se
leería como el cuerpo de la función.
4. Tupla de retorno. : [number, number] promete exactamente dos números
en ese orden. Ventajas sobre number[]:
const results = taxCalculation(...)
results[2] // error: la tupla sólo tiene dos posiciones
Y al desestructurar, cada variable tiene su tipo exacto. Las posiciones se
pueden nombrar para que el editor las muestre:
[total: number, tax: number]. Los detalles de las tuplas están en la sección
Tipos de datos que sigue a este módulo.
Comparación con Angular¶
- Muchas APIs de Angular usan parámetro objeto:
inject(Service, { optional: true }),signal(0, { equal: ... }),http.get(url, { params, headers }). Son ejemplos de opciones con nombre, todas opcionales. - Los inputs de un componente cumplen el mismo papel que un parámetro objeto:
<app-cart [products]="cart" [tax]="0.15" />pasa cada dato con su nombre.
Errores comunes¶
- Confundir
fn({ tax })(crear un objeto) conconst { tax } = obj(desestructurar). - Olvidar los paréntesis en
forEach({ price } => ...): es un error de sintaxis. - Devolver tuplas largas (
[number, number, string, boolean]). Con más de dos o tres posiciones, un objeto con nombres es más claro. - Hacer cálculos de dinero con decimales sin cuidado: ver la pregunta 3.
Resumen¶
Muchos datos de entrada: un objeto con nombres. Pocos datos de salida en un
orden fijo: una tupla. { tax } como valor crea un objeto; a la izquierda de
un =, desestructura.
Preguntas de entrevista (senior)¶
1. ¿Cuándo usarías un parámetro objeto y cuándo parámetros posicionales?¶
Respuesta:
Posicionales cuando son pocos (uno o dos), obligatorios y con un orden
evidente: add(a, b), clamp(value, min, max). Parámetro objeto cuando hay
muchos, cuando varios son opcionales, cuando hay booleanos (true en una
llamada no dice nada) o cuando se espera que crezcan. El costo del objeto es
un poco más de escritura y una creación de objeto por llamada, irrelevante
salvo en código muy caliente. Un diseño intermedio común: los obligatorios
como posicionales y un último parámetro options opcional, como hace
http.get(url, options).
2. ¿Tupla u objeto para devolver varios valores?¶
Respuesta:
La tupla conviene cuando son dos valores, el orden es evidente y quien llama
querrá ponerles su propio nombre al desestructurar. El ejemplo clásico es
const [count, setCount] = useState(0) de React: cada componente nombra sus
variables. El objeto conviene cuando hay más valores, cuando el significado
no es evidente por la posición, o cuando quien llama sólo necesita algunos
(const { tax } = calc()). Con una tupla [number, number] es fácil
intercambiar total e impuesto sin que tsc lo note, porque ambos son
number; con { total, tax } el nombre lo impide. Las tuplas con etiquetas
([total: number, tax: number]) mejoran el editor, pero no protegen de ese
intercambio.
3. ¿Qué problema tiene calcular precios e impuestos con number?¶
Respuesta:
number es punto flotante binario: muchos decimales no se representan
exactos (0.1 + 0.2 da 0.30000000000000004). En este ejemplo
400 * 0.15 da justo 60, pero con otros precios aparecen errores de
redondeo que se acumulan al sumar. Prácticas habituales:
- Guardar y calcular en centavos como enteros (
15000en lugar de150.0), y dividir sólo al mostrar. - Redondear en un único lugar definido (por ejemplo, al calcular el impuesto de cada línea) según la regla del negocio.
- Formatear para mostrar con
Intl.NumberFormat(o el pipecurrencyde Angular), no contoFixeddentro del cálculo. - Para importes muy grandes o cálculos financieros exigentes, una librería decimal.
4. forEach, reduce o for...of para sumar los precios: ¿cuál elegirías?¶
Respuesta:
Los tres funcionan. reduce expresa "convertir la lista en un valor" sin
variable mutable externa:
const total = products.reduce((sum, { price }) => sum + price, 0)
for...of es el más flexible: permite break, continue, return y
await. forEach no permite cortar el recorrido y no espera promesas:
forEach(async p => await save(p)) lanza todas las llamadas y sigue sin
esperarlas, un error frecuente. Para sumar, reduce es lo idiomático; para
lógica con salidas tempranas o asincronía, for...of.
5. ¿Por qué const pair = [1, 2] no es una tupla, y cómo obtienes una?¶
Respuesta:
Porque TypeScript infiere el tipo más general útil para un array literal:
number[], ya que un array suele crecer. Asignarlo después a
[number, number] da error: un array puede tener cualquier largo y la
tupla exige exactamente dos. Para tener una tupla:
- Anotar:
const pair: [number, number] = [1, 2]. - Anotar el retorno de la función, como en
taxCalculation. as const:const pair = [1, 2] as constdareadonly [1, 2](tupla de sólo lectura con tipos literales).satisfies:const pair = [1, 2] satisfies [number, number]revisa la forma y conserva la inferencia.
Tipos de datos: de los primitivos a tuplas y enums¶
Sección de referencia que complementa el Módulo 6. Repasa rápido los tipos primitivos y se detiene en los tipos compuestos que más aparecen en entrevistas y en código Angular: tuplas y enums.
Primitivos, en una tabla¶
Un primitivo es un valor simple, que no es un objeto y no se puede modificar (se reemplaza por otro).
| Tipo | Ejemplo | typeof |
Nota |
|---|---|---|---|
string |
'Goku' |
'string' |
Texto |
number |
150.5 |
'number' |
Enteros y decimales, en punto flotante |
bigint |
10n |
'bigint' |
Enteros mayores que Number.MAX_SAFE_INTEGER |
boolean |
true |
'boolean' |
true o false |
symbol |
Symbol('id') |
'symbol' |
Valor único; sirve como clave que no choca |
undefined |
undefined |
'undefined' |
"Todavía no tiene valor" |
null |
null |
'object' |
"Sin valor, a propósito" (ver nota) |
Nota: que typeof null devuelva 'object' es un error histórico de
JavaScript que nunca se corrigió. Para saber si algo es null, compara con
=== null.
Los tipos se escriben en minúscula. String, Number y Boolean (con
mayúscula) son los objetos envoltorio de JavaScript y casi nunca se usan como
tipo: const s: string = new String('a') da error.
Los tipos especiales (any, unknown, never, void) están en los Módulos
1 y 3.
De lo simple a lo compuesto¶
primitivos string number boolean bigint symbol null undefined
literales 'FULL' 42 true (Módulo 1)
uniones 'FULL' | 'DEAD' number | string (Módulo 1)
objetos interface / type { ... } (Módulos 2 y 4)
arrays string[] Array<string> readonly string[]
tuplas [number, number] [name: string, age?: number]
enums enum Direction { Up, Down }
diccionarios Record<string, number> Map<K, V> Set<T>
Arrays¶
const names: string[] = ['Goku', 'Vegeta'] // forma corta
const ages: Array<number> = [30, 35] // forma genérica, equivalente
const fixed: readonly string[] = ['a', 'b'] // sin push, pop ni asignar posiciones
Un array tiene largo variable y todos los elementos del mismo tipo (o de la misma unión).
Tuplas¶
Una tupla es un array con largo fijo y un tipo por posición:
type Coordinate = [number, number]
type Entry = [string, number]
const point: Coordinate = [10, 20]
const entry: Entry = ['Goku', 9001]
Variantes:
type Labeled = [total: number, tax: number] // posiciones con nombre (sólo para leer mejor)
type Optional = [string, number?] // la segunda posición puede faltar
type Rest = [string, ...number[]] // un string y después cualquier cantidad de números
type Frozen = readonly [number, number] // no se puede modificar
Dónde aparecen sin que las declares:
Object.entries({ a: 1 })devuelve[string, number][]: un array de tuplas clave/valor.new Map([['a', 1], ['b', 2]])recibe tuplas[clave, valor].- Los hooks de React (
useState) devuelven una tupla.
Trampa 1: un array literal no es una tupla. const p = [1, 2] se infiere
como number[]. Hay que anotarlo o usar as const (Módulo 6, pregunta 5).
Trampa 2: una tupla normal acepta push.
const t: [number, number] = [1, 2]
t.push(3) // compila: push existe en los arrays
t[2] // error: la tupla no tiene índice 2
TypeScript no bloquea los métodos que cambian el largo. Para impedirlo, usa
readonly [number, number]: ahí push da error.
Enums¶
Un enum es un conjunto de constantes con nombre. A diferencia de casi todo
TypeScript, genera código JavaScript real.
Enum numérico. Si no asignas valores, empiezan en 0 y suben de a uno:
enum Direction { Up, Down } // Up = 0, Down = 1
Se compila a esto (salida real de tsc):
var Direction;
(function (Direction) {
Direction[Direction["Up"] = 0] = "Up";
Direction[Direction["Down"] = 1] = "Down";
})(Direction || (Direction = {}));
Ese objeto tiene mapeo inverso: Direction.Up es 0 y Direction[0] es
'Up'. Por eso tiene cuatro claves, no dos:
Object.values(Direction) // ['Up', 'Down', 0, 1]
Enum de textos. Cada miembro tiene un texto explícito, y no hay mapeo inverso:
enum LifeStatusEnum { Full = 'FULL', Dead = 'DEAD' }
Es el más legible en logs y en JSON. Pero acepta sólo sus miembros:
const s: LifeStatusEnum = 'FULL' da error aunque el texto sea igual
(Módulo 1).
const enum. En teoría, el compilador reemplaza cada uso por su valor y
no genera el objeto. En la práctica, herramientas como Vite compilan archivo
por archivo y no pueden hacer ese reemplazo, así que en este proyecto se
compila igual que un enum normal. Muchos equipos lo evitan.
¿Enum, unión o as const? La comparación completa está en el Módulo 1,
pregunta 4. Resumen: en código nuevo suele preferirse una unión de literales
o un objeto as const; el enum sigue siendo común en proyectos Angular
existentes, y en este repositorio está permitido.
Diccionarios: Record, Map y Set¶
const stock: Record<string, number> = { phone: 10, tablet: 3 } // objeto con claves de texto
const prices = new Map<string, number>([['phone', 150]]) // claves de cualquier tipo, orden garantizado
const tags = new Set<string>(['new', 'sale']) // valores sin repetir
Record es un objeto común con tipo; Map y Set son clases de JavaScript.
Ver la pregunta 5 sobre una trampa de Record.
Errores comunes¶
- Usar
StringoNumber(mayúscula) como tipos. - Esperar que
const p = [1, 2]sea una tupla. - Confiar en que
[number, number]impidepush: hace faltareadonly. - Recorrer un enum numérico con
Object.keysoObject.valuesy obtener el doble de elementos.
Resumen¶
Los primitivos son siete. Una tupla es un array de largo fijo con un tipo por
posición; márcala readonly si no debe cambiar. Un enum genera código real,
con mapeo inverso si es numérico; para conjuntos de textos, una unión de
literales suele ser más simple.
Preguntas de entrevista (senior)¶
1. ¿Qué es el mapeo inverso de un enum numérico y qué problema causa?¶
Respuesta:
En un enum numérico, el objeto generado guarda las dos direcciones:
Direction.Up === 0 y Direction[0] === 'Up'. Sirve para obtener el nombre
a partir del valor (por ejemplo, para mostrarlo en un log). El problema
aparece al recorrerlo: Object.values(Direction) devuelve
['Up', 'Down', 0, 1], y Object.keys también devuelve cuatro claves. Para
quedarse sólo con los valores hay que filtrar
(Object.values(Direction).filter(v => typeof v === 'number')). Los enum
de textos no tienen mapeo inverso, así que no tienen este problema.
2. ¿Por qué t.push(3) compila si t es [number, number]?¶
Respuesta:
Porque una tupla es, en tiempo de ejecución, un array común, y su tipo hereda
los métodos de Array, incluido push. TypeScript controla los accesos por
índice (t[2] da error) pero no modela que push cambia el largo. Después de
push, el tipo sigue diciendo "dos elementos" aunque haya tres: el tipo
miente. La solución es readonly [number, number] (o as const), que quita
los métodos que modifican. En general, las tuplas deberían ser de sólo
lectura: representan un valor compuesto, no una lista que crece.
3. ¿Qué es un const enum y por qué muchos equipos lo evitan?¶
Respuesta:
Un const enum le pide al compilador que reemplace cada uso por su valor
literal (Fast.A pasa a ser 1) y que no genere el objeto del enum. Para eso
el compilador necesita ver la declaración, aunque esté en otro archivo. Las
herramientas que compilan un archivo a la vez (esbuild, SWC, Vite y Babel)
no pueden hacerlo, y TypeScript con isolatedModules o verbatimModuleSyntax
lo refleja: prohíbe usar declare const enum y deja de reemplazar los
valores. Además, si una librería publica un const enum y cambia sus valores,
el código que ya lo había reemplazado queda con los valores viejos. Por eso la
recomendación habitual es usar enum normal, una unión o un objeto
as const.
4. Tupla con etiquetas [total: number, tax: number] vs objeto { total: number; tax: number }: ¿son equivalentes?¶
Respuesta:
No. Las etiquetas de la tupla sólo documentan: aparecen en el editor y en
los mensajes de error, pero no se pueden usar para acceder (result.total no
existe; sigue siendo result[0]) y no impiden desestructurar en otro orden
(const [tax, total] = result compila). El objeto sí accede por nombre y
protege contra el intercambio. La tupla etiquetada es útil cuando por diseño
se quiere una tupla (para que quien llama elija los nombres), pero con mejor
ayuda en el editor.
5. Con Record<string, number>, ¿qué tipo tiene stock['laptop'] si esa clave no existe?¶
Respuesta:
number, aunque en runtime sea undefined. Record<string, number> dice
"cualquier clave de texto tiene un número", y TypeScript le cree. Así,
stock['laptop'].toFixed(2) compila y falla al ejecutarse. Soluciones:
- Activar
noUncheckedIndexedAccess: todo acceso por índice pasa a sernumber | undefinedy obliga a revisarlo. También afecta a los arrays (arr[0]pasa aT | undefined), por eso no está incluido enstrict. - Usar un
Mapymap.get('laptop'), que ya devuelvenumber | undefined. - Si las claves son conocidas, usar una unión:
Record<'phone' | 'tablet', number>.
Módulo 7: Módulos, import y export¶
Archivo: 01-typescript-intro/src/topics/07-import-export-modules.ts
El problema que resuelve¶
Si todo el código vive en un archivo, crece sin control y no se puede
reutilizar. Los módulos permiten repartirlo en archivos y decidir qué
comparte cada uno con los demás. En este ejemplo, 07 reutiliza la función
taxCalculation y la interfaz Product que se escribieron en 06.
Definición simple¶
- Módulo: un archivo con al menos un
importoexporten el nivel superior. Todo lo que declara es privado salvo lo que exporta. export: marca qué puede usar otro archivo.import: trae lo que otro archivo exportó.
El código del repositorio¶
En 06-function-destructuring.ts:
export interface Product { description: string; price: number }
export function taxCalculation(options: TaxCalculationOptions): [number, number] { ... }
// TaxCalculationOptions NO se exporta: es privada de 06
En 07-import-export-modules.ts:
import { taxCalculation, type Product } from './06-function-destructuring'
const shoppingCart: Product[] = [
{ description: 'Nokia A1', price: 150.0 },
{ description: 'iPad Air', price: 255.0 }
]
const [total, tax] = taxCalculation({ products: shoppingCart, tax: 0.15 })
console.log(`Total: ${total}, Tax: ${tax}`) // Total: 405, Tax: 60.75
- La ruta empieza con
./: es un archivo propio, relativo a este. Sin./(from 'rxjs') se busca un paquete ennode_modules. - La ruta va sin extensión. Funciona porque el proyecto usa
moduleResolution: "bundler": Vite busca el.tscorrespondiente.
Cómo funciona por dentro¶
1. Importar ejecuta el otro archivo. Al importar 06, su código del nivel
superior se ejecuta, incluidos sus console.log. Por eso, con 07 activo
en main.ts, la consola muestra primero lo de 06:
Total: 400, Tax: 60 <- console.log de 06 (efecto secundario del import)
Total: 400, Tax: 60 <- console.log de 06
Total: 405, Tax: 60.75 <- console.log de 07
Un módulo pensado para importarse debería sólo declarar y exportar, sin
efectos secundarios. Cada módulo se ejecuta una sola vez: si diez
archivos importan 06, sus console.log aparecen una vez.
2. type en el import. type Product marca que Product es sólo un
tipo. No reduce el tamaño del resultado (los tipos nunca llegan al
JavaScript, con o sin type), pero en este proyecto es obligatorio,
porque verbatimModuleSyntax está activo:
import { taxCalculation, Product } from './06-function-destructuring'
// error: Product es un tipo y hay que importarlo con import type
La razón: Vite compila un archivo a la vez y no puede abrir 06 para saber si
Product es un valor o un tipo. El type se lo dice, y así sabe que puede
borrarlo.
3. Dos formas de importar tipos, con una diferencia real. Salida real de
tsc:
import { type Product } from './dep' // -> import {} from './dep' (el import queda: dep se ejecuta)
import type { Product } from './dep' // -> (desaparece por completo)
Si todo lo que importas son tipos, usa import type { ... }, así el
archivo no se ejecuta sólo por importar un tipo. Si mezclas valores y tipos,
como en 07, { taxCalculation, type Product } es lo correcto.
4. El export {} vacío. En 07 está comentado y no hace falta: el archivo
ya es un módulo porque tiene un import. Además, en este proyecto
moduleDetection: "force" trata todos los archivos como módulos.
Tipos de export¶
export const tax = 0.15 // export con nombre (named)
export function calc() {} // export con nombre
export default class Cart {} // export por defecto: uno por archivo
import { tax, calc } from './a' // los nombres deben coincidir
import MyCart from './a' // el default se nombra como quieras
import { tax as vat } from './a' // renombrar al importar
export * from './a' // reexportar todo (archivos "barril", index.ts)
Comparación con Angular¶
- Cada componente, servicio y pipe es una clase exportada desde su archivo, y
se importa con un
importnormal de TypeScript. - En un componente standalone, el
importde TypeScript no alcanza: además hay que agregar el componente al arregloimports: [...]del decorador para que el template lo pueda usar. Son dos sistemas distintos: uno de TypeScript (archivos) y otro de Angular (templates). - La carga diferida (lazy loading) usa
import()dinámico:loadComponent: () => import('./cart').then(m => m.Cart). Ese archivo se descarga recién cuando se navega a la ruta. - El código que genera Angular CLI usa exports con nombre
(
export class Cart), noexport default.
Errores comunes¶
- Dejar
console.logu otro código suelto en un archivo que se importa. - Olvidar el
typeal importar una interfaz (en este proyecto da error). - Usar
import { type X }cuando sólo importas tipos: el archivo importado se ejecuta igual. - Crear dependencias circulares:
aimportabybimportaa(ver la pregunta 3).
Resumen¶
Un archivo con import o export es un módulo; lo que no exporta es privado.
Importar ejecuta el otro módulo una vez. Los tipos se importan con type, y si
sólo importas tipos, con import type { ... }.
Preguntas de entrevista (senior)¶
1. Exports con nombre o export default: ¿qué prefieres y por qué?¶
Respuesta:
Muchas guías de estilo prefieren exports con nombre, y es lo que genera Angular CLI:
- El nombre es el mismo en todo el proyecto. Con
default, cada archivo que importa puede llamarlo distinto (import Cart,import ShoppingCart), y buscar o renombrar se vuelve difícil. - El editor puede sugerir y agregar el import automáticamente.
- Un archivo puede exportar varias cosas con el mismo estilo.
default tiene sentido cuando una herramienta lo exige (algunas
configuraciones, o el import() dinámico de ciertos frameworks), o en
archivos que representan una sola cosa por convención.
2. Si diez archivos importan el mismo módulo, ¿cuántas veces se ejecuta? ¿Qué implica?¶
Respuesta:
Una sola vez. La primera importación ejecuta el módulo y el resultado queda en caché; las siguientes reciben las mismas exportaciones. Implicaciones:
- Una variable exportada (
export const cache = new Map()) es, en la práctica, un singleton compartido por toda la aplicación. - Los efectos secundarios del nivel superior (logs, suscripciones, registrar algo) ocurren una vez, al cargar, y en un orden que depende del grafo de imports. Eso los hace difíciles de controlar y de probar.
- En Angular, el estado compartido se maneja con servicios inyectables
(
providedIn: 'root') en lugar de variables de módulo: el inyector controla cuándo se crean, se pueden reemplazar en pruebas y pueden tener un alcance menor que toda la aplicación.
3. ¿Qué pasa con una dependencia circular entre módulos?¶
Respuesta:
Si a.ts importa b.ts y b.ts importa a.ts, uno de los dos se ejecuta
antes de que el otro haya terminado. Cuando b intenta usar algo de a que
todavía no se inicializó, el programa falla al ejecutar: ese valor todavía
no existe.
El código compila: tsc no detecta ciclos. Aparecen con frecuencia a través
de archivos barril (index.ts que reexportan todo). Se detectan con
herramientas como madge o la regla import/no-cycle de ESLint, y se
resuelven moviendo lo compartido a un tercer módulo del que dependan ambos,
o importando sólo tipos (import type), que desaparecen al compilar.
4. ¿Qué diferencia hay entre import { type X } e import type { X }?¶
Respuesta:
Con verbatimModuleSyntax, TypeScript borra sólo lo marcado como tipo y deja
el resto del import tal cual:
import type { X } from './x'se borra entero:./xno se carga.import { type X } from './x'borraX, pero dejaimport {} from './x':./xse carga y se ejecuta.
Si ./x tiene efectos secundarios, o crea un ciclo, la diferencia importa.
Regla: si todo son tipos, import type { ... }; si mezclas valores y tipos,
import { valor, type Tipo }.
5. ¿Cómo afectan los archivos barril (index.ts) al tamaño del bundle y a los ciclos?¶
Respuesta:
Un barril (export * from './cart', export * from './user') simplifica los
imports, pero importar una sola cosa de él obliga a cargar y evaluar todos los
archivos que reexporta. El tree shaking (eliminar código no usado) puede
quitar lo que no se usa, pero sólo si esos módulos no tienen efectos
secundarios; si los tienen, o si el bundler no puede garantizarlo, el código
se queda. Además, los barriles facilitan los ciclos: un archivo de cart
importa de ../index, que reexporta cart. Por eso muchos equipos los usan
sólo en el límite público de una librería, no dentro de la aplicación.
Módulo 8: Clases, public y private¶
Archivo: 01-typescript-intro/src/topics/08-classes.ts
El problema que resuelve¶
Hasta ahora cada objeto se escribía a mano (const geralt = { ... }). Si
necesitas muchos objetos con la misma forma y el mismo comportamiento, o
quieres que cierta información no se pueda tocar desde afuera, hace falta
un molde: una clase.
Definición simple¶
- Clase: un molde para crear objetos con las mismas propiedades y métodos.
- Instancia: cada objeto creado con el molde, usando
new. - Constructor: la función que se ejecuta al hacer
newy prepara el objeto. public/private: quién puede leer o modificar una propiedad.
El archivo usa la imagen de un cortador de galletas: la clase es el cortador, cada galleta es una instancia. Todas tienen la misma forma; cada una tiene su propia decoración (los valores de sus propiedades); y todas "saben hacer" lo mismo (los métodos, que se definen una vez en la clase).
El código del repositorio¶
export class Person {
constructor(public name: string = 'John Connor', public age: number = 13, private address: string = 'Los Angeles') {
this.name = name
this.age = age
this.address = address
}
showAddress(): string {
return this.address
}
}
const hero = new Person('Geralt of Rivia', 100, 'Kaer Morhen')
console.log(hero)
const defaultHero = new Person()
console.log(defaultHero)
//console.log(hero.address) // error: address es private
hero.showAddress() // devuelve 'Kaer Morhen', pero no lo imprime
Salida real (de Person; las otras dos clases del archivo, SuperHero y
Hero, están en las secciones Herencia y Composición):
Person { name: 'Geralt of Rivia', age: 100, address: 'Kaer Morhen' }
Person { name: 'John Connor', age: 13, address: 'Los Angeles' }
- Las propiedades se declaran dentro de los paréntesis del constructor
(
public name,private address). Es un atajo: la palabrapublicoprivatedelante del parámetro crea la propiedad y le asigna el valor. Ver la pregunta 4. Por eso las tres líneasthis.name = name, etc., en realidad sobran: repiten lo que el atajo ya hizo. - Como los tres parámetros tienen valor por defecto,
new Person()es válido y crea a John Connor. Sin esos valores por defecto, faltarían argumentos y daría error. showAddress()devuelve la dirección en vez de imprimirla. Para verla hay que escribirconsole.log(hero.showAddress()).- Fíjate en la salida:
addressaparece aunque seaprivate. La razón está en La trampa deprivate, más abajo.
Cómo funciona por dentro¶
1. new crea un objeto y ejecuta el constructor.
new Person('Geralt of Rivia', 100, 'Kaer Morhen')
│
├─ 1. crea un objeto vacío, conectado a Person.prototype
├─ 2. crea los campos declarados: name, age, address (valen undefined)
├─ 3. ejecuta el constructor con los argumentos: name, age y address reciben sus valores
└─ 4. devuelve el objeto -> hero
2. Las propiedades viven en cada objeto; los métodos, en la clase. Cada
instancia guarda sus propios valores, pero showAddress existe una sola
vez, en Person.prototype, y todas las instancias lo comparten:
Object.keys(hero) // ['name', 'age', 'address']
Object.getOwnPropertyNames(Person.prototype) // ['constructor', 'showAddress']
3. Inicializar las propiedades es obligatorio (en modo estricto). TypeScript exige que cada propiedad tenga valor al terminar el constructor:
| Declaración | ¿Hay que inicializarla? | Por qué |
|---|---|---|
address: string |
Sí | Sin valor sería undefined, y no es un string |
name: string \| undefined |
No | undefined ya es un valor permitido |
age?: number |
No | Opcional: puede faltar |
value!: string |
No (tú lo garantizas) | El ! le dice a TypeScript "confía en mí" |
Si address no se asignara en el constructor, TypeScript daría error:
la propiedad quedaría sin valor.
4. Los campos existen antes de que corra el constructor. En este
proyecto, cada propiedad declarada en la clase se convierte en un campo
real del objeto, y empieza valiendo
undefined (paso 2 del diagrama). Si el constructor no asignara age,
console.log(hero) igual mostraría age: undefined. La primera versión del
archivo, que no asignaba age, lo mostraba así.
5. Un detalle de los tipos de este ejemplo. El constructor siempre asigna
los tres valores (los recibidos o los por defecto), así que name y age
nunca quedan en undefined. Sus declaraciones
(string | undefined y age?) son más permisivas de lo necesario: podrían ser
name: string y age: number. Con tipos más estrictos, TypeScript no te
obliga a revisar undefined cada vez que usas hero.name.
Tres formas de dar el valor inicial¶
La pregunta clave es quién decide el valor: la clase, o quien hace new.
1. Valores fijos dentro de {} (la primera versión del archivo):
constructor() {
this.name = 'Geralt of Rivia'
this.address = 'Kaer Morhen'
}
new Person() // Geralt, Kaer Morhen
new Person('Yennefer') // error: este constructor no recibe argumentos
Todas las instancias salen iguales. Para tener otra persona hay que crearla y después cambiarle las propiedades.
2. Parámetros con valor por defecto:
constructor(name: string = 'Geralt of Rivia', address: string = 'Kaer Morhen') {
this.name = name
this.address = address
}
new Person() // Geralt, Kaer Morhen (usa los dos por defecto)
new Person('Yennefer') // Yennefer, Kaer Morhen
new Person('Yennefer', 'Vengerberg') // Yennefer, Vengerberg
new Person(undefined, 'Vengerberg') // Geralt, Vengerberg (undefined usa el valor por defecto)
Quien crea el objeto puede personalizarlo, y lo común sigue funcionando sin pasar nada. La versión actual del archivo usa esta forma, con John Connor, 13 y Los Angeles como valores por defecto.
Cuidado: valor por defecto no es lo mismo que asignar en el cuerpo. El archivo deja comentadas estas líneas dentro del constructor:
constructor(name: string = 'John Connor', age: number = 13, address: string = 'Los Angeles') {
this.name = name
// ...
/* this.name = 'John Connor'
this.age = 13
this.address = 'Los Angeles' */
}
Si estuvieran activas, se ejecutarían después de recibir los argumentos y
los pisarían: new Person('Geralt of Rivia', 100, 'Kaer Morhen') crearía
igual a John Connor. El valor por defecto de un parámetro sólo se usa cuando
falta el argumento; un valor pasado explícitamente siempre gana.
3. En la declaración de la propiedad:
private address = 'Kaer Morhen'
Equivale a la opción 1, pero el valor inicial queda junto a la propiedad. Es lo más claro para valores que nunca dependen de quien crea el objeto (un contador que empieza en 0, una lista vacía).
| Forma | ¿Quien hace new puede cambiarlo? |
Úsala cuando… |
|---|---|---|
Dentro de {} |
No | El valor se calcula con lógica |
| Parámetro obligatorio | Sí, y debe pasarlo | No hay un valor razonable por defecto |
| Parámetro con valor por defecto | Sí, o puede omitirlo | Hay un valor común, pero debe poder cambiarse |
| En la declaración | No | El valor inicial es siempre el mismo |
Las reglas de los valores por defecto son las mismas que en cualquier función
(Módulo 3): sólo se aplican cuando el argumento es undefined (o falta), y
conviene ponerlos al final. Si uno con valor por defecto va antes de uno
obligatorio, deja de poder omitirse: con
constructor(name = 'Geralt', age: number), la llamada new R(5) da error:
faltan argumentos.
Herencia: extends y super¶
Una clase puede extender otra: hereda sus propiedades y métodos, y agrega
los suyos. En el archivo, SuperHero extiende Person:
export class SuperHero extends Person {
constructor(public alterEgo: string, public age: number, public realName: string, public occupation?: string) {
super(realName, age, 'Baal')
this.showAddress()
}
}
const sanguinius = new SuperHero('The Angel', 1000, 'Sanguinius', 'Loyal Primarch of the Blood Angels')
console.log(sanguinius)
El nombre SuperHero es el mismo que el de la interfaz del Módulo 4. No
chocan porque cada archivo es un módulo aparte (Módulo 7).
Salida real:
Baal
SuperHero {
name: 'Sanguinius',
age: 1000,
address: 'Baal',
alterEgo: 'The Angel',
realName: 'Sanguinius',
occupation: 'Loyal Primarch of the Blood Angels'
}
Person SuperHero extends Person
├── name ├── (hereda name, age, address, showAddress)
├── age? ├── age: number (redeclarada, más estricta)
├── address (private) ├── alterEgo
└── showAddress() ├── realName
└── occupation?
1. super(...) llama al constructor del padre. SuperHero le pasa
realName como nombre, age y 'Baal' como dirección, y Person asigna
esas propiedades. En una subclase, super() es obligatorio y tiene que ir
antes de usar this: si falta, o si usas this antes, da error.
2. Los métodos se heredan. this.showAddress() funciona en SuperHero
aunque se definió en Person, y devuelve 'Baal'. En el archivo esa línea
está comentada.
3. private no se hereda para acceder. El archivo deja comentado
this.address = address. No compilaría: address es private en Person,
así que ni siquiera una subclase puede usarla. Si las subclases necesitan
acceder, se declara protected. Aun así, console.log(sanguinius) muestra
address: 'Baal', por la misma razón que con hero (ver La trampa de
private).
4. Parameter properties. public alterEgo: string en el constructor
declara la propiedad y le asigna el argumento en un solo paso, sin escribir
this.alterEgo = alterEgo. Ver la pregunta 4. Se asignan después de que
super() termina, por eso en la salida aparecen detrás de las de Person.
5. Volver a declarar una propiedad heredada. public age: number vuelve
a declarar age. Person ya la tiene con el mismo tipo, así que aquí sólo la
repite. Una subclase también puede declararla con un tipo más estricto (por
ejemplo, si en el padre fuera age?: number).
Un detalle de las parameter properties. Son un atajo que sólo existe en TypeScript. Vite, que es lo que usa este proyecto, las entiende sin problema, pero algunas herramientas que ejecutan TypeScript directamente no las soportan.
Composición: una clase que tiene otra¶
El archivo termina con una clase Hero que no extiende Person: recibe
una Person ya creada y la guarda en una propiedad.
export class Hero {
constructor(public alterEgo: string, public age: number, public realName: string, public occupation: string, public person: Person) {}
}
const horusPerson = new Person('Horus Lupercal', 1000, 'Chthonia')
const horus = new Hero('The Warmaster', horusPerson.age, horusPerson.name, 'Primarch of the Luna Wolves', horusPerson)
console.log(horus)
Salida real:
Hero {
alterEgo: 'The Warmaster',
age: 1000,
realName: 'Horus Lupercal',
occupation: 'Primarch of the Luna Wolves',
person: Person { name: 'Horus Lupercal', age: 1000, address: 'Chthonia' }
}
Compara con SuperHero:
SuperHero extends Person "es una persona" las propiedades de Person quedan en el MISMO objeto
Hero { person: Person } "tiene una persona" la Person queda en un objeto APARTE, dentro de person
Por eso en sanguinius la dirección aparece al mismo nivel que alterEgo, y
en horus aparece anidada, dentro de person.
Crear la pieza adentro o recibirla de afuera. Una versión anterior del
archivo creaba la Person dentro de Hero
(this.person = new Person(realName, age, 'Chthonia')). La versión actual la
recibe ya creada. Las dos son composición, pero recibirla tiene ventajas:
Herono necesita saber cómo se crea unaPerson; sólo la usa.- Se puede pasar cualquier
Person: una real, una de prueba, una compartida con otro objeto. - Si mañana
Personcambia su constructor,Herono se entera.
Esta idea, "no crees lo que necesitas, recíbelo", es la inyección de dependencias, y es exactamente lo que hace Angular (ver más abajo).
Un detalle del ejemplo: datos repetidos. Hero guarda age y
realName, que ya están dentro de person (horus.age y horus.person.age
valen lo mismo). Si alguien cambia uno, el otro queda desactualizado. Con
composición conviene guardar cada dato en un solo lugar y leerlo desde la
pieza: horus.person.age, horus.person.name.
Herencia o composición: cómo elegir¶
Las dos formas reutilizan código, pero dicen cosas distintas:
HERENCIA SuperHero extends Person "un SuperHero ES una Person"
COMPOSICIÓN Hero { person: Person } "un Hero TIENE una Person"
Una imagen útil: la herencia es como heredar la casa de tus padres: te llega completa, con todo lo bueno y lo malo, y si tus padres la cambian, te afecta. La composición es como armar un mueble con piezas: eliges cada pieza, puedes cambiar una sin tocar las demás, y puedes usar la misma pieza en otro mueble.
| Pregunta | Herencia (extends) |
Composición (tiene una) |
|---|---|---|
| ¿Qué relación expresa? | "Es un" | "Tiene un" o "usa un" |
| ¿Cuántas piezas puede reutilizar? | Un solo padre | Todas las que quieras |
| Si la otra clase cambia, ¿te afecta? | Mucho: heredas todo | Poco: sólo usas lo que necesitas |
| ¿Se puede cambiar la pieza al ejecutar? | No: el padre es fijo | Sí: pasas otra pieza |
| ¿Es fácil de probar? | Hay que probar padre e hijo juntos | Se puede pasar una pieza de prueba |
La regla habitual: preferir composición. La herencia no es mala, pero ata mucho. Úsala sólo cuando se cumplan las dos condiciones:
- La relación "es un" es real y no va a cambiar: un
SuperHerosiempre será unaPerson. - La subclase puede usarse en cualquier lugar donde se espera al padre,
sin sorpresas. Si una función recibe una
Person, tiene que funcionar igual con unSuperHero.
Una señal de alerta: si la subclase anula métodos del padre para que no
hagan nada, o si la cadena crece (Person → SuperHero → FlyingSuperHero
→ …), probablemente composición era mejor.
El mismo caso resuelto de las dos formas. Supongamos que algunos personajes pueden volar y otros pueden curar:
// Con herencia: cada combinación necesita su propia clase
class FlyingHero extends Person {}
class HealingHero extends Person {}
// class FlyingHealingHero extends FlyingHero, HealingHero {} <- no se puede: sólo se extiende UNA clase
// Con composición: cada habilidad es una pieza y se combinan libremente
class Flight { fly() { return 'flying' } }
class Healing { heal() { return 'healing' } }
class Character {
constructor(public person: Person, public skills: { flight?: Flight; healing?: Healing }) {}
}
const sanguinius = new Character(new Person('Sanguinius', 1000, 'Baal'), { flight: new Flight() })
const both = new Character(new Person('Horus Lupercal', 1000, 'Chthonia'), { flight: new Flight(), healing: new Healing() })
Con herencia, cada mezcla de habilidades obliga a crear otra clase. Con composición, se arma el personaje con las piezas que necesita.
Herencia y composición en Angular¶
Angular está construido sobre la composición. La usas todo el tiempo, aunque no la llames así.
1. Inyección de dependencias: composición con servicios. Un componente no
crea lo que necesita: lo pide, y Angular se lo entrega. Es la misma idea
que Hero recibiendo una Person:
@Injectable({ providedIn: 'root' })
export class AddressService {
format(person: Person): string {
return `${person.name} vive en ${person.showAddress()}`
}
}
@Component({
selector: 'app-hero-card',
template: `<p>{{ description() }}</p>`,
})
export class HeroCardComponent {
private readonly addressService = inject(AddressService) // TIENE un servicio
readonly person = input.required<Person>() // RECIBE una persona
protected readonly description = computed(() => this.addressService.format(this.person()))
}
HeroCardComponent no hereda nada: tiene un AddressService y recibe
una Person. En una prueba se puede reemplazar el servicio por uno falso sin
tocar el componente.
2. Componentes dentro de componentes: composición en el template. Una pantalla se arma juntando componentes pequeños, como piezas:
<!-- hero-list.component.html -->
<app-search-box (search)="filter($event)" />
@for (person of people(); track person.name) {
<app-hero-card [person]="person" />
}
HeroListComponent tiene una caja de búsqueda y varias tarjetas. Cada
pieza se puede reutilizar en otra pantalla.
3. Herencia entre componentes: posible, pero poco común. A veces se ve algo así para compartir lógica:
export abstract class BaseListComponent {
protected readonly loading = signal(false)
ngOnInit() {
this.loading.set(true)
}
}
@Component({ selector: 'app-hero-list', template: '...' })
export class HeroListComponent extends BaseListComponent {
override ngOnInit() {
super.ngOnInit() // si te olvidas de esta línea, la lógica del padre no corre
// lógica propia de la lista de héroes
}
}
Funciona, pero trae los problemas de la tabla:
- Si el padre cambia, todas las listas cambian.
- Hay que recordar llamar a
super.ngOnInit()en cada hijo. - Un componente sólo puede extender una clase base: si necesita "cargando" y "paginación", no puede heredar de dos.
4. La alternativa con composición. La misma lógica de "cargando" se puede
poner en un servicio (o en una función que use inject()) y cada componente
la usa sin heredar:
@Injectable()
export class LoadingState {
readonly loading = signal(false)
start() { this.loading.set(true) }
stop() { this.loading.set(false) }
}
@Component({
selector: 'app-hero-list',
template: '...',
providers: [LoadingState], // cada lista tiene su propio estado de carga
})
export class HeroListComponent {
protected readonly loadingState = inject(LoadingState) // TIENE un estado de carga
}
Ahora HeroListComponent puede sumar otras piezas (paginación, filtros) sin
límite, y cada pieza se prueba por separado. Angular también permite componer
directivas sobre un componente con hostDirectives, para reutilizar
comportamiento del template sin herencia.
Cuándo sí se usa herencia en Angular: en casos acotados, como una clase base con lógica muy estable que comparten muchos componentes casi iguales, o al extender clases de librerías que están pensadas para eso. Aun así, la primera opción suele ser un servicio.
public, private, protected y readonly¶
| Modificador | Desde la clase | Desde una subclase | Desde afuera |
|---|---|---|---|
public |
Sí | Sí | Sí |
protected |
Sí | Sí | No |
private |
Sí | No | No |
readonlyse combina con cualquiera de los tres (private readonly): la propiedad sólo se puede asignar al declararla o en el constructor. Después,hero.id = 2da error, porqueides de sólo lectura.publices el valor por defecto: escribirlo es opcional. El archivo lo usa para que la intención quede explícita.showAddresses la forma correcta de "dejar ver" un dato privado: un método público que controla qué se muestra.
La trampa de private: protege el código, no oculta el dato¶
Por qué se ve la dirección. En la salida, console.log(hero) muestra
address: 'Kaer Morhen' aunque address sea private. No es un error del
ejemplo:
privateprotege el código que escribes: TypeScript no te deja usaraddressfuera de la clase. Eso funciona:hero.addressda error.- Pero no oculta el dato.
privatees una palabra de TypeScript y desaparece cuando el código se convierte a JavaScript (ver la Introducción). Al ejecutarse,addresses una propiedad común dentro del objeto, yconsole.logmuestra todo lo que el objeto tiene.
TypeScript JavaScript que llega al navegador
private address: string -> address (una propiedad común y corriente)
Lo mismo pasa con JSON.stringify(hero), que incluye address.
Cómo ocultarlo: #address. JavaScript tiene sus propios campos privados,
que se escriben con #. Esos sí quedan ocultos también cuando el programa
corre. Un campo # no se puede declarar dentro de los paréntesis del
constructor, así que se declara en la clase y se asigna en el constructor:
export class Person {
#address: string
constructor(public name: string = 'John Connor', public age: number = 13, address: string = 'Los Angeles') {
this.#address = address
}
showAddress(): string {
return this.#address
}
}
const hero = new Person('Geralt of Rivia', 100, 'Kaer Morhen')
console.log(hero) // Person { name: 'Geralt of Rivia', age: 100 }
console.log(hero.showAddress()) // Kaer Morhen
//console.log(hero.#address) // error: #address sólo se puede usar dentro de Person
Con #address, el objeto ya no muestra la dirección, y la única forma de
obtenerla desde afuera es showAddress(). Esa es la idea del
encapsulamiento: la clase decide qué deja ver y cómo.
| Lo que intentas | private address |
#address |
|---|---|---|
Usar hero.address fuera de la clase |
Error al escribirlo | Error |
| Usarla desde una subclase | Error | Error |
Verla en console.log(hero) |
Se ve | No se ve |
Leerla con showAddress() |
Sí | Sí |
El archivo de la lección mantiene private address a propósito, para que se
vea la diferencia; los comentarios explican cómo cambiarlo a #address.
Clases y tipos¶
Una clase es dos cosas a la vez: un valor (la función que usas con new)
y un tipo (la forma de sus instancias). const hero: Person usa el tipo;
new Person() usa el valor.
El tipado estructural (Módulo 2) también aplica a las clases, salvo que
tengan miembros private:
class Pub { name = 'a' }
const a: Pub = { name: 'x' } // compila: tiene la misma forma
class Priv { name = 'a'; private address = 'b' }
const b: Priv = { name: 'x', address: 'y' }
// error: address es private en Priv, y en el objeto no
Un miembro private hace que sólo las instancias creadas por esa clase (o sus
subclases) cuenten como ese tipo.
Comparación con Angular¶
En Angular, componentes, servicios, pipes y directivas son clases:
@Component({ selector: 'app-hero', template: '...' })
export class HeroComponent {
private readonly heroService = inject(HeroService) // dependencia privada
protected readonly hero = signal<Hero | null>(null) // usable desde el template
}
privatepara lo que sólo usa el código de la clase (servicios inyectados, detalles internos).protectedpara lo que el template necesita leer pero otras clases no deberían tocar. Desde Angular 14, los templates pueden usar miembrosprotected.readonlypara dependencias y signals que no se reasignan (el signal cambia su valor conset/update, pero la propiedad sigue apuntando al mismo signal).
Errores comunes¶
- Creer que
privateoculta datos en runtime. Para eso,#campo. - Usar el
!(value!: string) para callar el error de propiedad sin valor, sin garantizar que lo tenga: el problema vuelve al ejecutar, cuando el código usa unundefinedcomo si fuera un texto. - Poner valores fijos en el constructor (todas las
Personson Geralt). Lo habitual es recibirlos como parámetros, con o sin valor por defecto (ver Tres formas de dar el valor inicial). - Agregar parámetros al constructor sin ajustar los tipos de las propiedades:
si el constructor siempre las asigna,
string | undefinedo?sobran. - Asignar valores fijos en el cuerpo del constructor después de recibir
parámetros: pisan lo que se pasó en
new. - Usar
thisantes desuper()en una subclase. - Esperar que una subclase acceda a una propiedad
privatedel padre: hace faltaprotected. - Esperar que una clase con métodos sobreviva a
JSON.parse: lo que llega es un objeto común, sin métodos (Módulo 4, pregunta 4).
Resumen¶
Una clase es un molde: new crea instancias, el constructor las prepara, los
valores viven en cada instancia y los métodos se comparten. Los parámetros con
valor por defecto dejan personalizar sin obligar. Una subclase (extends)
hereda todo, llama a super() antes de usar this y no puede tocar lo
private del padre. private es una regla del compilador, no del navegador;
# sí protege en runtime.
Preguntas de entrevista (senior)¶
1. private de TypeScript vs #private de JavaScript: ¿cuál usas y por qué?¶
Respuesta:
| Aspecto | private (TypeScript) |
#campo (JavaScript) |
|---|---|---|
| Cuándo se aplica | Sólo al compilar | También en runtime |
(obj as any).campo |
Funciona | Imposible |
JSON.stringify / Object.keys |
Lo incluyen | No lo incluyen |
| Se puede usar en un template de Angular | No | No |
| Pruebas unitarias | Accesible con as any |
Inaccesible |
# da privacidad real: útil en librerías, donde no quieres que nadie dependa
de tus detalles internos, o con datos sensibles. private es más común en
aplicaciones Angular: es suficiente para guiar al equipo, convive con
decoradores e inyección, y en pruebas se puede inspeccionar si hace falta. Lo
importante es entender que private es documentación verificada por el
compilador, no un mecanismo de seguridad.
2. ¿Qué es strictPropertyInitialization y cuándo está justificado usar !?¶
Respuesta:
Es la parte de strict que exige que cada propiedad tenga valor al terminar
el constructor. Evita el clásico undefined inesperado. El !
(definite assignment assertion) le dice al compilador "esta propiedad tendrá
valor aunque no lo veas". Se justifica cuando otra cosa la asigna después del
constructor y antes de usarla: por ejemplo, en Angular antiguo,
@ViewChild('ref') ref!: ElementRef o @Input() hero!: Hero. Es una promesa
sin verificación: si no se cumple, el error aparece en runtime. Hoy hay
alternativas que evitan el !: input.required<Hero>() y viewChild()
devuelven signals con el tipo correcto, o se puede declarar el tipo con
| undefined y manejar ese caso.
3. ¿Clase o interfaz para modelar los datos que llegan de una API?¶
Respuesta:
Normalmente una interfaz (o un type). Lo que llega de una API es JSON:
un objeto común, sin métodos ni prototipo. Declararlo como clase
(http.get<Hero>() con class Hero) engaña al compilador: hero instanceof
Hero da false y los métodos no existen. La interfaz describe la forma sin
prometer nada más, y no genera código. Una clase tiene sentido si de verdad
construyes la instancia (new Hero(dto)) después de validar los datos, porque
necesitas comportamiento o invariantes (por ejemplo, un Money que nunca es
negativo).
4. ¿Qué son las parameter properties (constructor(private http: HttpClient))?¶
Respuesta:
Una abreviatura de TypeScript que declara y asigna una propiedad en el mismo parámetro:
class Person {
constructor(public name: string, private address: string) {}
}
// equivale a declarar name y address y asignarlos en el constructor
Ahorra código, pero no es JavaScript estándar: TypeScript tiene que
generar las asignaciones, no sólo borrar tipos. Por eso opciones como
erasableSyntaxOnly, o el modo de Node que sólo quita tipos, las prohíben.
En este repositorio están permitidas porque erasableSyntaxOnly se quitó a
propósito. En Angular fue la forma clásica de inyectar dependencias; hoy se
prefiere private readonly http = inject(HttpClient), que es JavaScript
estándar y funciona fuera del constructor.
5. ¿Método de clase o función flecha como propiedad? ¿Qué cambia en memoria y en this?¶
Respuesta:
class Person {
showAddress() { ... } // método: uno solo, en Person.prototype
showArrow = () => { ... } // propiedad: una función nueva por instancia
}
Con mil instancias hay un showAddress compartido y mil showArrow. A
cambio, la flecha conserva this aunque se pase como callback
(setTimeout(hero.showArrow)), mientras que el método lo pierde
(Módulo 3, pregunta 1). Regla práctica: métodos normales por defecto; flecha
sólo cuando el método se va a pasar como callback y no quieres usar
.bind(this) o una flecha en el lugar de la llamada.
6. ¿Por qué un objeto literal no puede ser de tipo Priv si tiene las mismas propiedades?¶
Respuesta:
Porque TypeScript compara las clases por estructura excepto sus miembros
private y protected: para esos exige que vengan de la misma declaración.
Un objeto literal con un address público no es compatible con una clase con
address privado. Efecto
práctico: agregar un miembro private vuelve "nominal" a una clase, y sólo
sus instancias (o las de sus subclases) cuentan como ese tipo. A veces se usa
a propósito para que no se pueda fabricar un objeto "a mano" que se haga
pasar por una instancia, una alternativa a los branded types del Módulo 2.
7. El constructor de Person empieza a necesitar seis o siete datos. ¿Cómo lo diseñas?¶
Respuesta:
Con muchos parámetros posicionales,
new Person('Geralt', 100, 'Kaer Morhen', true, null, 'EN') se vuelve
ilegible y fácil de equivocar: dos string seguidos se pueden intercambiar
sin que tsc lo note. Opciones, de la más simple a la más elaborada:
- Un objeto de opciones (Módulo 6):
new Person({ name, age, address }). Cada dato va con su nombre, el orden no importa y los opcionales se omiten sin pasarundefined. Se combina bien con desestructuración y valores por defecto en la firma:constructor({ name, age, address = 'Kaer Morhen' }: PersonOptions). - Métodos de fábrica estáticos con nombre, cuando hay formas distintas de
crear el objeto:
Person.fromDto(dto),Person.guest(). - Builder, si la construcción tiene muchos pasos opcionales o validaciones entre ellos. En TypeScript suele bastar con el objeto de opciones.
También conviene preguntarse si la clase hace demasiado: muchos datos en el
constructor a veces indican que habría que separarla en dos. En Angular el
problema casi no aparece en componentes y servicios, porque las dependencias
llegan con inject() y los datos de entrada con input(), no por el
constructor.
8. ¿Herencia o composición? ¿Cuándo usarías extends?¶
Respuesta:
(La explicación completa, con ejemplos en Angular, está en las secciones Herencia o composición: cómo elegir y Herencia y composición en Angular.)
La herencia expresa "es un": un SuperHero es una Person. La composición
expresa "tiene un": un Hero tiene una Person, un Profile o un
Address. La regla habitual es preferir composición:
- La herencia acopla fuerte: un cambio en
Person(un parámetro nuevo en el constructor, un método que cambia) afecta a todas las subclases. - JavaScript sólo permite un padre (
extendsadmite una sola clase). SiSuperHeronecesita comportamiento de dos fuentes, la herencia no alcanza. - Las jerarquías profundas (
Person→SuperHero→FlyingSuperHero→ …) se vuelven difíciles de seguir.
extends tiene sentido cuando la relación "es un" es real y estable, y la
subclase puede usarse en cualquier lugar donde se espera el padre sin
sorpresas (principio de sustitución de Liskov). En Angular la herencia entre
componentes es poco común; para compartir lógica se usan servicios
inyectados, funciones (inject() dentro de funciones reutilizables) o
directivas.
9. ¿Qué pasa si el constructor del padre llama a un método que la subclase sobrescribe?¶
Respuesta:
El método de la subclase se ejecuta antes de que los campos de la
subclase estén inicializados, porque éstos se asignan recién cuando super()
termina:
class Base {
constructor() { this.describe() }
describe() { console.log('base') }
}
class Sub extends Base {
title = 'Hero'
constructor() {
super()
console.log('after super, title =', this.title)
}
describe() { console.log('describe sees title =', this.title) }
}
new Sub()
// describe sees title = undefined
// after super, title = Hero
TypeScript no lo detecta: el tipo de title dice string, pero en ese
momento vale undefined. Lo mismo pasa con las parameter properties. Por
eso un constructor no debería llamar a métodos que una subclase pueda
sobrescribir; si hace falta un paso de inicialización, se hace en un método
separado que se llama después de crear el objeto (en Angular, ngOnInit).
Módulo 9: Genéricos¶
Archivo: 01-typescript-intro/src/topics/09-generics.ts
El problema que resuelve¶
Imagina una función que recibe algo y lo devuelve tal cual. ¿Qué tipo le
pones al parámetro? Si pones string, sólo sirve para textos. Si escribes una
versión por tipo (returnString, returnNumber…), repites lo mismo muchas
veces. Si pones any, acepta todo, pero TypeScript deja de revisar el
resultado.
Los genéricos (generics) resuelven esto: una sola función que sirve para cualquier tipo sin perder la información del tipo.
Definición simple¶
Un genérico es un tipo que se completa después. Se escribe entre < >,
normalmente con la letra T (de Type):
function whatsMyType<T>(argument: T): T
Se lee así: "esta función recibe algo de tipo T y devuelve algo del mismo
tipo T". Qué es T se decide cada vez que se llama a la función.
Una comparación: T es como un casillero vacío con etiqueta. Al llamar a la
función, alguien pone un tipo en el casillero (string, number…), y en
todos los lugares donde aparece T se usa ese mismo tipo. La comparación
falla en un punto: el casillero existe sólo mientras escribes el código. Al
ejecutar, los tipos desaparecen y la función es un return argument común.
El código del repositorio¶
export function whatsMyType<T>(argument: T): T {
return argument
}
const amIString = whatsMyType<string>('Hello World')
const amINumber = whatsMyType<number>(42)
const amIBoolean = whatsMyType<boolean>(true)
const amIArray = whatsMyType<number[]>([1, 2, 3])
const amIObject = whatsMyType<{ name: string; age: number }>({ name: 'Alice', age: 30 })
const amINull = whatsMyType<null>(null)
const amIUndefined = whatsMyType<undefined>(undefined)
const amINever = whatsMyType<never>(undefined as never)
console.log(amIString.toUpperCase())
console.log(amINumber.toFixed(2))
console.log(amIArray, amIArray.length, amIArray.join('-'))
console.log(amIObject, amIObject.name, amIObject.age)
Salida real:
HELLO WORLD
42.00
true
[ 1, 2, 3 ] 3 1-2-3
{ name: 'Alice', age: 30 } Alice 30
null
undefined
undefined
- Cada resultado conserva su tipo.
amIStringesstring, por eso acepta.toUpperCase().amINumberesnumber: acepta.toFixed(2), peroamINumber.toUpperCase()sería un error. amIArrayesnumber[], así que tiene.lengthy.join().amIObjecttiene.namey.age, y el editor los sugiere al escribir el punto.neveres el tipo de un valor que no puede existir, así que no hay un valor real para pasar.undefined as neverlo fuerza: TypeScript confía en elas, pero el valor sigue siendoundefined, y eso es lo que se imprime. Sirve como curiosidad, no como ejemplo a copiar.
Cómo funciona por dentro¶
1. El tipo se puede escribir o se puede deducir.
En el archivo, el tipo va escrito entre < >. No hace falta: TypeScript lo
deduce del argumento (inferencia).
const amIString = whatsMyType<string>('Hello World') // tipo escrito
const greeting = whatsMyType('Hello World') // tipo deducido
Si escribes el tipo, el argumento tiene que coincidir:
whatsMyType<string>(42) es un error.
2. Con const, el tipo deducido es más exacto.
const a = whatsMyType('Hello') // tipo: 'Hello' (sólo ese texto)
let b = whatsMyType('Hello') // tipo: string (cualquier texto)
Es la misma regla del Módulo 1: una constante no cambia, así que TypeScript
guarda el valor exacto como tipo (tipo literal). Una variable let puede
cambiar, así que se ensancha a string.
3. Al ejecutar, los genéricos no existen.
El JavaScript que corre en el navegador es:
function whatsMyType(argument) {
return argument
}
Los < > son notas para el compilador, igual que el resto de los tipos. Por
eso dentro de la función no puedes preguntar "¿qué es T?" al ejecutar.
Genérico, any o unknown¶
Las tres aceptan cualquier valor. La diferencia está en lo que pasa con el resultado:
function withAny(argument: any): any {
return argument
}
const x = withAny(42)
x.toUpperCase() // compila, pero falla al ejecutar: un número no tiene toUpperCase
| Opción | Acepta cualquier valor | El resultado conserva el tipo | TypeScript sigue revisando |
|---|---|---|---|
any |
Sí | No | No |
unknown |
Sí | No | Sí: obliga a comprobarlo |
Genérico <T> |
Sí | Sí | Sí |
Regla práctica: si lo que entra y lo que sale están relacionados, usa un
genérico. Si no sabes qué llega (por ejemplo, datos de afuera), usa unknown
y compruébalo. Deja any para casos muy puntuales.
Poner límites: extends¶
A veces la función necesita algo del tipo. Por ejemplo, leer .length. Con un
T libre no se puede, porque un número no tiene length. extends pone un
requisito mínimo:
function getLength<T extends { length: number }>(item: T): number {
return item.length
}
getLength('abc') // 3: un texto tiene length
getLength([1, 2]) // 2: un array tiene length
getLength(42) // error: un número no tiene length
Aquí extends no es herencia de clases (Módulo 8). Se lee como "T puede ser
cualquier tipo, siempre que tenga length".
Genéricos en interfaces y clases¶
No sólo las funciones pueden ser genéricas:
interface ApiResponse<T> {
data: T
status: number
}
const response: ApiResponse<string[]> = { data: ['Geralt', 'Ciri'], status: 200 }
class Box<T> {
constructor(public value: T) {}
}
const box = new Box(5) // Box<number>: el tipo se deduce del constructor
Y puede haber más de un tipo:
function pair<K, V>(key: K, value: V): [K, V] {
return [key, value]
}
const entry = pair('id', 1) // [string, number]: una tupla (ver Tipos de datos)
Ya los usaste sin saberlo¶
| Dónde | Qué significa |
|---|---|
Array<number> (Tipos de datos) |
Igual que number[] |
document.querySelector<HTMLDivElement>('#app') (main.ts) |
El elemento encontrado es un div |
Promise<string> |
Una promesa que termina con un texto |
Record<string, number> |
Un objeto con claves texto y valores número |
Comparación con Angular¶
Angular usa genéricos en casi todas sus piezas:
import { Component, inject, input, output, signal } from '@angular/core'
import { HttpClient } from '@angular/common/http'
@Component({ selector: 'app-hero-list', template: '' })
export class HeroListComponent {
private readonly http = inject(HttpClient)
readonly count = signal<number>(0) // una signal que guarda un número
readonly title = input<string>('Héroes') // un input de tipo texto
readonly selected = output<string>() // un evento que emite un texto
loadHeroes() {
return this.http.get<Person[]>('/api/heroes') // Observable<Person[]>
}
}
Ojo con http.get<Person[]>(): el genérico le promete al compilador qué
va a llegar, pero no lo comprueba. Si la API devuelve otra cosa, TypeScript no
se entera (ver Módulo 4, pregunta 4).
Errores comunes¶
- Usar
any"porque acepta todo" y perder la revisión de tipos, cuando un genérico hacía lo mismo sin perderla. - Escribir un genérico donde
Taparece una sola vez (function log<T>(value: T): void). No conecta nada con nada:unknownbasta. - Esperar que
http.get<T>()oasvaliden los datos. Sólo cambian lo que cree el compilador. - Intentar leer
Tal ejecutar (if (T === string)). Los tipos no existen en el navegador. - Confundir el
extendsde<T extends …>(requisito) con elextendsde las clases (herencia).
Resumen¶
Un genérico es un tipo que se completa al usar la función, interfaz o clase.
Permite escribir el código una vez y que sirva para muchos tipos, sin perder
la revisión de TypeScript. El tipo se puede escribir (<string>) o dejar que
se deduzca. extends pone un requisito mínimo. Angular los usa en signal,
input, output y HttpClient, y ahí también son promesas al compilador,
no validaciones.
Preguntas de entrevista (senior)¶
1. ¿Cuándo usas un genérico, cuándo unknown y cuándo any?¶
Respuesta:
Un genérico cuando hay una relación entre tipos: lo que entra determina lo
que sale (first<T>(items: T[]): T | undefined). unknown cuando no sabes
qué llega y vas a comprobarlo antes de usarlo, por ejemplo un JSON.parse o
un catch (error). any casi nunca: apaga la revisión y se contagia, porque
todo lo que sale de un any también es any. Se tolera al migrar código
viejo o con una librería mal tipada, y conviene encerrarlo en una función
pequeña con un tipo de retorno claro.
2. ¿Cuándo un genérico no aporta nada?¶
Respuesta:
Cuando el parámetro de tipo aparece una sola vez. En
function log<T>(value: T): void, T no conecta el parámetro con nada más:
es lo mismo que value: unknown, pero más difícil de leer. La regla práctica:
un parámetro de tipo debería aparecer al menos dos veces (en dos
parámetros, o en un parámetro y el retorno). Si no, sobra.
3. http.get<Person[]>('/api/heroes'): ¿qué garantiza ese <Person[]>?¶
Respuesta:
Nada al ejecutar. Le dice al compilador "trata la respuesta como
Person[]", igual que un as. Si el backend cambia un campo o devuelve un
error con otra forma, el código compila y falla después, lejos del origen. En
proyectos serios se valida en el borde: se recibe como unknown y se
comprueba con una función propia o con una librería de esquemas (Zod,
Valibot), que además deducen el tipo a partir del esquema. Otra opción
habitual es generar los tipos desde el contrato de la API (OpenAPI), para que
al menos no se desincronicen.
4. ¿Qué hace extends en <T extends { length: number }>?¶
Respuesta:
Pone un requisito mínimo: T puede ser cualquier tipo que tenga
length como número. Gracias a eso, dentro de la función se puede usar
item.length, y al llamar con un número da error. No es herencia: un
string no "hereda" de { length: number }, sólo cumple la forma. Es el
mismo criterio de forma del Módulo 2: si tiene lo que se pide, sirve. Un uso
típico es <K extends keyof T>, que limita K a las claves de T:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
5. ¿Por qué const a = whatsMyType('Hello') tiene tipo 'Hello' y let b = whatsMyType('Hello') tiene tipo string?¶
Respuesta:
TypeScript deduce T del argumento, que es el texto exacto 'Hello'. Con
const, la variable no puede cambiar, así que guarda ese tipo exacto (tipo
literal). Con let, la variable podría recibir otro texto más adelante, así
que TypeScript lo ensancha a string. Importa cuando el resultado se pasa a
algo que sólo acepta ciertos valores, como una unión
'admin' | 'user': el const encaja, el let no. Si quieres el tipo exacto
siempre, se puede declarar <const T> en la función o usar as const al
llamarla.
Módulo 10: Decoradores¶
Archivo: 01-typescript-intro/src/topics/10-decorators.ts
El problema que resuelve¶
A veces quieres que muchas clases reciban el mismo agregado: registrar cuándo
se crean, sumarles una propiedad o avisarle a un framework qué papel cumplen.
Copiar ese código en cada clase es repetitivo y fácil de olvidar. Un
decorador lo escribe una vez y se aplica con una etiqueta: @nombre.
Definición simple¶
Un decorador es una función que se pone encima de una clase (o de un
método o una propiedad) con @. Recibe lo que decora y puede:
- sólo mirarlo (por ejemplo, para anotar información),
- o devolver algo que lo reemplace.
Una comparación: es como el sello que se le pone a un paquete antes de enviarlo. El sello no cambia lo que hay adentro, pero le dice al correo qué hacer con él. Algunos decoradores son sólo un sello (los de Angular). Otros abren el paquete y cambian el contenido (el del archivo). Ahí la comparación deja de servir: un sello real nunca cambia el contenido.
El código del repositorio¶
function classDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
return class extends constructor {
newProperty = "new property"
hello = "override"
}
}
@classDecorator
export class SuperClass {
public myProperty: string = 'Abc123'
print() {
console.log('Hola Mundo')
}
}
console.log(SuperClass)
const myInstance = new SuperClass()
console.log(myInstance)
Salida real:
[class (anonymous) extends SuperClass]
SuperClass { myProperty: 'Abc123', newProperty: 'new property', hello: 'override' }
Paso a paso:
@classDecoratorllama aclassDecorator(SuperClass). Pasa una sola vez, cuando se define la clase, no cada vez que se hacenew.- La función devuelve
class extends constructor { ... }: una clase hija (subclase) deSuperClass, sin nombre, con dos propiedades más. - Desde ahí, el nombre
SuperClassapunta a esa clase hija. Por esonew SuperClass()crea un objeto con las tres propiedades:myProperty(heredada del padre),newPropertyyhello(de la hija). myInstance.print()funciona:printse hereda del padre.
Tres detalles:
- El decorador no agrega propiedades a
SuperClass: la reemplaza por una clase hija que las tiene. hello = "override"no sobrescribe nada.SuperClassno tienehello, así que sólo se agrega. Para sobrescribir, la clase padre tendría que tener su propiohello.- TypeScript no se entera del cambio. Sigue creyendo que
SuperClasses la clase original.myInstance.newPropertyexiste al ejecutar, pero escribirlo da error en el editor.
Clase padre y clase hija¶
class extends constructor es la misma idea que SuperHero extends Person
del Módulo 8:
| Nombre | En el Módulo 8 | En el decorador |
|---|---|---|
| Clase padre (superclase) | Person |
SuperClass, que llega como constructor |
| Clase hija (subclase) | SuperHero |
La clase sin nombre que devuelve la función |
La hija recibe todo lo del padre y suma lo suyo. Toda instancia de la hija es también una instancia del padre; al revés, no.
El genérico <T extends { new (...args: any[]): {} }> (Módulo 9) se lee como
"T puede ser cualquier clase", es decir, cualquier cosa que se pueda usar con
new. Por eso la función puede hacer extends constructor.
Por qué el navegador ve la clase como una función¶
console.log(SuperClass) imprime la clase, no un objeto. En JavaScript una
clase es una función (typeof SuperClass es 'function'), y la consola
del navegador la marca con el símbolo ƒ, que con algunas fuentes se parece a
una λ. Por el decorador, lo que se ve es la clase hija sin nombre.
Por qué hace falta experimentalDecorators¶
Los navegadores todavía no entienden decoradores, así que el @ se traduce a
JavaScript común antes de ejecutar. En este proyecto:
- Sin la opción, Vite deja
@classDecoratortal cual y la página falla en el navegador, aunque el editor no marque nada. - Con
"experimentalDecorators": trueentsconfig.json, Vite lo traduce usando la versión antigua de los decoradores, la misma que usa Angular.
Existen dos versiones. La antigua (experimental) es la que usaron Angular y muchas librerías durante años. La nueva es la oficial de JavaScript y TypeScript la acepta sin ninguna opción, pero cambia lo que recibe la función y no permite decorar parámetros del constructor. No se mezclan: un proyecto usa una o la otra.
Más formas de decorar¶
Decorador con parámetros (decorator factory). Es una función que
devuelve el decorador. Así funciona @Component({ ... }):
function logClass(message: string) {
return function (constructor: Function) {
console.log(message, constructor.name)
}
}
@logClass('Clase definida:')
class Witcher {}
// imprime: Clase definida: Witcher
Varios decoradores en la misma clase. Se aplican de abajo hacia arriba: el más cercano a la clase va primero.
@first
@second
class Witcher {}
// imprime: second, luego first
Decorador de método. Recibe el método y puede envolverlo, por ejemplo para registrar cada llamada:
function logCall(_target: object, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value
descriptor.value = function (this: unknown, ...args: unknown[]) {
console.log(`llamando a ${key} con`, args)
return original.apply(this, args)
}
}
class Calculator {
@logCall
add(a: number, b: number): number {
return a + b
}
}
new Calculator().add(2, 3)
// llamando a add con [ 2, 3 ]
// devuelve 5
Decoradores en Angular¶
En Angular, los decoradores le dicen al framework qué es cada clase. No la reemplazan por otra: le agregan instrucciones que Angular lee al compilar.
import { Component, Injectable, inject } from '@angular/core'
@Injectable({ providedIn: 'root' })
export class HeroService {
getHeroes(): string[] {
return ['Geralt', 'Ciri']
}
}
@Component({
selector: 'app-hero-list',
template: `
@for (hero of heroes; track hero) {
<p>{{ hero }}</p>
}
`
})
export class HeroListComponent {
private readonly heroService = inject(HeroService)
heroes = this.heroService.getHeroes()
}
@Injectable({ providedIn: 'root' }): "esto es un servicio; crea uno solo para toda la aplicación y entrégaselo a quien lo pida".@Component({ ... }): "esto es una pieza de pantalla; se usa con la etiqueta<app-hero-list>y dibuja este template".- Los dos son decoradores con parámetros:
Component(...)recibe la configuración y devuelve el decorador.
Sin el decorador, las dos son clases comunes y Angular no sabría qué hacer con ellas. La tabla completa de decoradores está en Qué es Angular.
Lo que cambió. Los decoradores de clase (@Component, @Injectable,
@Directive, @Pipe) se siguen usando igual. Los de propiedad y
parámetro tienen hoy un reemplazo en forma de función:
| Antes (decorador) | Ahora (función) |
|---|---|
@Input() name!: string |
name = input.required<string>() |
@Output() saved = new EventEmitter<string>() |
saved = output<string>() |
@ViewChild('box') box!: ElementRef |
box = viewChild<ElementRef>('box') |
constructor(@Inject(TOKEN) value) |
value = inject(TOKEN) |
Las funciones devuelven signals con el tipo correcto y evitan el !.
Errores comunes¶
- Creer que un decorador se ejecuta en cada
new. Se ejecuta una vez, al definir la clase. - Esperar que TypeScript conozca lo que agregó un decorador que reemplaza la clase. No lo sabe.
- Olvidar la opción
experimentalDecoratorsen este proyecto: el editor no avisa y la página falla. - Escribir
@Componentsin paréntesis. Es un decorador con parámetros: va@Component({ ... }). - Mezclar ejemplos de la versión antigua y la nueva: reciben cosas distintas.
Resumen¶
Un decorador es una función que se aplica con @ y recibe lo que decora. Se
ejecuta una vez, al definir la clase. Puede sólo anotar información (como
Angular) o devolver un reemplazo (como classDecorator, que devuelve una clase
hija). TypeScript no se entera de los reemplazos. En este proyecto hace falta
experimentalDecorators para que Vite los traduzca. En Angular, los de clase
siguen vigentes y los de propiedad pasaron a funciones como input().
Preguntas de entrevista (senior)¶
1. ¿Qué diferencia hay entre @Logger y @Logger()?¶
Respuesta:
@Logger usa la función directamente como decorador: recibe la clase.
@Logger() llama a la función, y lo que devuelve es el decorador
(decorator factory). La segunda forma permite pasar configuración, como
@Component({ selector: 'app-hero' }). Confundirlas da error o, peor, hace
que el decorador reciba algo que no espera. Al escribir uno propio, decide una
forma y documéntala.
2. ¿Qué riesgos tiene un decorador que reemplaza la clase?¶
Respuesta:
Tres. Primero, TypeScript no ve el cambio: las propiedades nuevas existen
al ejecutar, pero el editor no las conoce, así que hay que forzarlas con as o
declararlas aparte. Segundo, se pierde el nombre: la clase nueva es
anónima, y en la consola o en un error aparece (anonymous) en vez de
SuperClass. Tercero, es magia escondida: quien lee la clase no ve lo
que se le agregó sin ir al decorador. Por eso, para comportamiento compartido,
suele ser más claro usar composición o una función explícita (Módulo 8), y
dejar los decoradores para anotar información.
3. ¿En qué orden se aplican varios decoradores?¶
Respuesta:
De abajo hacia arriba: el más cercano a la clase va primero. Con
@first
@second
class Witcher {}
se aplica second y después first, como si fuera first(second(Witcher)).
Si son decoradores con parámetros, las funciones de afuera (logClass('...'))
se evalúan de arriba hacia abajo, y los decoradores que devuelven se aplican
de abajo hacia arriba. Importa cuando uno depende del resultado del otro.
4. ¿Por qué Angular pasó de @Input() a input()?¶
Respuesta:
Porque la función resuelve problemas que el decorador tenía. input()
devuelve una signal, así que el valor se puede usar en computed() y
effect() y Angular sabe exactamente qué actualizar. input.required<T>()
obliga al componente padre a pasar el valor, sin el ! que usaba
@Input() name!: string. Además, el tipo sale de la función
(input<string>()) y no hace falta repetirlo. Lo mismo vale para output(),
viewChild() e inject().
5. ¿Qué diferencia hay entre la versión antigua y la nueva de los decoradores?¶
Respuesta:
La antigua (experimentalDecorators) es una propuesta vieja que TypeScript
implementó antes de que JavaScript la terminara; la usan Angular y muchas
librerías. La nueva es la oficial de JavaScript, y TypeScript la acepta sin
ninguna opción. Cambia lo que recibe la función: la nueva recibe el valor y un
objeto de contexto (context.name, context.kind). Además, la nueva no
permite decorar parámetros del constructor, como @Inject(TOKEN), una de
las razones por las que Angular empuja inject(). En un proyecto se usa una
sola: el código de una no funciona con la otra.
Módulo 11: Encadenamiento opcional y !¶
Archivo: 01-typescript-intro/src/topics/11-optional-chaining.ts
El problema que resuelve¶
Muchos datos pueden faltar: un pasajero puede no tener hijos, un usuario puede
no tener dirección. Si intentas leer algo de un dato que no existe
(passenger.children.length cuando no hay children), el programa falla.
Antes había que preguntar a mano en cada paso
(if (passenger.children) { ... }). El encadenamiento opcional (?.) lo
resuelve en una sola expresión.
Definición simple¶
| Símbolo | Nombre | Qué hace |
|---|---|---|
? en una interfaz |
Propiedad opcional | La propiedad puede estar o no |
?. |
Encadenamiento opcional (optional chaining) | Si lo de la izquierda no existe, se detiene y devuelve undefined |
?? |
Fusión nula (nullish coalescing) | Si lo de la izquierda es null o undefined, usa el valor de la derecha |
! |
Aserción de no nulo (non-null assertion) | Le dice a TypeScript "confía en mí, esto existe" y deja de revisar |
Una comparación: ?. es como tocar el timbre antes de entrar. Si no hay nadie,
te vas sin romper nada. ! es entrar sin tocar porque estás seguro de que hay
alguien: si te equivocas, chocas contra la puerta.
El código del repositorio¶
export interface Passenger {
name: string
children?: string[]
}
const passenger1: Passenger = { name: 'Geralt' }
const passenger2: Passenger = { name: 'Emhyr', children: ['Ciri'] }
const passenger3: Passenger = { name: 'Philip Strenger', children: ['Tamara', 'Dea'] }
const printChildren = (passenger: Passenger) => {
const howManyChildren = passenger.children?.length || 0
console.log(`Passenger ${passenger.name} has ${howManyChildren} children.`)
}
printChildren(passenger1)
printChildren(passenger2)
const returnChildrenNumber = (passenger: Passenger): number => {
const howManyChildren = passenger.children!.length
return howManyChildren
}
console.log(`Passenger ${passenger3.name} has ${returnChildrenNumber(passenger3)} children.`)
Salida real:
Passenger Geralt has 0 children.
Passenger Emhyr has 1 children.
Passenger Philip Strenger has 2 children.
children?: string[]: el?hace que la propiedad sea opcional. Por esopassenger1es válido sinchildren. Su tipo esstring[] | undefined, y TypeScript no te deja usarla como array hasta comprobar que existe.passenger.children?.length: sichildrenno existe, devuelveundefineden vez de fallar. Si existe, leelengthcomo siempre. El resultado esnumber | undefined.|| 0: reemplaza eseundefinedpor0, asíhowManyChildrensiempre es un número y el mensaje nunca dice "undefined children".passenger.children!.length: el!le dice a TypeScript quechildrenexiste. Conpassenger3es cierto y funciona.
Cómo funciona por dentro¶
1. ?. corta la cadena completa.
Si lo de la izquierda es null o undefined, no se evalúa nada de lo que
sigue: toda la expresión vale undefined.
passenger1.children?.length // undefined: no se lee length
passenger2.children?.length // 1
2. ?. también sirve con índices y funciones.
const firstChild = passenger.children?.[0] // el primer hijo, o undefined
const result = callback?.() // llama a la función sólo si existe
const city = user.address?.city // varios niveles: si falta address, undefined
No sirve para asignar: user.address?.city = 'Novigrad' da error. Para
escribir, primero hay que comprobar que address existe.
3. ! desaparece al ejecutar.
El ! es una nota para TypeScript, como los tipos. En el JavaScript que corre
en el navegador, passenger.children!.length es passenger.children.length:
no comprueba nada. Por eso esto compila, pero falla al ejecutar:
returnChildrenNumber(passenger1) // compila, pero falla: passenger1 no tiene children
Es justo el caso que resuelve ?.: printChildren(passenger1) imprime 0 en
vez de fallar.
|| o ??¶
Las dos ponen un valor por defecto. La diferencia es cuándo lo ponen:
| Operador | Usa el valor de la derecha cuando lo de la izquierda es |
|---|---|
\|\| |
Cualquier cosa que cuente como falsa: undefined, null, 0, '', false |
?? |
Sólo null o undefined |
const price = 0
price || 10 // 10: el 0 cuenta como falso y se reemplaza
price ?? 10 // 0: el 0 es un valor real y se respeta
En el archivo, || 0 funciona bien: si hay un array vacío, length es 0 y el
resultado sigue siendo 0. Pero cuando 0 o '' son valores válidos (un
precio, una cantidad, un texto vacío a propósito), ?? es la opción segura.
Ver también el Módulo 3.
?. o !: cuándo usar cada uno¶
| Situación | Usa |
|---|---|
| El dato puede faltar y está bien que falte | ?. (con ?? si hace falta un valor) |
| Ya comprobaste que existe, pero TypeScript no lo ve | Mejor un if; ! como último recurso |
| "Estoy casi seguro de que existe" | No uses !: usa ?. o un if |
Un if es más claro que un !, porque TypeScript sí entiende la
comprobación:
if (passenger.children) {
console.log(passenger.children.length) // aquí TypeScript sabe que existe
}
No confundas este ! con el de las propiedades de clase (value!: string,
Módulo 8). Los dos dicen "confía en mí", pero sobre cosas distintas: aquí,
que un valor existe; allá, que una propiedad tendrá valor aunque el
constructor no se lo dé.
Comparación con Angular¶
En los templates de Angular se usan los mismos operadores:
<p>{{ passenger().name }} tiene {{ passenger().children?.length ?? 0 }} hijos</p>
Para mostrar un bloque sólo si el dato existe, lo habitual es @if, que además
le dice a Angular que dentro del bloque el dato ya existe:
@if (passenger().children; as children) {
<p>Primer hijo: {{ children[0] }}</p>
}
Y aparecen mucho al leer datos que llegan de una API, donde casi todo puede
faltar: response.data?.items ?? [].
Errores comunes¶
- Usar
!para callar a TypeScript cuando el dato de verdad puede faltar. El error no desaparece: aparece al ejecutar. - Usar
||cuando0o''son valores válidos: se reemplazan sin querer. - Poner
?.en todos lados "por si acaso". Si un dato siempre existe, el?.confunde a quien lee, porque sugiere que puede faltar. - Intentar asignar con
?.(user.address?.city = 'x'): no se puede. - Olvidar que
?.devuelveundefined: el resultado hay que manejarlo, por ejemplo con??.
Resumen¶
? en una interfaz marca una propiedad opcional. ?. lee algo que puede
faltar sin que el programa falle: si falta, devuelve undefined. ?? (o
||) pone un valor por defecto en ese caso. ! le dice a TypeScript que el
dato existe, pero no lo comprueba: si te equivocas, falla al ejecutar. Ante la
duda, ?. o un if.
Preguntas de entrevista (senior)¶
1. ¿Qué diferencia hay entre a?.b, a!.b y a && a.b?¶
Respuesta:
a?.b devuelve undefined si a es null o undefined, y si no, lee b.
a!.b lee b sin comprobar nada: el ! sólo convence a TypeScript y, si a
falta, el programa falla. a && a.b es la forma antigua de a?.b, con una
diferencia: si a es 0, '' o false, devuelve ese valor en vez de
undefined, porque && mira si algo cuenta como falso, no si es null o
undefined. En código moderno se prefiere ?., que es más corto y más
preciso.
2. ¿Cuándo está justificado usar !?¶
Respuesta:
Cuando sabes algo que TypeScript no puede deducir y no hay una forma
razonable de expresarlo. Por ejemplo, document.querySelector('#app')! en
main.ts: el elemento está en el HTML, pero TypeScript no lee el HTML. Aun
así, en código de aplicación conviene preferir un if que maneje el caso, o
lanzar un error con un mensaje claro si el dato falta. Un ! es una promesa
sin verificación; si un día deja de cumplirse, el error aparece lejos de su
causa. En revisiones de código, cada ! merece una pregunta: "¿por qué
estamos seguros?".
3. passenger.children?.length || 0 vs passenger.children?.length ?? 0: ¿importa la diferencia?¶
Respuesta:
En este caso no: si children es un array vacío, length es 0, y las dos
dan 0. Importa cuando el valor de la izquierda puede ser 0, '' o false
y esos valores son válidos. Por ejemplo, settings.volume || 50 convierte un
volumen de 0 en 50, y settings.volume ?? 50 lo respeta. Por eso la
costumbre segura es ??, y || sólo cuando de verdad quieres reemplazar
cualquier valor "vacío".
4. ¿Cómo cambia el tipo de una expresión con ?.?¶
Respuesta:
Le suma undefined. passenger.children?.length es number | undefined,
aunque length siempre sea un número, porque la cadena puede cortarse antes.
TypeScript obliga a manejar ese undefined antes de usar el resultado como
número: con ??, con un if, o pasándolo a algo que acepte undefined. Es
la misma regla de las propiedades opcionales: children?: string[] es
string[] | undefined. En strict, este undefined explícito es lo que
evita la mayoría de los fallos por datos que faltan.
5. Una respuesta de API tiene muchos campos opcionales y el código se llena de ?.. ¿Qué haces?¶
Respuesta:
Normalizar en el borde. En vez de arrastrar response.data?.user?.address?.city
por toda la aplicación, se transforma la respuesta una sola vez, al recibirla,
en un tipo propio sin opcionales, con valores por defecto donde tenga sentido
(items ?? [], name ?? 'Sin nombre'). El resto del código trabaja con datos
completos y no necesita ?.. Si un campo puede faltar de verdad y eso
significa algo (un usuario sin dirección), el tipo lo dice explícitamente y se
maneja en un solo lugar.
Referencias del repositorio¶
01-typescript-intro/README.md: cómo ejecutar el proyecto y agregar lecciones.01-typescript-intro/tsconfig.json: reglas del compilador.01-typescript-intro/src/topics/: un archivo por módulo de esta página.- Hoja de atajos de Angular: referencia rápida de sintaxis, con lo que es legacy y su forma actual.