Saltar al contenido principal

Cómo Construir una API REST con Node.js y TypeScript

· 11 min de lectura
MeepLab Team
Equipo de Desarrollo de Software Evolutivo

Según la encuesta de Stack Overflow 2024, Node.js es la tecnología de backend más usada por segundo año consecutivo, y TypeScript superó a JavaScript como lenguaje preferido para proyectos nuevos. Si tu equipo está construyendo un backend en 2026, esta es la combinación que probablemente van a usar.

Pero hay una diferencia enorme entre una API que "funciona" y una API que tu equipo puede mantener, escalar y debugear a las 3 de la mañana cuando algo falla en producción.

En este artículo aprenderás:

  • La estructura de carpetas que escala de 5 a 50 endpoints sin volverse caótica
  • Cómo configurar TypeScript para máxima productividad sin frustración
  • Patrones de validación, errores y middleware que todo equipo necesita
  • Testing desde el inicio: cómo escribir tests que realmente protegen tu código
  • Buenas prácticas que separan un proyecto hobby de uno profesional
  • Cuándo esta arquitectura es suficiente y cuándo necesitas algo más robusto

Por qué Node.js + TypeScript para tu API

Antes de entrar al código, la pregunta legítima: ¿por qué esta combinación y no otra?

#1Node.js: backend más usado (Stack Overflow 2024)
78%De proyectos nuevos eligen TypeScript sobre JS
40%Menos bugs en producción con TypeScript (Airbnb, 2023)

Node.js te da un runtime rápido y un ecosistema de paquetes (npm) que cubre prácticamente cualquier necesidad. Si tu equipo ya trabaja con JavaScript en el frontend, Node.js les permite usar el mismo lenguaje en el backend — un solo lenguaje para todo el stack.

TypeScript agrega tipos estáticos a JavaScript. Eso significa que el editor te avisa de errores antes de ejecutar el código, el autocompletado funciona mucho mejor, y refactorizar código de otro desarrollador deja de ser un acto de fe.

Un estudio interno de Airbnb (2023) reportó 40% menos bugs en producción después de migrar a TypeScript. No es magia — es que el compilador atrapa errores que antes solo aparecían cuando un usuario los encontraba.

ℹ️¿Cuándo NO elegir Node.js?

Node.js no es ideal para todo. Si tu API necesita procesamiento intensivo de CPU (renderizado de video, cálculos científicos complejos), lenguajes como Go o Rust son mejor opción. Node.js brilla en I/O intensivo: APIs, conexiones a bases de datos, integraciones con servicios externos. Que es exactamente lo que hace el 90% de las APIs empresariales.

Estructura de carpetas que escala

La estructura de tu proyecto es la decisión más importante que vas a tomar. Una buena estructura permite que desarrolladores nuevos entiendan el código rápidamente y que el proyecto crezca sin caos.

src/
├── config/ # Configuración (env, database, etc.)
│ ├── env.ts
│ └── database.ts
├── modules/ # Agrupados por dominio de negocio
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.routes.ts
│ │ ├── user.schema.ts # Validación con Zod
│ │ └── user.types.ts
│ └── products/
│ ├── product.controller.ts
│ └── ...
├── middleware/ # Middleware compartido
│ ├── auth.ts
│ ├── errorHandler.ts
│ └── validate.ts
├── shared/ # Utilidades compartidas
│ ├── errors.ts
│ └── response.ts
├── app.ts # Configuración de Express
└── server.ts # Entry point

Por qué módulos por dominio, no por tipo

La alternativa común es agrupar por tipo: una carpeta /controllers, otra /services, otra /routes. Esto funciona cuando tienes 5 endpoints. Cuando llegas a 20, tienes 20 archivos en cada carpeta y encontrar lo que buscas es una pesadilla.

Con módulos por dominio, todo lo relacionado con "usuarios" está en modules/users/. Cuando necesitas modificar algo de usuarios, abres una carpeta y todo está ahí. Cuando un nuevo desarrollador se une al equipo, puede entender un módulo completo sin conocer el resto del sistema.

Las cuatro capas

Cada módulo tiene cuatro capas con responsabilidades claras:

  1. Controller: recibe el request HTTP, valida input, llama al service, envía response. No tiene lógica de negocio.
  2. Service: contiene la lógica de negocio. No sabe nada de HTTP, requests ni responses.
  3. Repository: habla con la base de datos. El service no sabe si usas MongoDB, PostgreSQL o un archivo JSON.
  4. Schema/Types: define la forma de los datos y las reglas de validación.

Esta separación no es burocracia — es lo que permite que tu equipo trabaje en paralelo sin pisarse, que los tests sean simples, y que puedas cambiar la base de datos sin reescribir la lógica de negocio.

Configuración de TypeScript: productiva, no frustrante

La configuración de TypeScript puede ser una fuente de frustración si es demasiado estricta o demasiado permisiva. Esta configuración equilibra seguridad con productividad:

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Las opciones clave:

  • strict: true: activa todas las comprobaciones de tipos. Es la razón de usar TypeScript — no la desactives.
  • module: NodeNext: usa el sistema de módulos nativo de Node.js. Compatible con ESM y CommonJS.
  • sourceMap: true: permite debugear el código TypeScript original, no el JavaScript compilado.
📐

¿Tu equipo necesita estructura en su backend?

Si tu API creció de forma orgánica y ahora es difícil de mantener, podemos ayudarte a reestructurarla sin detener el desarrollo. Revisamos tu código y proponemos un plan de migración gradual.

Solicitar revisión de código →

Validación de datos: nunca confíes en el input

Todo dato que entra a tu API puede estar mal formado, ser malicioso o simplemente no existir. La validación en el borde (antes de que llegue a tu lógica de negocio) es tu primera línea de defensa.

Usamos Zod para validación porque se integra nativamente con TypeScript — defines el schema una vez y obtienes tanto la validación runtime como el tipo estático:

// modules/users/user.schema.ts
import { z } from 'zod';

export const createUserSchema = z.object({
body: z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'viewer']),
company: z.string().optional(),
}),
});

// El tipo se infiere automáticamente del schema
export type CreateUserInput = z.infer<typeof createUserSchema>['body'];

El middleware de validación aplica el schema antes de que el request llegue al controller:

// middleware/validate.ts
import { AnyZodObject, ZodError } from 'zod';
import { Request, Response, NextFunction } from 'express';

export function validate(schema: AnyZodObject) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({
status: 'error',
errors: error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
});
return;
}
next(error);
}
};
}

Ahora en tus rutas, la validación es una línea:

router.post('/users', validate(createUserSchema), userController.create);

Si el body no tiene un name, si el email no es válido, o si el role no es uno de los permitidos, el request nunca llega a tu controller. Respuesta 400 con errores detallados, automática.

Manejo de errores: profesional, no caótico

El manejo de errores es lo que separa una API de hobby de una profesional. Define errores personalizados y un handler centralizado:

// shared/errors.ts
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public code: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}

export class NotFoundError extends AppError {
constructor(resource: string) {
super(404, `${resource} no encontrado`, 'NOT_FOUND');
}
}

export class ValidationError extends AppError {
constructor(message: string) {
super(400, message, 'VALIDATION_ERROR');
}
}

export class UnauthorizedError extends AppError {
constructor() {
super(401, 'No autorizado', 'UNAUTHORIZED');
}
}

El handler centralizado captura todos los errores en un solo lugar:

// middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../shared/errors.js';

export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
if (err instanceof AppError) {
res.status(err.statusCode).json({
status: 'error',
code: err.code,
message: err.message,
});
return;
}

// Error inesperado — no exponer detalles al cliente
console.error('Error no manejado:', err);
res.status(500).json({
status: 'error',
code: 'INTERNAL_ERROR',
message: 'Error interno del servidor',
});
}
⚠️Nunca expongas errores internos al cliente

En producción, un error 500 debe devolver un mensaje genérico, nunca el stack trace o detalles de implementación. Un stack trace expuesto puede revelar rutas de archivos, versiones de dependencias y estructura interna — información que un atacante puede usar.

En tu service, lanzar errores es natural y limpio:

// modules/users/user.service.ts
export async function getUser(id: string) {
const user = await userRepository.findById(id);
if (!user) throw new NotFoundError('Usuario');
return user;
}
🛠️

¿Necesitas un equipo que domine este stack?

Nuestro equipo trabaja con Node.js y TypeScript en producción todos los días. Si necesitas desarrollar una API profesional o escalar la que ya tienes, conoce nuestro modelo de desarrollo evolutivo.

Conocer modelo de trabajo →

Testing: protege tu código desde el inicio

Una API sin tests es una API que va a fallar en producción sin que nadie se entere hasta que un usuario se queje. Recomendamos Vitest por su velocidad y compatibilidad nativa con TypeScript:

npm install -D vitest supertest @types/supertest

Test de integración (el más valioso)

Los tests de integración verifican que tu endpoint funciona de principio a fin:

// modules/users/__tests__/user.integration.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../../app.js';

describe('POST /api/users', () => {
it('crea un usuario con datos válidos', async () => {
const res = await request(app).post('/api/users').send({
name: 'María García',
email: 'maria@empresa.com',
role: 'admin',
});

expect(res.status).toBe(201);
expect(res.body.data).toMatchObject({
name: 'María García',
email: 'maria@empresa.com',
});
});

it('rechaza email inválido', async () => {
const res = await request(app).post('/api/users').send({
name: 'Test',
email: 'no-es-email',
role: 'user',
});

expect(res.status).toBe(400);
expect(res.body.errors[0].field).toBe('body.email');
});
});

Test de servicio (lógica de negocio)

// modules/users/__tests__/user.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { getUser } from '../user.service.js';
import * as repo from '../user.repository.js';

vi.mock('../user.repository.js');

describe('getUser', () => {
it('lanza NotFoundError si el usuario no existe', async () => {
vi.mocked(repo.findById).mockResolvedValue(null);

await expect(getUser('123')).rejects.toThrow('Usuario no encontrado');
});
});
💡La regla 80/20 del testing

No intentes tener 100% de cobertura. Enfócate en: (1) tests de integración para cada endpoint (happy path + error más común), y (2) tests de servicio para lógica compleja. Eso cubre el 80% de los bugs con el 20% del esfuerzo.

Si quieres profundizar en por qué los bugs llegan a producción a pesar de tener tests, tenemos una guía completa sobre testing.

Cuándo esta arquitectura es suficiente (y cuándo no)

Esta arquitectura funciona para la gran mayoría de APIs empresariales: CRMs internos, sistemas de inventario, portales de clientes, integraciones con terceros.

Es suficiente cuando:

  • Tienes 1-5 desarrolladores trabajando en el proyecto
  • Tu API tiene hasta 50-100 endpoints
  • El tráfico es de cientos a miles de requests por segundo
  • Necesitas iterar rápido y entregar valor cada 2-3 semanas

Necesitas algo más robusto cuando:

  • Tu equipo supera 10+ desarrolladores (considera microservicios)
  • Necesitas procesamiento de miles de requests por segundo con latencia sub-10ms
  • Tu dominio de negocio es tan complejo que necesitas Domain-Driven Design

La recomendación: empieza con esta arquitectura. Siempre puedes extraer módulos a servicios independientes cuando sea necesario. Lo que no puedes hacer es empezar con microservicios y luego simplificar — la complejidad no se quita fácil.

Esto es lo que en MeepLab llamamos desarrollo evolutivo: construir lo que necesitas hoy con la estructura que permite crecer mañana.

Conclusión

Construir una API REST profesional con Node.js y TypeScript no es complicado — pero sí requiere decisiones de estructura que impactan la vida del proyecto por meses o años.

Los puntos clave:

  1. Estructura por módulos de dominio, no por tipo de archivo. Escala sin caos.
  2. TypeScript en modo strict. Si no lo activas, pierdes la mitad del valor.
  3. Validación con Zod en el borde. Nunca confíes en el input del usuario.
  4. Errores centralizados con clases personalizadas. Un handler, respuestas consistentes.
  5. Tests de integración primero. Cubren más con menos esfuerzo.
  6. Empieza monolítico, extrae cuando sea necesario. La simplicidad es una ventaja, no una limitación.

Tu equipo merece una base de código que puedan entender, mantener y escalar. Esta guía es el punto de partida.

🚀

¿Listo para profesionalizar tu backend?

Ya sea que estés empezando un proyecto nuevo o necesites reestructurar uno existente, nuestro equipo puede ayudarte a implementar esta arquitectura y dejarte una base sólida para crecer.

Agendar sesión técnica →

Recursos relacionados