Lección 8 de 8. Un proyecto real con TypeScript, con todo lo anterior junto y en orden: migramos un programa de JavaScript a TypeScript en cuatro etapas sin dejar de poder publicarlo, encontramos dos fallos que nadie había visto, y lo dejamos como un proyecto de verdad, con tsconfig.json, módulos, validación con Zod y pruebas.
Hasta ahora cada lección ha tenido fragmentos sueltos. Esta junta todo en un programa de verdad, y lo hace como pasa en la vida real: no empezamos de cero, sino con un programa de JavaScript que ya funciona y que vamos a pasar a TypeScript sin pararlo, una etapa cada vez. Por el camino aparecerán dos fallos que nadie había visto.
El punto de partida: un programa de JavaScript
Lee los pedidos de una tienda de un archivo JSON, como en el ejemplo JSON en JavaScript, y escribe el total de cada uno con IVA y, si lo hay, el descuento del cupón. Este es pedidos.json:
[
{
"id": 1041,
"cliente": "Ana López",
"pagado": true,
"productos": [
{
"nombre": "Teclado",
"precio": 49.9,
"cantidad": 1
},
{
"nombre": "Ratón",
"precio": 19.5,
"cantidad": 2
}
]
},
{
"id": 1042,
"cliente": "Luis Pérez",
"pagado": false,
"cupon": {
"codigo": "OTOÑO",
"porcentaje": 10
},
"productos": [
{
"nombre": "Monitor",
"precio": 189,
"cantidad": 1
}
]
}
]Y este, pedidos.js, que funciona:
import { readFile } from "node:fs/promises";
const IVA = 0.21;
function subtotal(pedido) {
return pedido.productos.reduce((suma, p) => suma + p.precio * p.cantidad, 0);
}
function resumen(pedido) {
let importe = subtotal(pedido);
if (pedido.cupon) importe *= 1 - pedido.cupon.porcentaje / 100;
if (pedido.urgente) {
console.log("¡Urgente! Recibido el", new Date().toLocaleDateSting("es"));
}
return `Pedido ${pedido.id} de ${pedido.cliente}: ${(importe * (1 + IVA)).toFixed(2)} EUR`;
}
function buscar(pedidos, id) {
return pedidos.find((p) => p.id === id);
}
const pedidos = JSON.parse(await readFile(new URL("./pedidos.json", import.meta.url), "utf8"));
for (const p of pedidos) console.log(resumen(p));
console.log("Cliente del 1042:", buscar(pedidos, 1042).cliente);node pedidos.js
salidaPedido 1041 de Ana López: 107.57 EUR Pedido 1042 de Luis Pérez: 205.82 EUR Cliente del 1042: Luis Pérez
Etapa 1: // @ts-check
El primer paso no cambia nada del programa: se añade // @ts-check en la primera línea, como en la lección 1, y se instala TypeScript en el proyecto. El compilador revisa el archivo con los tipos que deduce solo, sin que hayas escrito ninguno.
El error que te ahorra: un fallo escondido en una rama que nunca se ejecuta
En esta etapa conviene revisar con el modo estricto desactivado (--strict false). Sin esa opción, TypeScript 7 añadiría un aviso por cada parámetro sin tipo (el TS7006 de la lección 4), que se resuelven en la etapa 2.
// @ts-check
import { readFile } from "node:fs/promises";
const IVA = 0.21;
function subtotal(pedido) {
return pedido.productos.reduce((suma, p) => suma + p.precio * p.cantidad, 0);
}
function resumen(pedido) {
let importe = subtotal(pedido);
if (pedido.cupon) importe *= 1 - pedido.cupon.porcentaje / 100;
if (pedido.urgente) {
console.log("¡Urgente! Recibido el", new Date().toLocaleDateSting("es"));
}
return `Pedido ${pedido.id} de ${pedido.cliente}: ${(importe * (1 + IVA)).toFixed(2)} EUR`;
}
function buscar(pedidos, id) {
return pedidos.find((p) => p.id === id);
}
const pedidos = JSON.parse(await readFile(new URL("./pedidos.json", import.meta.url), "utf8"));
for (const p of pedidos) console.log(resumen(p));
console.log("Cliente del 1042:", buscar(pedidos, 1042).cliente);npx tsc --noEmit --allowJs --strict false --types node --locale es pedidos.js
tscpedidos.js(14,53): error TS2551: La propiedad "toLocaleDateSting" no existe en el tipo "Date". ¿Quería decir "toLocaleDateString"?
Ninguno de los pedidos de prueba es urgente, así que esa línea no se había ejecutado nunca, y el programa daba la impresión de estar bien. El primer pedido urgente de verdad lo habría tumbado con un TypeError. El compilador lo encuentra sin ejecutar nada, porque sabe que new Date() es un Date y que Date no tiene ese método.
Fíjate en lo que no ha encontrado: todo lo que depende de pedido. Como sale de JSON.parse, que devuelve any, el compilador no sabe qué tiene dentro y no puede revisarlo. Para eso hacen falta tipos.
Etapa 2: renombrar a .ts y escribir los tipos
Ahora sí: el archivo pasa a llamarse pedidos.ts, se describen los datos con interfaces (lección 5) y se anotan los parámetros de las funciones (lección 4). En un proyecto grande, esta etapa se hace archivo por archivo y con el modo estricto desactivado (--strict false), para que el primer día no salgan cientos de errores a la vez:
import { readFile } from "node:fs/promises";
interface Producto { nombre: string; precio: number; cantidad: number }
interface Cupon { codigo: string; porcentaje: number }
interface Pedido {
id: number;
cliente: string;
pagado: boolean;
urgente?: boolean;
cupon?: Cupon;
productos: Producto[];
}
const IVA = 0.21;
function subtotal(pedido: Pedido): number {
return pedido.productos.reduce((suma, p) => suma + p.precio * p.cantidad, 0);
}
function resumen(pedido: Pedido): string {
let importe = subtotal(pedido);
if (pedido.cupon) importe *= 1 - pedido.cupon.porcentaje / 100;
if (pedido.urgente) {
console.log("¡Urgente! Recibido el", new Date().toLocaleDateString("es"));
}
return `Pedido ${pedido.id} de ${pedido.cliente}: ${(importe * (1 + IVA)).toFixed(2)} EUR`;
}
function buscar(pedidos: Pedido[], id: number): Pedido | undefined {
return pedidos.find((p) => p.id === id);
}
const pedidos: Pedido[] = JSON.parse(await readFile(new URL("./pedidos.json", import.meta.url), "utf8"));
for (const p of pedidos) console.log(resumen(p));
console.log("Cliente del 1042:", buscar(pedidos, 1042).cliente);Ábrelo en el editor y cámbialo →
npx tsc --noEmit --strict false --types node pedidos.ts
tsc(sin errores)
salidaPedido 1041 de Ana López: 107.57 EUR Pedido 1042 de Luis Pérez: 205.82 EUR Cliente del 1042: Luis Pérez
Etapa 3: el modo estricto
Con los tipos en su sitio, se activa el modo estricto, que en TypeScript 7 es lo normal:
import { readFile } from "node:fs/promises";
interface Producto { nombre: string; precio: number; cantidad: number }
interface Cupon { codigo: string; porcentaje: number }
interface Pedido {
id: number;
cliente: string;
pagado: boolean;
urgente?: boolean;
cupon?: Cupon;
productos: Producto[];
}
const IVA = 0.21;
function subtotal(pedido: Pedido): number {
return pedido.productos.reduce((suma, p) => suma + p.precio * p.cantidad, 0);
}
function resumen(pedido: Pedido): string {
let importe = subtotal(pedido);
if (pedido.cupon) importe *= 1 - pedido.cupon.porcentaje / 100;
if (pedido.urgente) {
console.log("¡Urgente! Recibido el", new Date().toLocaleDateString("es"));
}
return `Pedido ${pedido.id} de ${pedido.cliente}: ${(importe * (1 + IVA)).toFixed(2)} EUR`;
}
function buscar(pedidos: Pedido[], id: number): Pedido | undefined {
return pedidos.find((p) => p.id === id);
}
const pedidos: Pedido[] = JSON.parse(await readFile(new URL("./pedidos.json", import.meta.url), "utf8"));
for (const p of pedidos) console.log(resumen(p));
console.log("Cliente del 1042:", buscar(pedidos, 1042).cliente);npx tsc --noEmit --types node --locale es pedidos.ts
tscpedidos.ts(35,34): error TS2532: El objeto es posiblemente "undefined".
Segundo fallo escondido: buscar devuelve Pedido | undefined, y el programa usa el resultado como si siempre existiera. Funciona con el 1042 porque está en el archivo; con un número que no esté, revienta. Sin el modo estricto, undefined cabe en cualquier tipo y el compilador no lo ve. Se arregla como en la lección 3:
import { readFile } from "node:fs/promises";
interface Producto { nombre: string; precio: number; cantidad: number }
interface Cupon { codigo: string; porcentaje: number }
interface Pedido {
id: number;
cliente: string;
pagado: boolean;
urgente?: boolean;
cupon?: Cupon;
productos: Producto[];
}
const IVA = 0.21;
function subtotal(pedido: Pedido): number {
return pedido.productos.reduce((suma, p) => suma + p.precio * p.cantidad, 0);
}
function resumen(pedido: Pedido): string {
let importe = subtotal(pedido);
if (pedido.cupon) importe *= 1 - pedido.cupon.porcentaje / 100;
if (pedido.urgente) {
console.log("¡Urgente! Recibido el", new Date().toLocaleDateString("es"));
}
return `Pedido ${pedido.id} de ${pedido.cliente}: ${(importe * (1 + IVA)).toFixed(2)} EUR`;
}
function buscar(pedidos: Pedido[], id: number): Pedido | undefined {
return pedidos.find((p) => p.id === id);
}
const pedidos: Pedido[] = JSON.parse(await readFile(new URL("./pedidos.json", import.meta.url), "utf8"));
for (const p of pedidos) console.log(resumen(p));
console.log("Cliente del 1042:", buscar(pedidos, 1042)?.cliente ?? "no existe");console.log("Cliente del 1042:", buscar(pedidos, 1042)?.cliente ?? "no existe");salidaPedido 1041 de Ana López: 107.57 EUR Pedido 1042 de Luis Pérez: 205.82 EUR Cliente del 1042: Luis Pérez
La frontera: los tipos no comprueban los datos
El programa ya compila en modo estricto, pero tiene un punto débil que ningún compilador puede cubrir. const pedidos: Pedido[] = JSON.parse(...) no comprueba nada: es una promesa que le haces al compilador. Si el archivo trae otra cosa, los tipos mienten:
interface Producto { nombre: string; precio: number; cantidad: number }
const datos = '[{ "nombre": "Ratón", "precio": 19.5 }]'; // al JSON le falta la cantidad
const productos: Producto[] = JSON.parse(datos); // compila: JSON.parse devuelve any
const total = productos.reduce((s, p) => s + p.precio * p.cantidad, 0);
console.log("Total:", total);Ábrelo en el editor y cámbialo →
salidaTotal: NaN
Recuerda la lección 1: los tipos se borran, y al ejecutar no queda nada que compruebe la forma de los datos. Todo lo que entra de fuera (archivos, respuestas de una API, formularios, variables de entorno) tiene que validarse al ejecutar. Se puede hacer a mano con guardas de tipo, como en la lección 3, pero para objetos con muchos campos lo práctico es una biblioteca. La más usada es Zod: describes el esquema una vez y te da a la vez la validación y el tipo.
El proyecto ordenado
Para terminar, el programa se reparte en módulos, se configura con un tsconfig.json y se le añaden pruebas. Esta es la estructura final:
pedidos/
├── package.json
├── tsconfig.json
├── datos/
│ ├── pedidos.json
│ └── pedidos-mal.json
└── src/
├── modelo.ts esquemas de Zod y tipos
├── calculos.ts subtotal, total y resumen
├── calculos.test.ts pruebas
└── main.ts lee, valida y escribenpm init -y npm install zod npm install --save-dev typescript @types/node
tsconfig.json
En vez de repetir las opciones en cada orden, se escriben una vez en tsconfig.json, y a partir de ahí basta con npx tsc. Esta configuración está pensada para un proyecto que se ejecuta con Node 24 directamente, sin compilar a JavaScript:
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}| Opción | Qué hace |
|---|---|
target, module | la versión de JavaScript y el sistema de módulos; nodenext sigue las reglas de Node |
types: ["node"] | carga los tipos de Node (process, node:fs…); en TypeScript 7 ya no se cargan solos |
strict | el modo estricto; en la 7 viene activado, pero escribirlo deja clara la intención |
noUncheckedIndexedAccess | lista[i] puede ser undefined; no entra en strict, y caza muchos errores |
noEmit | tsc solo revisa; quien ejecuta es Node |
allowImportingTsExtensions | permite escribir ./modelo.ts en los import, que es lo que Node necesita |
erasableSyntaxOnly | prohíbe lo que Node no sabe borrar: enum, propiedades de parámetro… |
verbatimModuleSyntax | obliga a marcar con import type lo que solo es un tipo |
include | qué carpetas revisa |
{
"name": "pedidos",
"private": true,
"type": "module",
"scripts": {
"check": "tsc",
"test": "node --test"
}
}El modelo, con Zod
import * as z from "zod";
export const Producto = z.object({
nombre: z.string(),
precio: z.number().positive(),
cantidad: z.number().int().positive(),
});
export const Pedido = z.object({
id: z.number().int(),
cliente: z.string(),
pagado: z.boolean(),
urgente: z.boolean().optional(),
cupon: z.object({ codigo: z.string(), porcentaje: z.number().min(0).max(100) }).optional(),
productos: z.array(Producto).min(1),
});
export const Pedidos = z.array(Pedido);
// El tipo sale del esquema: no hay que escribirlo dos veces.
export type Producto = z.infer<typeof Producto>;
export type Pedido = z.infer<typeof Pedido>;Cada esquema es un valor que existe al ejecutar (puede validar) y, con z.infer, también un tipo. Si mañana añades un campo al esquema, el tipo Pedido cambia con él y el compilador te señala todo lo que hay que actualizar. Que el esquema y el tipo se llamen igual es una costumbre habitual: TypeScript distingue por el contexto si usas el valor o el tipo.
Los cálculos, y por qué import type
import type { Pedido } from "./modelo.ts";
export const IVA = 0.21;
export function subtotal(pedido: Pedido): number {
return pedido.productos.reduce((suma, p) => suma + p.precio * p.cantidad, 0);
}
export function total(pedido: Pedido): number {
const descuento = pedido.cupon ? pedido.cupon.porcentaje / 100 : 0;
return Math.round(subtotal(pedido) * (1 - descuento) * (1 + IVA) * 100) / 100;
}
export function resumen(pedido: Pedido): string {
return `Pedido ${pedido.id} de ${pedido.cliente}: ${total(pedido).toFixed(2)} EUR`;
}La primera línea importa Pedido con import type, porque aquí solo se usa como tipo. No es una manía: Node borra los tipos sin mirar otros archivos, así que no sabe si lo que importas es un tipo o un valor. Si lo importas sin type, intenta cargarlo al ejecutar y falla:
export interface Pedido { id: number; cliente: string }import { Pedido } from "./tipos_pedido.ts";
const p: Pedido = { id: 1, cliente: "Ana" };
console.log(p.cliente);node import_mal.tsSyntaxError: The requested module './tipos_pedido.ts' does not provide an export named 'Pedido'
tsc (con verbatimModuleSyntax)import_mal.ts(1,10): error TS1484: “Pedido” es un tipo y debe importarse mediante una importación de solo tipo cuando “verbatimModuleSyntax” está habilitado.
Con verbatimModuleSyntax en el tsconfig.json, el compilador te avisa antes, y la solución es la que dice el mensaje: import type { Pedido }.
El programa principal
import { readFile } from "node:fs/promises";
import * as z from "zod";
import { Pedidos } from "./modelo.ts";
import { resumen } from "./calculos.ts";
z.config(z.locales.es()); // mensajes de error de Zod en español
const ruta = process.argv[2]; // string | undefined, por noUncheckedIndexedAccess
if (!ruta) {
console.error("Uso: node src/main.ts <archivo.json>");
process.exit(2);
}
const datos: unknown = JSON.parse(await readFile(ruta, "utf8"));
const resultado = Pedidos.safeParse(datos);
if (!resultado.success) {
console.error(`${ruta} no es válido:`);
console.error(z.prettifyError(resultado.error));
process.exit(1);
}
for (const pedido of resultado.data) console.log(resumen(pedido)); // pedido: Pedido
const pendientes = resultado.data.filter((p) => !p.pagado).map((p) => p.id);
console.log("Pendientes de pago:", pendientes.join(", ") || "ninguno");Los datos de fuera entran como unknown (lección 2), y solo después de safeParse, dentro del if que comprueba el éxito, son Pedido[]: es el estrechamiento de la lección 3, ahora con una unión discriminada que fabrica Zod. Y por noUncheckedIndexedAccess, process.argv[2] es string | undefined, así que el compilador obliga a comprobar que se pasó el archivo.
npx tsc
tsc(sin errores)
node src/main.ts datos/pedidos.json
salidaPedido 1041 de Ana López: 107.57 EUR Pedido 1042 de Luis Pérez: 205.82 EUR Pendientes de pago: 1042
Y con un archivo que trae datos incorrectos (al ratón le falta la cantidad, pagado es un texto y el cupón tiene un 150 %), el programa no calcula basura: se detiene y dice exactamente qué está mal y dónde:
node src/main.ts datos/pedidos-mal.json
salidadatos/pedidos-mal.json no es válido: ✖ Entrada inválida: se esperaba booleano, recibido texto → at [1].pagado ✖ Demasiado grande: se esperaba que número fuera <=100 → at [1].cupon.porcentaje ✖ Entrada inválida: se esperaba número, recibido indefinido → at [0].productos[1].cantidad
Las pruebas
Las pruebas se escriben en TypeScript igual que el resto, con el ejecutor que trae Node (lo tienes explicado en la lección de módulos y pruebas de JavaScript). node --test encuentra solo los archivos *.test.ts:
import { test } from "node:test";
import assert from "node:assert/strict";
import { subtotal, total } from "./calculos.ts";
import type { Pedido } from "./modelo.ts";
const base: Pedido = {
id: 1,
cliente: "Prueba",
pagado: false,
productos: [{ nombre: "Taza", precio: 10, cantidad: 3 }],
};
test("el subtotal multiplica precio por cantidad", () => {
assert.equal(subtotal(base), 30);
});
test("el cupón se descuenta antes del IVA", () => {
const conCupon: Pedido = { ...base, cupon: { codigo: "X", porcentaje: 10 } };
assert.equal(total(conCupon), 32.67); // 30 - 10 % = 27; 27 + 21 % = 32.67
});node --test
salida✔ el subtotal multiplica precio por cantidad ✔ el cupón se descuenta antes del IVA ℹ tests 2 ℹ suites 0 ℹ pass 2 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms (omitido)
Tipos de las bibliotecas: .d.ts y @types
¿Cómo sabe TypeScript qué tiene Zod, o qué hay en node:fs? Por los archivos de declaraciones, que terminan en .d.ts: solo tienen tipos, sin código. Zod los trae dentro de su paquete, como casi todas las bibliotecas modernas. Las que no los traen suelen tenerlos en DefinitelyTyped, un repositorio comunitario que publica paquetes con el nombre @types/<biblioteca>; es el caso de Node, cuyos tipos están en @types/node.
Un cambio de TypeScript 7 que pilla a mucha gente: antes, los paquetes @types instalados se cargaban solos; ahora hay que listarlos en types. Sin esa línea, ni siquiera process existe:
console.log(process.argv.length);
tsc --strictsin_types.ts(1,13): error TS2591: No se encuentra el nombre "process". ¿Necesita instalar definiciones de tipo para el nodo? Pruebe "npm i --save-dev @types/node" y, a continuación, agregue "node" al campo de tipos del archivo tsconfig.
Resumen
| Para | Haz | Recuerda |
|---|---|---|
| Empezar sin tocar nada | // @ts-check en los .js | solo revisa lo que puede deducir |
| Migrar un archivo | renómbralo a .ts y escribe los tipos | con strict desactivado al principio, si hace falta |
| Terminar la migración | activa strict | es lo normal en TypeScript 7 |
| Datos de fuera | unknown y Zod | los tipos no comprueban nada al ejecutar |
| Importar un tipo | import type | Node no sabe distinguirlo |
| Los tipos de una biblioteca | .d.ts o @types/... | en la 7, lístalos en types |
| Probar | node --test | encuentra los *.test.ts |
Retos
Reto 1. Añade al esquema de Producto un campo opcional categoria que solo admita "hardware", "software" o "servicio". Comprueba que el tipo Producto lo incluye sin tocarlo, creando un producto con una categoría que no existe.
Ver la solución
import * as z from "zod";
const Producto = z.object({
nombre: z.string(),
precio: z.number().positive(),
cantidad: z.number().int().positive(),
categoria: z.enum(["hardware", "software", "servicio"]).optional(),
});
type Producto = z.infer<typeof Producto>;
const ok: Producto = { nombre: "Licencia", precio: 30, cantidad: 1, categoria: "software" };
const mal: Producto = { nombre: "Cable", precio: 5, cantidad: 2, categoria: "accesorio" }; // errorÁbrelo en el editor y cámbialo →
tsc --strictreto1.ts(12,66): error TS2322: El tipo '"accesorio"' no se puede asignar al tipo '"hardware" | "servicio" | "software" | undefined'.
z.enum de Zod no es el enum de TypeScript: es un valor normal que valida y da una unión de literales, así que Node lo ejecuta sin problema.
Reto 2. Escribe una prueba más para calculos.ts: que un pedido sin cupón con dos productos (2 × 5 y 1 × 20) dé un total de 36.3.
Ver la solución
import { test } from "node:test";
import assert from "node:assert/strict";
const IVA = 0.21;
type Pedido = { cupon?: { porcentaje: number }; productos: { precio: number; cantidad: number }[] };
function total(p: Pedido): number {
const sub = p.productos.reduce((s, x) => s + x.precio * x.cantidad, 0);
const desc = p.cupon ? p.cupon.porcentaje / 100 : 0;
return Math.round(sub * (1 - desc) * (1 + IVA) * 100) / 100;
}
test("sin cupón, solo IVA", () => {
const p: Pedido = { productos: [{ precio: 5, cantidad: 2 }, { precio: 20, cantidad: 1 }] };
assert.equal(total(p), 36.3); // (10 + 20) + 21 % = 36.3
});Aquí la función está copiada en la prueba para que se pueda ejecutar sola; en el proyecto, se importa de ./calculos.ts.
Todo el código de esta lección se comprobó con tsc 7.0.2 y se ejecutó con Node.js 24.22 en un contenedor limpio, con Zod 4.6.7 y @types/node 24.19.2; el proyecto final, con las mismas órdenes que se publican. ¿Has llegado hasta aquí? Comprueba lo aprendido en el test «¿compila o no?» y repasa las rarezas de TypeScript.
