Skip to content

study guide

Janio Isacura edited this page Oct 5, 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

Ruta recomendada

Etapa Tema Resultado esperado
0 Qué es TypeScript y cómo corre el proyecto Saber qué hace tsc y qué hace Vite
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

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.


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.


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