← Todos los artículos EN

Usando TypeScript en un proyecto Node.js

Configura TypeScript en un proyecto Express desde cero — tsconfig, handlers tipados, scripts de build, y los patrones que hacen que valga la pena la configuración inicial.

TypeScript en Node.js es de esas cosas que parecen un overhead hasta que has desplegado algo sin él y pasado una tarde debugueando un cannot read property of undefined que el compilador habría atrapado a costo cero.

Esta guía configura TypeScript en un proyecto Express desde cero — no solo el boilerplate, sino el razonamiento detrás de cada decisión.

Inicializar el proyecto

mkdir ts-node-app
cd ts-node-app
npm init -y

Instalar dependencias

TypeScript y sus definiciones de tipos son dependencias de desarrollo. El output compilado es JavaScript plano, por lo que ninguna de las herramientas de TypeScript llega a producción.

# Runtime
npm install express

# Solo desarrollo
npm install -D typescript ts-node @types/node @types/express
  • typescript — el compilador
  • ts-node — ejecuta archivos .ts directamente sin paso de build, útil para desarrollo
  • @types/node y @types/express — definiciones de tipos para las APIs de Node y Express

Configurar TypeScript

Genera un tsconfig.json:

npx tsc --init

Luego configúralo para un target de Node.js:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build"]
}

Opciones clave:

  • strict: true — activa todas las verificaciones estrictas. Este es el punto de TypeScript; no lo desactives.
  • outDir: "./build" — el JS compilado va aquí, nunca lo subas al repositorio
  • esModuleInterop: true — permite import express from 'express' en lugar de import * as express from 'express'

Estructura del proyecto

ts-node-app/
├── src/
│   ├── app.ts          ← configuración de Express
│   └── server.ts       ← punto de entrada, inicia el servidor
├── build/              ← output compilado (en .gitignore)
├── tsconfig.json
└── package.json

Escribir la aplicación Express

// src/app.ts
import express, { Application, Request, Response, NextFunction } from 'express';

const app: Application = express();

app.use(express.json());

app.get('/', (req: Request, res: Response) => {
  res.json({ status: 'ok', mensaje: 'Hola desde TypeScript.' });
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: err.message });
});

export default app;
// src/server.ts
import app from './app';

const PORT = process.env.PORT ?? 3000;

app.listen(PORT, () => {
  console.log(`Servidor corriendo en el puerto ${PORT}`);
});

Separar app de server hace que la aplicación sea importable en los tests sin vincularla a un puerto.

Agregar scripts de build

{
  "scripts": {
    "dev": "ts-node src/server.ts",
    "build": "tsc --project ./",
    "start": "node ./build/server.js"
  }
}
  • npm run dev — desarrollo, sin paso de compilación, feedback instantáneo
  • npm run build — compila TypeScript a ./build
  • npm start — ejecuta el output compilado en producción

Handlers de rutas tipados

El valor real aparece cuando empiezas a definir tipos para request y response:

// src/routes/usuarios.ts
import { Router, Request, Response } from 'express';

interface CrearUsuarioBody {
  nombre: string;
  email: string;
}

interface UsuarioParams {
  id: string;
}

const router = Router();

router.post('/', (req: Request<{}, {}, CrearUsuarioBody>, res: Response) => {
  const { nombre, email } = req.body; // completamente tipado — sin any
  res.status(201).json({ id: '1', nombre, email });
});

router.get('/:id', (req: Request<UsuarioParams>, res: Response) => {
  const { id } = req.params; // string, garantizado
  res.json({ id, nombre: 'Esteban', email: 'e@example.com' });
});

export default router;

El genérico Request<Params, ResBody, ReqBody, Query> te da cobertura de tipos completa a lo largo de todo el ciclo de vida de la solicitud.

Variables de entorno con seguridad de tipos

// src/config.ts
function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) throw new Error(`Variable de entorno requerida: ${key}`);
  return value;
}

export const config = {
  port: parseInt(process.env.PORT ?? '3000', 10),
  dbUrl: requireEnv('DATABASE_URL'),
  nodeEnv: (process.env.NODE_ENV ?? 'development') as 'development' | 'production' | 'test',
};

requireEnv falla rápido al inicio en lugar de fallar en runtime cuando realmente se usa la variable.

Agregar .gitignore

node_modules/
build/
.env

La carpeta build/ es generada — nunca la subas al repositorio.

Conclusiones

  • Las dependencias de TypeScript (typescript, ts-node, @types/*) son devDependencies — no llegan a producción
  • strict: true es el objetivo principal. Desactivarlo es básicamente escribir JavaScript con pasos extra
  • Separa app.ts de server.ts — hace los tests significativamente más limpios
  • Los genéricos Request<Params, ResBody, ReqBody, Query> son cómo obtienes seguridad de tipos en los handlers de Express
  • ts-node en desarrollo, JavaScript compilado en producción — nunca ejecutes ts-node en un entorno productivo

Una vez que has tipado un servicio Express en producción, volver a Node sin tipos se siente como trabajar sin autocompletado. El costo de configuración son unos 15 minutos; el beneficio se acumula en cada refactor.