🌍 Servidores web en Node.js con TypeScript
Express, Fastify y NestJS a fondo: routing, middleware, validación con zod, estructura profesional de proyectos backend y una API REST completa con TypeScript.
Servidores web en Node.js con TypeScript
Node.js nace como un runtime para construir servidores de red: http.createServer, listen, req, res. Sobre esa base, los frameworks web añaden capas de abstracción que ordenan el trabajo: routing, middleware, validación, serialización y testing. Aquí dominas los tres enfoques de producción con TypeScript: Express (el clásico), Fastify (el rápido y type-safe) y NestJS (el arquitectónico con inyección de dependencias).
El servidor HTTP crudo de Node
Todo framework usa por debajo el módulo node:http:
const http = require("node:http");
http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, url: req.url }));
}).listen(3000);
req es un Readable stream (el cuerpo llega por partes) y res un Writable stream. Todo lo demás —parsear el body, enrutar, CORS, validar— es trabajo que los frameworks automatizan. Node 18+ expone además fetch nativo.
Express a fondo
Express es middleware puro: cada request pasa por una pila de funciones que se ejecutan en orden.
La app y el routing
import express from "express";
const app = express();
const port = Number(process.env.PORT ?? 3000);
app.get("/usuarios/:id", (req, res) => {
res.json({ id: req.params.id, nombre: "Ana" });
});
app.post("/usuarios", (req, res) => res.status(201).json({ ok: true }));
app.listen(port, () => console.log(`API en :${port}`));
req.params: parámetros de ruta (/usuarios/:id).req.query: query string (?page=2→{ page: "2" }).res.json(obj): serializa conContent-Type: application/json.res.status(c): fija el código HTTP.
Con rutas agrupadas usas express.Router(), que registras con app.use("/api/usuarios", router) en lugar de rutas sueltas.
Middleware: el corazón de Express
Un middleware es (req, res, next) => void que hace algo y o responde o llama a next(). El orden de registro define el orden de ejecución.
app.use(express.json()); // parsea el body JSON
app.use(cors()); // cabeceras CORS
app.use((req, _res, next) => { // middleware propio: log
console.log(`${req.method} ${req.url}`);
next(); // sin esto, la pila se detiene
});
function requiereApiKey(req: express.Request, res: express.Response, next: express.NextFunction) {
if (req.headers["x-api-key"] !== process.env.API_KEY) {
res.status(401).json({ error: "no autorizado" });
return; // corta: NO se llama a next
}
next(); // autorizado: continúa
}
⚠️ La regla de oro: o respondes o llamas a
next(). Hacer ambas lanza “headers already sent”; no hacer ninguna deja el request colgado.
Para el body y CORS, registra antes de tus rutas express.json({ limit: "1mb" }) y cors({ origin: [...], methods: [...] }).
Error handling: el middleware de 4 parámetros
Un middleware de error se distingue por tener cuatro parámetros y se registra al final de la pila. Si algo llama a next(err), la ejecución salta aquí:
import type { NextFunction, Request, Response } from "express";
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
app.get("/usuarios/:id", (req, _res, next) => {
if (req.params.id !== "1") next(new ApiError(404, "usuario no encontrado"));
else next();
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
const status = err instanceof ApiError ? err.status : 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message });
});
Validación con zod
Zod es un schema validator cuyo tipo TS se deriva del esquema: validación en runtime + tipos estáticos en un solo lugar.
import { z } from "zod";
const crearUsuarioSchema = z.object({
nombre: z.string().min(2).max(100),
email: z.string().email(),
});
type CrearUsuario = z.infer<typeof crearUsuarioSchema>; // { nombre: string; email: string }
app.post("/usuarios", (req, res) => {
const resultado = crearUsuarioSchema.safeParse(req.body);
if (!resultado.success) {
res.status(400).json({ errores: resultado.error.issues });
return;
}
const datos: CrearUsuario = resultado.data; // ya validado y tipado
res.status(201).json({ ok: true, datos });
});
💡
safeParseno lanza excepciones: devuelve{ success: true, data }o{ success: false, error }. Es lo recomendado dentro de un handler para responder 400 en vez de 500.
Fastify: esquemas JSON y rendimiento
Fastify nace en 2016 en nearForm con dos metas: rendimiento y serialización optimizada. Diferencias frente a Express:
| Característica | Express | Fastify |
|---|---|---|
| Serialización | JSON.stringify por request |
Esquemas compilados a funciones (fast-json-stringify) |
| Validación | Manual o zod por tu cuenta | ajv integrado vía JSON Schema |
| Middleware | Pila genérica | Hooks con ciclo de vida definido |
| Tipos | req/res poco estrictos |
Tipos generados del esquema |
El body se declara con JSON Schema y Fastify valida, tipa y serializa automáticamente:
import Fastify from "fastify";
const app = Fastify({ logger: true });
const crearUsuarioSchema = {
body: {
type: "object",
required: ["nombre", "email"],
properties: {
nombre: { type: "string", minLength: 2, maxLength: 100 },
email: { type: "string", format: "email" },
},
additionalProperties: false,
},
} as const;
app.post("/usuarios", { schema: crearUsuarioSchema }, async (req, reply) => {
const { nombre, email } = req.body as { nombre: string; email: string };
return reply.code(201).send({ nombre, email });
});
await app.listen({ port: 3000 });
Plugins y encapsulación
Fastify encapsula el estado por scope: lo registrado en un plugin no contamina a los demás. Los plugins son funciones async que reciben fastify y opts, y se registran con un prefix:
import type { FastifyInstance } from "fastify";
export async function rutasUsuarios(app: FastifyInstance) {
app.get("/usuarios", async () => [{ id: 1, nombre: "Ana" }]);
}
await app.register(rutasUsuarios, { prefix: "/api" });
💡 La encapsulación es la gran ventaja operativa de Fastify: los plugins son la unidad natural de despliegue y test; toda app Fastify ES un plugin.
NestJS: arquitectura con inyección de dependencias
NestJS organiza el backend como una aplicación modular con inyección de dependencias (DI) de inspiración Angular. Es la opción para equipos que necesitan estructura, testing y escalado de código.
Módulos, controllers y providers
El módulo declara qué expone y qué inyecta:
import { Module } from "@nestjs/common";
import { UsuariosController } from "./usuarios.controller";
import { UsuariosService } from "./usuarios.service";
@Module({ controllers: [UsuariosController], providers: [UsuariosService], exports: [UsuariosService] })
export class UsuariosModule {}
// usuarios.controller.ts
import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";
import { UsuariosService } from "./usuarios.service";
@Controller("usuarios")
export class UsuariosController {
constructor(private readonly usuarios: UsuariosService) {} // DI
@Get()
findAll() { return this.usuarios.findAll(); }
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return this.usuarios.findOne(id);
}
}
El @Injectable() marca a UsuariosService como provider; el constructor lo inyecta sin instanciarlo manualmente. Nest resuelve el grafo de dependencias al arrancar. El service es una clase normal:
// usuarios.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class UsuariosService {
private usuarios = [{ id: 1, nombre: "Ana" }];
findAll() { return this.usuarios; }
findOne(id: number) { return this.usuarios.find((u) => u.id === id); }
}
Guards, pipes e interceptors
NestJS separa las preocupaciones transversales en piezas decorables:
| Pieza | Rol | Ejemplo |
|---|---|---|
| Guard | Autoriza antes del handler | @UseGuards(AuthGuard) |
| Pipe | Transforma/valida argumentos | ParseIntPipe, ValidationPipe con DTO |
| Interceptor | Envuelve la ejecución (logging, cache, transformación) | @UseInterceptors(LoggingInterceptor) |
| Filter | Convierte excepciones en respuestas HTTP | @Catch(HttpException) |
Un Guard simple autoriza antes de ejecutar el handler:
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const request = ctx.switchToHttp().getRequest();
return request.headers["x-api-key"] === process.env.API_KEY;
}
}
La validación con DTO + class-validator + ValidationPipe global es el patrón Nest estándar (whitelist: true elimina del body lo no declarado en el DTO, evitando mass-assignment):
import { IsEmail, IsString, MinLength } from "class-validator";
export class CrearUsuarioDto {
@IsString()
@MinLength(2)
nombre!: string;
@IsEmail()
email!: string;
}
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
Estructura profesional de un proyecto backend TS
Un proyecto real separa dominio de infraestructura:
src/
├── config/ # validación de variables de entorno (env.ts)
├── domain/ # entidades y reglas de negocio puras
├── application/ # casos de uso (orquestan domain + infra)
├── infrastructure/ # adaptadores: DB, HTTP, colas, email, logger
├── interfaces/ # controllers y presentación HTTP
├── errors/ # errores de dominio y handler central
└── index.ts # punto de entrada
Claves que separan un proyecto amateur de uno profesional:
- Config validada al arrancar: falla rápido si falta una variable.
- Error handling centralizado en una sola función.
- Logging estructurado (JSON), no
console.logsuelto. - Sin lógica de negocio en los controllers: solo reciben HTTP y responden.
Variables de entorno y configuración
Node carga process.env; para ficheros .env usas dotenv (o node --env-file desde Node 20). La práctica profesional es validar la config al arrancar:
import "dotenv/config";
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(16),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Configuración inválida:", parsed.error.issues);
process.exit(1); // falla rápido
}
export const env = parsed.data;
// env.PORT, env.DATABASE_URL, env.API_KEY → tipados y garantizados
💡
z.coerce.number()convierte el string de.enva número. Comoprocess.envsiempre son strings, todo valor numérico necesita coerción explícita.
Logging: pino y winston
El log en producción debe ser estructurado (JSON) y con niveles. pino es el más rápido de Node; winston es el clásico con transports configurables:
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: ["req.headers.authorization", "*.password"], // nunca loguees secretos
});
logger.info({ usuarioId: 42, ruta: "/api" }, "request procesado");
logger.error({ err: new Error("boom") }, "fallo en checkout");
Winston cubre el mismo caso con winston.createLogger({ format: json(), transports: [Console, File] }), útil cuando necesitas escribir a varios destinos a la vez.
⚠️ Nunca loguees passwords, tokens ni datos personales. Usa
redactde pino o filtros en winston antes de escribir.
Ejemplo completo: API REST con Fastify + zod
Unimos todo lo anterior: Fastify, validación zod y persistencia en memoria:
import Fastify from "fastify";
import { randomUUID } from "node:crypto";
import { z } from "zod";
const app = Fastify({ logger: true });
type Usuario = { id: string; nombre: string; email: string };
const usuariosSchema = z.object({
nombre: z.string().min(2).max(100),
email: z.string().email(),
});
const db = new Map<string, Usuario>();
app.get("/usuarios", async () => [...db.values()]);
app.get("/usuarios/:id", async (req, reply) => {
const usuario = db.get(req.params.id as string);
if (!usuario) return reply.code(404).send({ error: "usuario no encontrado" });
return usuario;
});
app.post("/usuarios", async (req, reply) => {
const resultado = usuariosSchema.safeParse(req.body);
if (!resultado.success) return reply.code(400).send({ error: resultado.error.issues });
const usuario: Usuario = { id: randomUUID(), ...resultado.data };
db.set(usuario.id, usuario);
return reply.code(201).send(usuario);
});
app.put("/usuarios/:id", async (req, reply) => {
const id = req.params.id as string;
if (!db.has(id)) return reply.code(404).send({ error: "no existe" });
const resultado = usuariosSchema.safeParse(req.body);
if (!resultado.success) return reply.code(400).send({ error: resultado.error.issues });
const actualizado: Usuario = { id, ...resultado.data };
db.set(id, actualizado);
return actualizado;
});
app.delete("/usuarios/:id", async (req, reply) => {
const id = req.params.id as string;
if (!db.has(id)) return reply.code(404).send({ error: "no existe" });
db.delete(id);
return reply.code(204).send();
});
try {
await app.listen({ port: 3000, host: "0.0.0.0" });
} catch (err) {
app.log.error(err);
process.exit(1);
}
Prueba con curl: un POST /usuarios válido devuelve 201 con el usuario; uno con email inválido devuelve 400 con los issues de zod.
Prueba rápida:
Cómo elegir
| Necesidad | Framework |
|---|---|
| API pequeña o microservicio rápido, validación incluida | Fastify |
| Proyecto con rutas y middleware simple | Express |
| Aplicación corporativa grande, DI y testing estructural | NestJS |
💡 Menos de 10 endpoints, Express o Fastify. Módulos, equipos y años de vida, NestJS.
Para profundizar
- Express — Guía de routing y middleware: la documentación oficial del framework más usado de Node.
- Fastify — Documentación oficial: esquemas JSON, plugins, hooks y el ciclo de vida del request.
- NestJS — Documentación oficial: controllers, providers, guards, pipes e interceptors.
- Zod — Esquemas y validación: la librería de schemas estándar del ecosistema TS.
- Pino — Logging estructurado: el logger JSON más rápido para Node.