Un servidor HTTP con TypeScript y Node.js, sin frameworks

Node trae un servidor HTTP de serie, node:http, y con @types/node TypeScript conoce todos sus tipos. Montamos una API de tareas sin ningún framework. Los tipos ayudan en dos frentes: que las rutas estén bien escritas y que lo que llega del cliente se compruebe antes de usarlo.

El servidor

Instala los tipos de Node con npm install -D @types/node; usamos la versión 24.19.2. El código:

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

interface Tarea {
  id: number;
  titulo: string;
  hecha: boolean;
}

const tareas: Tarea[] = [
  { id: 1, titulo: "Comprar café", hecha: false },
  { id: 2, titulo: "Revisar el correo", hecha: true },
];

// Lo único que aceptamos al crear una tarea.
type NuevaTarea = Pick<Tarea, "titulo">;

function esNuevaTarea(x: unknown): x is NuevaTarea {
  return typeof x === "object" && x !== null && "titulo" in x &&
    typeof x.titulo === "string" && x.titulo.trim() !== "";
}

function responder(res: ServerResponse, estado: number, cuerpo: unknown): void {
  res.writeHead(estado, { "Content-Type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(cuerpo));
}

async function leerCuerpo(req: IncomingMessage): Promise<unknown> {
  let texto = "";
  for await (const trozo of req) texto += trozo;
  return JSON.parse(texto);
}

// Cada ruta es «MÉTODO /ruta» y su manejador. El tipo impide registrar un método inventado.
type Metodo = "GET" | "POST" | "DELETE";
type Manejador = (req: IncomingMessage, res: ServerResponse, id?: number) => Promise<void> | void;
const rutas: Record<`${Metodo} ${string}`, Manejador> = {
  "GET /tareas": (_req, res) => responder(res, 200, tareas),
  "POST /tareas": async (req, res) => {
    let datos: unknown;
    try {
      datos = await leerCuerpo(req);
    } catch {
      return responder(res, 400, { error: "El cuerpo no es JSON válido" });
    }
    if (!esNuevaTarea(datos)) return responder(res, 422, { error: "Falta «titulo» (texto)" });
    const nueva: Tarea = { id: tareas.length + 1, titulo: datos.titulo.trim(), hecha: false };
    tareas.push(nueva);
    responder(res, 201, nueva);
  },
  "DELETE /tareas/:id": (_req, res, id) => {
    const i = tareas.findIndex((t) => t.id === id);
    if (i === -1) return responder(res, 404, { error: `No hay ninguna tarea ${id}` });
    const [borrada] = tareas.splice(i, 1);
    responder(res, 200, borrada);
  },
};

const servidor = createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", "http://localhost");
  const m = url.pathname.match(/^\/tareas\/(\d+)$/);
  const clave = m ? `${req.method} /tareas/:id` : `${req.method} ${url.pathname}`;
  const manejador = rutas[clave as keyof typeof rutas];
  if (!manejador) return responder(res, 404, { error: `No existe ${clave}` });
  await manejador(req, res, m ? Number(m[1]) : undefined);
});

servidor.listen(3000, () => console.log("Escuchando en http://localhost:3000"));
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es servidor.ts
tsc(sin errores)
node servidor.ts
salidaEscuchando en http://localhost:3000

Las piezas con tipos

PiezaQué aporta
type IncomingMessage, type ServerResponseLos tipos de la petición y la respuesta de Node. Se importan con type porque solo se usan como tipos.
Record<`${Metodo} ${string}`, Manejador>Las claves de la tabla de rutas tienen que empezar por GET, POST o DELETE. Es un tipo de plantilla de texto.
esNuevaTarea(x: unknown): x is NuevaTareaUna guarda de tipo: comprueba el cuerpo y, si devuelve true, TypeScript lo trata como NuevaTarea.
leerCuerpo(): Promise<unknown>Lo que manda el cliente es unknown hasta que se compruebe, no any.
Pick<Tarea, "titulo">Lo único que se acepta al crear: el id y hecha los pone el servidor.

La tabla de rutas no admite un método que no exista. Si alguien añade PATCH sin ampliar el tipo Metodo:

import type { IncomingMessage, ServerResponse } from "node:http";
type Metodo = "GET" | "POST" | "DELETE";
type Manejador = (req: IncomingMessage, res: ServerResponse) => void;
const rutas: Record<`${Metodo} ${string}`, Manejador> = {
  "GET /tareas": () => {},
  "PATCH /tareas": () => {},
};
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es metodo-error.ts
tscmetodo-error.ts(6,3): error TS2353: El literal de objeto solo puede especificar propiedades conocidas y '"PATCH /tareas"' no existe en el tipo 'Record<`DELETE ${string}` | `GET ${string}` | `POST ${string}`, Manejador>'.

Probarlo

Un cliente que recorre todas las rutas, incluidos los casos malos: un cuerpo sin titulo, uno que no es JSON, borrar algo que no existe y un método sin ruta.

// Un cliente que usa la API del servidor y comprueba cada respuesta.
const BASE = "http://localhost:3000";

async function pedir(metodo: string, ruta: string, cuerpo?: string): Promise<void> {
  const res = await fetch(BASE + ruta, {
    method: metodo,
    headers: { "Content-Type": "application/json" },
    body: cuerpo,
  });
  console.log(`${metodo} ${ruta} -> ${res.status}`, await res.text());
}

await pedir("GET", "/tareas");
await pedir("POST", "/tareas", JSON.stringify({ titulo: "Llamar al banco" }));
await pedir("POST", "/tareas", JSON.stringify({ nombre: "sin título" }));
await pedir("POST", "/tareas", "{esto no es JSON");
await pedir("DELETE", "/tareas/2");
await pedir("DELETE", "/tareas/99");
await pedir("PATCH", "/tareas");
await pedir("GET", "/tareas");
node cliente.ts
salidaGET /tareas -> 200 [{"id":1,"titulo":"Comprar café","hecha":false},{"id":2,"titulo":"Revisar el correo","hecha":true}]
POST /tareas -> 201 {"id":3,"titulo":"Llamar al banco","hecha":false}
POST /tareas -> 422 {"error":"Falta «titulo» (texto)"}
POST /tareas -> 400 {"error":"El cuerpo no es JSON válido"}
DELETE /tareas/2 -> 200 {"id":2,"titulo":"Revisar el correo","hecha":true}
DELETE /tareas/99 -> 404 {"error":"No hay ninguna tarea 99"}
PATCH /tareas -> 404 {"error":"No existe PATCH /tareas"}
GET /tareas -> 200 [{"id":1,"titulo":"Comprar café","hecha":false},{"id":3,"titulo":"Llamar al banco","hecha":false}]
CódigoCuándo lo devuelve
200Todo bien: lista o tarea borrada
201Tarea creada
400El cuerpo no es JSON
404No existe la tarea o la ruta
422Es JSON, pero no trae lo que hace falta
Los tipos no llegan al cliente. El compilador comprueba tu código, no lo que te mandan: un cliente puede enviar cualquier cosa. Por eso el cuerpo es unknown y pasa por esNuevaTarea. Para APIs más grandes, un esquema de Zod hace lo mismo con menos código.

Todo el código de esta página se comprobó con TypeScript 7.0.2 en modo estricto (--strict --locale es) y se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. Documentación oficial: Módulo node:http de Node.js.