Skip to content

study guide

Janio Isacura edited this page Oct 6, 2026 · 44 revisions

Guía de estudio: Angular UFH

Guía para aprender TypeScript y Angular desde cero, siguiendo los proyectos del repositorio. No reemplaza la documentación oficial: explica qué problema resuelve cada concepto, cómo funciona por dentro y cómo se ve en el código del curso.

Regla de lectura: primero ejecuta el ejemplo del repositorio, después lee la teoría del módulo y al final intenta responder las preguntas de entrevista sin mirar la respuesta.

Cómo usar esta guía

  1. Cada proyecto del repositorio (01-typescript-intro/, …) es una parte de esta guía.
  2. Cada archivo de src/topics/ es un módulo. El número del módulo coincide con el número del archivo.
  3. Cada módulo termina con preguntas de entrevista de nivel senior, cada una con su respuesta debajo. Intenta responder antes de leerla.
  4. Si algo no te queda claro, cámbialo en el archivo del tópico, ejecuta npx tsc y mira qué error aparece. El compilador es el mejor profesor. (tsc es el compilador de TypeScript; la Introducción explica qué es y qué hace.)

Mapa general

angular-ufh/
└── 01-typescript-intro/        Parte 1: TypeScript sin framework
    └── src/topics/
        ├── 01-basic-types.ts             Módulo 1: tipos básicos
        ├── 02-object-interface.ts        Módulo 2: arrays e interfaces
        ├── 03-functions.ts               Módulo 3: funciones
        ├── 04-homework-types.ts          Módulo 4: tarea, interfaces anidadas
        ├── 05-basic-destructuring.ts     Módulo 5: desestructuración
        ├── 06-function-destructuring.ts  Módulo 6: parámetro objeto y tuplas
        │                                 + Tipos de datos: primitivos, tuplas y enums
        ├── 07-import-export-modules.ts   Módulo 7: módulos, import y export
        └── 08-classes.ts                 Módulo 8: clases

Ruta recomendada

Etapa Tema Resultado esperado
0 Qué es TypeScript y cómo corre el proyecto Saber qué hace tsc y qué hace Vite
0+ package.json, lockfile y tsconfig.json Leer la configuración e instalar de forma reproducible
1 Tipos básicos, literales y uniones Leer y escribir anotaciones de tipo sin dudar
2 Arrays tipados e interfaces Describir la forma de un objeto y entender los errores
3 Funciones y parámetros Tipar parámetros y retornos, opcionales y por defecto
4 Tarea: interfaces anidadas Componer interfaces y nombrar tipos según convención
5 Desestructuración de objetos y arrays Extraer, renombrar y anidar valores sin errores
6 Parámetro objeto y tuplas de retorno Diseñar funciones con muchas entradas o salidas
6+ Tipos de datos: primitivos, tuplas, enums Elegir entre array, tupla, enum, unión y diccionario
7 Módulos, import y export Repartir código en archivos sin efectos inesperados
8 Clases, public y private Crear objetos desde una clase y proteger su estado

Qué es Angular

Angular es un framework opinionado: ya decidió por ti cómo se enruta, cómo se hacen los formularios, cómo se pide información a una API y cómo se organiza el código. Eso promueve orden y legibilidad, porque dos proyectos de Angular se parecen mucho entre sí.

Con los años se volvió más flexible. Antes todo componente tenía que declararse dentro de un módulo (NgModule); hoy los componentes son standalone: cada uno declara lo que necesita y los módulos son opcionales.

Decoradores

Un decorador es una etiqueta que empieza con @ y se pone encima de una clase (o de una propiedad) para decirle a Angular qué es y cómo usarla. La clase sola es TypeScript normal; el decorador es lo que la convierte en componente, servicio o directiva.

@Component({
  selector: 'app-hero',
  template: `<h1>{{ name }}</h1>`
})
export class Hero {
  name = 'Geralt'
}

Sin @Component, Hero es una clase cualquiera. Con él, Angular sabe que es una pieza de pantalla, con qué etiqueta HTML se usa (<app-hero>) y qué dibuja.

Decorador Qué le dice a Angular
@Component Es una pieza de pantalla, con su template
@Directive Agrega comportamiento a un elemento que ya existe
@Pipe Transforma un valor para mostrarlo ({{ price | currency }})
@Injectable Es un servicio: Angular puede crearlo y entregarlo a quien lo pida
@Input / @Output Datos que entran al componente / eventos que salen

Por qué ayudan a leer el código: abres un archivo y, con sólo ver el decorador, ya sabes qué papel cumple esa clase.

Por dentro, un decorador es una función que recibe la clase y le adjunta información (metadata). Angular lee esa información al compilar.

Ojo: en el Angular actual varios decoradores de propiedad tienen un reemplazo en forma de función (input() y output() en lugar de @Input y @Output). Los de clase (@Component, @Injectable, @Directive, @Pipe) se siguen usando igual.


Mitos sobre Angular

Antes de empezar conviene despejar algunas ideas que se repiten mucho y que no son ciertas, o que ya dejaron de serlo.

Mito 1: «Angular es mejor (o peor) que React, Vue, Svelte o Solid»

Realidad: no, son herramientas distintas. Angular es un framework opinionado: ya trae resuelto el enrutado, los formularios y las peticiones HTTP, y te dice cómo organizar el código. React es una librería, y Vue, Svelte y Solid son frameworks más livianos: dan más libertad, pero dejan más decisiones en manos del equipo. Ninguno es mejor en general; depende de lo que necesite tu proyecto.

Ojo: la herramienta no garantiza el orden. La estructura de carpetas y la disciplina dependen del equipo, y también hay proyectos de Angular desordenados.

Mito 2: «Hay que volver a aprender Angular cada 6 meses»

Realidad: no. Angular publica una versión mayor cada seis meses, pero la mayoría de los cambios no son disruptivos. Y cuando lo son, no hay que aprender de nuevo todo el framework: lo que ya sabes sigue sirviendo. Además, ng update hace buena parte de la migración por ti, la documentación oficial es muy buena y la comunidad es grande, así que ponerse al día cuesta poco.

Mito 3: «Las aplicaciones de Angular son muy pesadas»

Realidad: cada versión reduce el bundle (lo que el navegador tiene que descargar). Hoy el peso depende sobre todo de ti y de las buenas prácticas que apliques.

Para que pese menos:

  • Lazy loading de rutas. Cada pantalla se descarga sólo cuando alguien la visita, en lugar de bajar todo al entrar: { path: 'heroes', loadComponent: () => import('./heroes/heroes').then(m => m.Heroes) }.
  • @defer en el template. Lo mismo, pero para un pedazo de pantalla: un gráfico pesado se descarga recién cuando aparece en el área visible.
  • Cuidar las dependencias. Cada librería que instalas viaja al navegador. Importa sólo lo que usas y desconfía de las librerías enormes para tareas pequeñas.
  • Componentes compartidos. Un botón o una tarjeta se escriben una vez y se reutilizan; copiar y pegar el mismo código en diez pantallas lo descarga diez veces.
  • Budgets. En angular.json se define un tamaño máximo; si el build lo supera, avisa o falla. Así el peso no crece sin que nadie lo note.

Para que no se vuelva lenta con el uso (no cambia el peso, sí la experiencia):

  • Cerrar las suscripciones. Una suscripción abierta sigue trabajando aunque el componente ya no esté en pantalla, y la memoria nunca se libera. Se evita con el pipe async en el template o con takeUntilDestroyed().
  • Respetar el ciclo de vida. Lo que el componente abre (un temporizador, un listener) lo cierra al destruirse (ngOnDestroy o DestroyRef).
  • Signals u OnPush. Angular revisa sólo los componentes cuyos datos cambiaron, en lugar de revisar todos.
  • track en @for. Al cambiar una lista, Angular reutiliza los elementos que ya estaban en pantalla en lugar de dibujarlos de nuevo.

Mito 4: «Angular no es SEO friendly»

Realidad: sí lo es. Angular puede armar la página en el servidor (SSR) o dejarla armada de antemano al compilar (prerender). En los dos casos Google recibe HTML con el contenido ya puesto, no una página vacía que depende de JavaScript. Después, en el navegador, Angular «hidrata» esa página: la vuelve interactiva sin dibujarla otra vez.

De dónde viene el mito. Una aplicación clásica de Angular se dibuja en el navegador (CSR: client-side rendering, «renderizado del lado del cliente»). El servidor manda un HTML casi vacío, apenas <app-root></app-root> y los scripts, y el contenido aparece recién cuando el JavaScript se descarga y corre. Un robot que lee ese HTML sin ejecutar JavaScript no ve nada. Google sí ejecuta JavaScript, pero lo hace más tarde y con límites; las vistas previas de redes sociales y apps de mensajería directamente no lo ejecutan.

Las tres formas de dibujar la página:

CSR         servidor manda HTML vacío ------> el navegador baja el JS -> el JS dibuja la página
SSR         servidor dibuja en cada visita -> manda HTML completo ----> hidratación
Prerender   se dibuja una vez al compilar --> se guarda como .html ---> hidratación
  • SSR (server-side rendering, «renderizado del lado del servidor»): sirve cuando el contenido cambia seguido o depende de quién visita (una tienda, un perfil público).
  • Prerender (también llamado SSG): sirve cuando el contenido casi no cambia (un blog, una página de presentación). Es lo más rápido, porque el HTML ya está hecho.
  • CSR: alcanza para pantallas detrás de un login, donde el SEO no importa.

Qué es la hidratación. El HTML que llega del servidor se ve, pero todavía no responde: los botones no hacen nada. Hidratar es el momento en que Angular, ya en el navegador, reconoce ese HTML que está en pantalla y le conecta los eventos y el estado. No lo borra ni lo dibuja otra vez; antes de la versión 16 sí lo hacía, y la página parpadeaba.

Un beneficio extra. Como el contenido llega ya dibujado, la página se ve antes. Google también mide esa velocidad para posicionar.

Ojo: hay que activarlo (ng new --ssr al crear el proyecto, o ng add @angular/ssr después). Sin eso, la aplicación se dibuja sólo en el navegador y el HTML inicial llega casi vacío.

Mito 5: «Angular no soporta Redux»

Realidad: sí lo soporta. Redux es una forma de guardar todo el estado de la aplicación en un solo lugar, y en Angular se usa con librerías como NgRx o NGXS. Además, hoy Angular trae Signals, una opción nativa y más simple para manejar estado, que alcanza para muchas aplicaciones.

Mito 6: «En Angular sólo puedo usar TypeScript»

Realidad: también puedes usar librerías y código JavaScript, porque al final TypeScript se convierte en JavaScript para correr en el navegador.

  • Si una librería JavaScript no trae tipos, se instalan aparte con @types/nombre-libreria. Hoy la mayoría ya los incluye.
  • Puedes escribir archivos .js propios activando allowJs en tsconfig.json.

Es posible, pero se recomienda TypeScript: sin tipos pierdes el aviso de errores antes de ejecutar (ver la Introducción de la Parte 1).

Resumen

Angular no es mejor ni peor que las demás opciones, no hay que reaprenderlo cada seis meses, no es pesado por obligación, sí sirve para SEO, sí soporta Redux y sí acepta JavaScript. Casi todos estos mitos vienen de versiones antiguas del framework.


Parte 1: TypeScript sin framework

Proyecto: 01-typescript-intro/

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() // TypeError en runtime

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 TypeS*cript Compiler*: 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,

    /* 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 (error TS1484). 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 (TS7029)

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.

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: Type '"FULL"' is not assignable to type 'number | LifeStatusEnum'.
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 incluye noImplicitAny.

error TS7006: Parameter 'a' implicitly has an 'any' type.

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: Type 'void' is not assignable to type '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 TS2554: Expected 3 arguments, but got 1.

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 TS1016: A required parameter cannot follow an optional parameter.
  • 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: Object is possibly 'undefined'.
}

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 (TS7023) 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 falla con No overload matches this call, 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: Property 'city' is missing ... but required in type 'Address'.

address: { street: 's', country: 'c', city: 'x', zip: '1' }
// error: Object literal may only specify known properties, and 'zip' does not exist in type '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, tsc da Type '() => void' is not assignable to type '() => string'.

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: Cannot assign to 'name' because it is a read-only property.
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()                          // TypeError: hero.showAddress is not a function

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 TS2451: Cannot redeclare block-scoped variable 'songTitle'.

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. tsc lo avisa (Property 'author' does not exist on type 'Details | undefined') y, si se ignora, en runtime da TypeError: Cannot read properties of undefined.
  • 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 TS2493: Tuple type '[number, number]' of length '2' has no element at index '2'.

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] falla (Type 'number[]' is not assignable to type '[number, number]'). 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 (Property 'push' does not exist on type 'readonly [number, number]').

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, con herramientas que compilan archivo por archivo (Vite, esbuild) y con verbatimModuleSyntax activo, como en este proyecto, se compila igual que un enum normal, y los declare const enum directamente dan error (Cannot access ambient const enums when 'verbatimModuleSyntax' is enabled). 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 TS1484: 'Product' is a type and must be imported using a type-only import
// when 'verbatimModuleSyntax' is enabled.

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 (error TS1484 en este proyecto).
  • 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ó, en módulos ES da un error en runtime:

ReferenceError: Cannot access 'a' before initialization

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 {
  public name: string | undefined
  public age?: number
  private address: string

  constructor(name: string = 'John Connor', age: number = 13, address: string = 'Los Angeles') {
    this.name = name
    this.age = age
    this.address = address
  }

  showAddress() {
    console.log(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 TS2341
hero.showAddress()            // Kaer Morhen

Salida real (de Person; la subclase SuperHero del mismo archivo está en la sección Herencia):

Person { name: 'Geralt of Rivia', age: 100, address: 'Kaer Morhen' }
Person { name: 'John Connor', age: 13, address: 'Los Angeles' }
Kaer Morhen

Como los tres parámetros tienen valor por defecto, new Person() es válido y crea a John Connor. Sin esos valores por defecto, daría error TS2554: Expected 3 arguments, but got 0.

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: error TS2564: Property 'address' has no initializer and is not definitely assigned in the constructor.

4. Los campos existen antes de que corra el constructor. Con un target moderno (ES2022 o posterior, aquí es2023), 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 TS2554: Expected 0 arguments, but got 1.

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 TS2554: Expected 2 arguments, but got 1.

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, e imprime Baal.

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.

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. Redeclarar una propiedad heredada. public age: number vuelve a declarar age, que en Person era age?: number. Una subclase puede hacer más estricto el tipo: en un SuperHero, age siempre es un número.

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.

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 TS2540: Cannot assign to 'id' because it is a read-only property.
  • 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: sólo existe al compilar

private es una palabra de TypeScript. Al convertir a JavaScript se borra, como todos los tipos (ver la Introducción):

TypeScript                    JavaScript que llega al navegador
private address: string   ->  address          (un campo común y corriente)

Por eso:

  • console.log(hero) ya muestra address: 'Kaer Morhen'.
  • hero.address da error en tsc, pero (hero as any).address compila y devuelve el valor.
  • JSON.stringify(hero) incluye address.

Qué pasa con el error según el comando (comprobado en este proyecto):

Comando ¿Revisa tipos? Resultado si descomentas hero.address
npm run dev No La página carga y muestra la dirección
npx tsc Sí error TS2341: Property 'address' is private...
npm run build Sí (tsc primero) Falla y no genera dist/

Privado de verdad: #. JavaScript tiene sus propios campos privados, que se respetan también en runtime:

class Person {
  #address = 'Kaer Morhen'
}
// Desde afuera, hero.#address es un error de sintaxis de JavaScript:
// SyntaxError: Private field '#address' must be declared in an enclosing class

Un campo # no aparece en Object.keys ni en JSON.stringify.

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: Property 'address' is private in type 'Priv' but not in type '{ ... }'.

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 TS2564 sin garantizar el valor: el error vuelve en runtime como TypeError: Cannot read properties of undefined.
  • 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 (error TS2564). 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 (Property 'address' is private in type 'Priv' but not in type ...). 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 herencia expresa "es un": un SuperHero es una Person. La composición expresa "tiene un": un SuperHero tiene 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).


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 guía.
  • Hoja de atajos de Angular: referencia rápida de sintaxis, con lo que es legacy y su forma actual.

Clone this wiki locally