Un proyecto real con TypeScript

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

La migración en cuatro etapas: JavaScript, @ts-check, archivo .ts con tipos y modo estricto; los datos externos se validan con ZodMigrar sin parar el proyecto: cuatro escalonesEtapa 0JavaScriptpedidos.jsfunciona… hasta que noEtapa 1// @ts-checkpedidos.jstsc revisa lo que deduceEtapa 2tipospedidos.tsinterfaces y anotacionesEtapa 3strictpedidos.tsnull y undefined, a la vistaY en la frontera, siempre:lo que llega de fuera (JSON, APIs, formularios)se valida al ejecutar, con Zod

Cada etapa deja el programa funcionando: se puede publicar entre una y otra. Esquema: decodigo.com.

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 escribe
npm 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ónQué hace
target, modulela 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
strictel modo estricto; en la 7 viene activado, pero escribirlo deja clara la intención
noUncheckedIndexedAccesslista[i] puede ser undefined; no entra en strict, y caza muchos errores
noEmittsc solo revisa; quien ejecuta es Node
allowImportingTsExtensionspermite escribir ./modelo.ts en los import, que es lo que Node necesita
erasableSyntaxOnlyprohíbe lo que Node no sabe borrar: enum, propiedades de parámetro…
verbatimModuleSyntaxobliga a marcar con import type lo que solo es un tipo
includequé 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)
Los tiempos de cada prueba, que cambian en cada ejecución, se han quitado de la salida. Las pruebas comprueban lo que hace el programa; el compilador, que las piezas encajan. Se complementan: el compilador no sabe que el cupón va antes del IVA, y una prueba no revisa todos los caminos del código como hace tsc.

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

ParaHazRecuerda
Empezar sin tocar nada// @ts-check en los .jssolo revisa lo que puede deducir
Migrar un archivorenómbralo a .ts y escribe los tiposcon strict desactivado al principio, si hace falta
Terminar la migraciónactiva strictes lo normal en TypeScript 7
Datos de fueraunknown y Zodlos tipos no comprueban nada al ejecutar
Importar un tipoimport typeNode no sabe distinguirlo
Los tipos de una biblioteca.d.ts o @types/...en la 7, lístalos en types
Probarnode --testencuentra 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.