Saltar a contenido

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 .ts en 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 carpeta dist/.

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:

  • Promise y fetch(): 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() y Array.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 dev levanta Vite. Vite no revisa tipos: si hay un error de tipos, la página carga igual.
  • npx tsc es quien revisa tipos. Ejecútalo seguido.
  • TypeScript 6.0 activa el modo strict por defecto, aunque tsconfig.json no lo diga. Eso incluye noImplicitAny y strictNullChecks.
  • noUnusedLocals está activo: una variable de ejemplo que no se usa rompe el build. Por eso cada tópico termina con export { ... }.

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. Incluye noImplicitAny y strictNullChecks.
  • erasableSyntaxOnly: el proyecto original lo traía activo y se quitó a propósito para poder usar enum.

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.json tiene dependencies (los paquetes @angular/*, rxjs) y scripts como "start": "ng serve" y "build": "ng build".
  • La configuración de TypeScript se reparte: un tsconfig.json base y otros que lo extienden (tsconfig.app.json para la aplicación, tsconfig.spec.json para las pruebas).
  • tsconfig.json tiene además una sección angularCompilerOptions, que configura el compilador de templates de Angular (por ejemplo, strictTemplates para revisar tipos dentro del HTML).
  • angular.json es el equivalente a la configuración de Vite: dice cómo compilar, servir y probar.

Errores comunes

  • Subir node_modules/ a git, o no subir package-lock.json.
  • Editar package-lock.json a mano.
  • Usar npm install en CI: puede actualizar el lockfile en lugar de fallar.
  • Creer que lib agrega funciones al navegador. Sólo le dice a TypeScript que existen.
  • Instalar con npm install -g herramientas que el proyecto ya declara: se ejecutan con npx o con npm 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 tus devDependencies.
  • Servidores y Docker: npm ci --omit=dev instala sólo dependencies, 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.json de las dependencias. Sólo respeta el del proyecto raíz (o un npm-shrinkwrap.json).
  • Que npm install no lo cambie: si package.json se modificó, npm install vuelve 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 (o node16): 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 sea utils.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, tsc reescribe 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 (esnext para módulos ES, commonjs para el require antiguo 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 | string es «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'
  • LifeStatus es una unión de tres tipos literales. Sólo esos tres textos son válidos.
  • hpPoints acepta cualquier number o uno de esos tres textos.
  • hpPoints = 'FUL' daría error en tsc.

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 any para "salir del paso". Usa unknown y 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 number distingue enteros de decimales. Es un único tipo de punto flotante (0.1 + 0.2 !== 0.3). Para enteros grandes existe bigint.

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 como erasableSyntaxOnly o el modo de "sólo quitar tipos" de Node la rechazan. Un enum de textos no acepta el texto literal ('FULL'), sólo LifeStatusEnum.FULL, lo que complica recibir datos de una API. Un enum numérico rechaza un literal fuera de rango (const e: E = 42 da error desde TypeScript 5.0), pero sigue aceptando cualquier variable de tipo number.
  • 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 son string.
  • 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ón string[] obliga a que todos sean texto.
  • geralt no tiene age al crearse, y es válido porque age es opcional.
  • geralt.age = 100 funciona aunque geralt sea const: const impide 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 const hace el objeto inmutable. Para eso existe readonly (readonly name: string, readonly string[]) o Object.freeze en 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 revisar undefined en 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 vale undefined.
  • 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 return es implícito (=> a + b). Con llaves hay que escribir return.
  • Las comillas invertidas son un template literal: ${a + b} inserta el resultado dentro del texto.
  • La diferencia importante con function es this: una función flecha no tiene su propio this, 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 es null o undefined.
  • 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 undefined a 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: () => void dice: "este objeto tiene una propiedad showHp que es una función sin parámetros y cuyo resultado no se usa".
  • Todo objeto declarado como Character está obligado a tener showHp. Si falta, tsc da error al crear el objeto, y por eso geralt.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 += amount da error porque healtPoints no existe en Character. En JavaScript esa línea crearía una propiedad nueva con NaN y 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. healCharacter no devuelve nada: modifica directamente el geralt que 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_HP reemplaza el 100 escrito 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 this para leer su estado. Si pasas un método como callback (setTimeout(this.save, 0)), pierde su this; 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 || 1 convierte un 0 legítimo en 1, y multiply(2, 0) daría 4 en lugar de 0.
  • Pensar que un parámetro opcional ya tiene valor. Dentro de la función es number | undefined y hay que manejar el undefined.
  • Escribir una flecha con llaves y olvidar el return: (a, b) => { a + b } devuelve undefined.
  • Definir un método de objeto como flecha (showHp: () => console.log(this.name)): this no es el objeto.
  • Comparar con === contra un límite (hp === 100) cuando lo que se quiere es un tope: usa >= y Math.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 el this del lugar donde se escribió (a eso se le llama this léxico); la tradicional lo recibe según cómo se la llama. Por eso un método pasado como callback pierde su this y una flecha no.
  • arguments: la flecha no tiene su propio objeto arguments; se usan parámetros rest (...args).
  • new: una flecha no se puede usar como constructor y no tiene prototype.
  • Hoisting: una declaración function se puede llamar antes de la línea donde está escrita; una flecha asignada a const no.

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 vale number | undefined.
  • secondNumber: number | undefined: el que llama debe pasarlo, aunque sea undefined explícito.
  • secondNumber = 1: el que llama puede omitirlo y dentro siempre es number. 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.ts sin 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(): this es geralt.
  • const fn = geralt.showHp; fn(): this es undefined (en módulos y modo estricto), y this.name falla en runtime.
  • fn.call(otro) o fn.bind(otro): this es otro.
  • En una flecha, this es 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 SuperHero con name (string), age (number), address (un objeto con street, country y city) y un método showAddress que devuelva un texto con el nombre y la dirección del héroe. Después crear un objeto que cumpla la interfaz y llamar a showAddress.

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.
  • superHeroe mezcla inglés y español. Siguiendo la regla de código en inglés, sería superHero.
  • 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> o Partial<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 es undefined, y una posición que no existe es undefined.
  • Un detalle de tipos: como dbz es string[] (un array de largo desconocido), TypeScript le da a first, second y third el tipo string, 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 details fuera opcional, const { details: { author } } = obj falla. TypeScript lo avisa y, si se ignora, el programa falla al ejecutar porque no se puede sacar author de algo que no existe.
  • Creer que desestructurar copia los objetos. const { details } = audioPlayer copia la referencia: si modificas details.author, cambia también audioPlayer.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:

  1. Muchos parámetros. taxCalculation(products, 0.15, true, 'USD') no se entiende sin abrir la función: ¿qué es true? ¿y si cambio el orden?
  2. 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) con const { 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 (15000 en lugar de 150.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 pipe currency de Angular), no con toFixed dentro 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 const da readonly [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 String o Number (mayúscula) como tipos.
  • Esperar que const p = [1, 2] sea una tupla.
  • Confiar en que [number, number] impide push: hace falta readonly.
  • Recorrer un enum numérico con Object.keys o Object.values y 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 ser number | undefined y obliga a revisarlo. También afecta a los arrays (arr[0] pasa a T | undefined), por eso no está incluido en strict.
  • Usar un Map y map.get('laptop'), que ya devuelve number | 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 import o export en 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 en node_modules.
  • La ruta va sin extensión. Funciona porque el proyecto usa moduleResolution: "bundler": Vite busca el .ts correspondiente.

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 import normal de TypeScript.
  • En un componente standalone, el import de TypeScript no alcanza: además hay que agregar el componente al arreglo imports: [...] 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), no export default.

Errores comunes

  • Dejar console.log u otro código suelto en un archivo que se importa.
  • Olvidar el type al 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: a importa b y b importa a (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: ./x no se carga.
  • import { type X } from './x' borra X, pero deja import {} from './x': ./x se 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 new y 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 palabra public o private delante del parámetro crea la propiedad y le asigna el valor. Ver la pregunta 4. Por eso las tres líneas this.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 escribir console.log(hero.showAddress()).
  • Fíjate en la salida: address aparece aunque sea private. La razón está en La trampa de private, 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:

  • Hero no necesita saber cómo se crea una Person; sólo la usa.
  • Se puede pasar cualquier Person: una real, una de prueba, una compartida con otro objeto.
  • Si mañana Person cambia su constructor, Hero no 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:

  1. La relación "es un" es real y no va a cambiar: un SuperHero siempre será una Person.
  2. 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 un SuperHero.

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
  • readonly se 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 = 2 da error, porque id es de sólo lectura.
  • public es el valor por defecto: escribirlo es opcional. El archivo lo usa para que la intención quede explícita.
  • showAddress es 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:

  • private protege el código que escribes: TypeScript no te deja usar address fuera de la clase. Eso funciona: hero.address da error.
  • Pero no oculta el dato. private es una palabra de TypeScript y desaparece cuando el código se convierte a JavaScript (ver la Introducción). Al ejecutarse, address es una propiedad común dentro del objeto, y console.log muestra 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
}
  • private para lo que sólo usa el código de la clase (servicios inyectados, detalles internos).
  • protected para lo que el template necesita leer pero otras clases no deberían tocar. Desde Angular 14, los templates pueden usar miembros protected.
  • readonly para dependencias y signals que no se reasignan (el signal cambia su valor con set/update, pero la propiedad sigue apuntando al mismo signal).

Errores comunes

  • Creer que private oculta 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 un undefined como si fuera un texto.
  • Poner valores fijos en el constructor (todas las Person son 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 | undefined o ? sobran.
  • Asignar valores fijos en el cuerpo del constructor después de recibir parámetros: pisan lo que se pasó en new.
  • Usar this antes de super() en una subclase.
  • Esperar que una subclase acceda a una propiedad private del padre: hace falta protected.
  • 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 pasar undefined. 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 (extends admite una sola clase). Si SuperHero necesita 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. amIString es string, por eso acepta .toUpperCase(). amINumber es number: acepta .toFixed(2), pero amINumber.toUpperCase() sería un error.
  • amIArray es number[], así que tiene .length y .join(). amIObject tiene .name y .age, y el editor los sugiere al escribir el punto.
  • never es el tipo de un valor que no puede existir, así que no hay un valor real para pasar. undefined as never lo fuerza: TypeScript confía en el as, pero el valor sigue siendo undefined, 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 T aparece una sola vez (function log<T>(value: T): void). No conecta nada con nada: unknown basta.
  • Esperar que http.get<T>() o as validen los datos. Sólo cambian lo que cree el compilador.
  • Intentar leer T al ejecutar (if (T === string)). Los tipos no existen en el navegador.
  • Confundir el extends de <T extends …> (requisito) con el extends de 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:

  1. @classDecorator llama a classDecorator(SuperClass). Pasa una sola vez, cuando se define la clase, no cada vez que se hace new.
  2. La función devuelve class extends constructor { ... }: una clase hija (subclase) de SuperClass, sin nombre, con dos propiedades más.
  3. Desde ahí, el nombre SuperClass apunta a esa clase hija. Por eso new SuperClass() crea un objeto con las tres propiedades: myProperty (heredada del padre), newProperty y hello (de la hija).
  4. myInstance.print() funciona: print se 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. SuperClass no tiene hello, así que sólo se agrega. Para sobrescribir, la clase padre tendría que tener su propio hello.
  • TypeScript no se entera del cambio. Sigue creyendo que SuperClass es la clase original. myInstance.newProperty existe 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 @classDecorator tal cual y la página falla en el navegador, aunque el editor no marque nada.
  • Con "experimentalDecorators": true en tsconfig.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 experimentalDecorators en este proyecto: el editor no avisa y la página falla.
  • Escribir @Component sin 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 eso passenger1 es válido sin children. Su tipo es string[] | undefined, y TypeScript no te deja usarla como array hasta comprobar que existe.
  • passenger.children?.length: si children no existe, devuelve undefined en vez de fallar. Si existe, lee length como siempre. El resultado es number | undefined.
  • || 0: reemplaza ese undefined por 0, así howManyChildren siempre es un número y el mensaje nunca dice "undefined children".
  • passenger.children!.length: el ! le dice a TypeScript que children existe. Con passenger3 es 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 || cuando 0 o '' 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 ?. devuelve undefined: 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.