🔎 Buscar

🌍 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.

Wiki / Apuntes📖 Contenido

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 con Content-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 });
});

💡 safeParse no 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:

  1. Config validada al arrancar: falla rápido si falta una variable.
  2. Error handling centralizado en una sola función.
  3. Logging estructurado (JSON), no console.log suelto.
  4. 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 .env a número. Como process.env siempre 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 redact de 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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.