DEV Community

Pedro Alvarado
Pedro Alvarado

Posted on

Semana 1 — Node + TypeScript con mentalidad Rails

De ApplicationController al Event Loop

Tiempo estimado: 6-8 horas | Nivel: Básico-Intermedio | Prerrequisitos: Rails sólido, JavaScript básico


📋 Índice y objetivos

Objetivo principal: Construir el foundation mental y técnico para entender NestJS desde una perspectiva Rails, dominando los conceptos fundamentales de Node y TypeScript.


🎯 Por qué esta semana es crucial

Diferencias arquitecturales clave

Rails + Puma Node.js Implicación práctica
Multi-thread por defecto Single-thread + Event Loop Un bloqueo = toda la app se congela
IO bloqueante (transparente) IO no-bloqueante (explícito) Debes pensar en async/await siempre
Convención sobre configuración Configuración explícita Control total, pero más decisiones
Rack middleware stack Express/Connect middleware Conceptos similares, API diferente

Modelo mental fundamental

Rails: "Cada request tiene su hilo, puedo bloquear tranquilo"

# En Rails esto es normal:
def show
  @user = User.find(params[:id])        # Query DB (bloquea 50ms)
  @posts = @user.posts.includes(:tags)  # Otra query (bloquea 30ms)
  render :show                          # Total: ~80ms bloqueado
end
Enter fullscreen mode Exit fullscreen mode

Node: "Un solo hilo para todos, debo ceder control constantemente"

// En Node debes pensar así:
async function show(req, res) {
  const user = await User.findById(req.params.id);     // Cede control
  const posts = await user.getPostsWithTags();         // Cede control
  res.json({ user, posts });                           // ~80ms total, sin bloquear
}
Enter fullscreen mode Exit fullscreen mode

Key insight: En Node, cada await es como un "yield" que permite que otros requests se procesen.


⚡ Setup del entorno

Instalación base

# Verificar Node LTS
node -v  # Necesitas >=18.x (LTS para estabilidad)
npm -v   # Viene incluido con Node

# Setup del proyecto
mkdir rails-to-node-week1
cd rails-to-node-week1

# Usar pnpm (más eficiente que npm)
npm install -g pnpm
pnpm init -y

# Dependencias de desarrollo
pnpm add -D typescript ts-node @types/node
pnpm add -D eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin
pnpm add -D jest ts-jest @types/jest

# Dependencias de runtime
pnpm add express zod
pnpm add -D @types/express supertest @types/supertest
Enter fullscreen mode Exit fullscreen mode

Configuración inicial

tsconfig.json (enfocado en lo esencial):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext", 
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "test"]
}
Enter fullscreen mode Exit fullscreen mode

package.json scripts:

{
  "scripts": {
    "build": "tsc",
    "dev": "ts-node --esm src/server.ts",
    "start": "node dist/server.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "format": "prettier --write 'src/**/*.ts'",
    "lint": "eslint src/**/*.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Jest config (jest.config.js):

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/test'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  collectCoverageFrom: ['src/**/*.ts'],
};
Enter fullscreen mode Exit fullscreen mode

Comparación Rails: El setup es como configurar un Gemfile, config/application.rb y spec_helper.rb desde cero, pero con más control granular.


🔧 TypeScript esencial para Rails devs

Tipado estructural vs nominal

En Ruby (nominal): las clases importan

class User
  attr_accessor :id, :email
end

class AdminUser < User
  attr_accessor :admin
end

# AdminUser ES-UN User por herencia
Enter fullscreen mode Exit fullscreen mode

En TypeScript (estructural): la forma importa

// Si tiene la misma "forma", es compatible
interface User {
  id: number;
  email: string;
}

interface Admin {
  id: number;
  email: string;
  admin: boolean;
}

// Admin es compatible con User automáticamente (duck typing extremo)
function processUser(user: User) {
  console.log(user.email);
}

const admin: Admin = { id: 1, email: 'admin@test.com', admin: true };
processUser(admin); // ✅ Funciona sin problema
Enter fullscreen mode Exit fullscreen mode

Equivalencias Rails → TypeScript

// src/types.ts

// Como un PORO con attr_accessor
export interface User {
  id: number;
  email: string;
  createdAt: Date;
  admin?: boolean; // ? = opcional (como un attr con default nil)
}

// Como strong parameters definidos
export type CreateUserParams = {
  email: string;
  password: string;
};

// Como un Service Object genérico
export interface Repository<T> {
  find(id: number): Promise<T | null>;
  create(params: Partial<T>): Promise<T>;
  update(id: number, params: Partial<T>): Promise<T>;
  destroy(id: number): Promise<void>;
}

// Union types = como enums pero más flexibles
export type UserStatus = 'active' | 'inactive' | 'banned';

// Genéricos = como módulos parametrizados
export interface ApiResponse<T> {
  data: T;
  success: boolean;
  errors?: string[];
}

// Utility types = como meta-programación Ruby
export type UserUpdate = Partial<User>;           // Todos los campos opcionales
export type UserCreate = Omit<User, 'id'>;        // User sin el id
export type UserEmail = Pick<User, 'email'>;      // Solo el email
Enter fullscreen mode Exit fullscreen mode

Type guards y narrowing

// src/type-guards.ts

// Como un método de validación en Ruby
export function isValidEmail(value: unknown): value is string {
  return typeof value === 'string' && value.includes('@');
}

// Refinamiento de tipos (como rescue/ensure pero para tipos)
export function processInput(input: string | number | undefined) {
  if (typeof input === 'undefined') {
    return 'No input provided';
  }

  if (typeof input === 'string') {
    return input.toUpperCase(); // TS sabe que es string aquí
  }

  return input * 2; // TS sabe que es number aquí
}

// Assertion personalizada
export function assertIsUser(value: unknown): asserts value is User {
  if (!value || typeof value !== 'object') {
    throw new Error('Not a valid user object');
  }

  const obj = value as any;
  if (typeof obj.id !== 'number' || typeof obj.email !== 'string') {
    throw new Error('Invalid user structure');
  }
}
Enter fullscreen mode Exit fullscreen mode

Errores comunes viniendo de Rails

// ❌ MAL: Usar any (como no validar params en Rails)
function badFunction(data: any) {
  return data.something.nested.deep; // Puede explotar en runtime
}

// ✅ BIEN: Ser explícito con unknown y validar
function goodFunction(data: unknown) {
  if (typeof data === 'object' && data !== null && 'something' in data) {
    // Ahora puedes acceder de forma segura
    return (data as any).something;
  }
  throw new Error('Invalid data structure');
}

// ❌ MAL: Confiar en librerías sin tipos
const someLib = require('some-untyped-lib'); // No hay autocompletado ni validación

// ✅ BIEN: Usar @types o crear declaraciones
import someLib from 'some-untyped-lib';
// O crear types/some-lib.d.ts con las declaraciones
Enter fullscreen mode Exit fullscreen mode

🌊 Event Loop vs Rails Threading

Visualización del Event Loop

// src/event-loop-demo.ts

console.log('1. Sync start');

// Macro task (timer)
setTimeout(() => console.log('2. setTimeout 0ms'), 0);

// Micro task (promise)
Promise.resolve().then(() => console.log('3. Promise resolved'));

// I/O operation (file system)
import { readFile } from 'fs/promises';
readFile(__filename, 'utf8').then(() => console.log('4. File read'));

console.log('5. Sync end');

// Salida: 1, 5, 3, 2, 4
// Orden: sync → microtasks → macrotasks
Enter fullscreen mode Exit fullscreen mode

Comparación práctica: Manejo de múltiples operaciones

Rails (blocking, multi-thread):

# Cada request bloquea su thread, pero hay múltiples threads
def dashboard
  # Thread 1 - Request A
  users = User.all          # Bloquea 100ms
  posts = Post.recent       # Bloquea 50ms  
  stats = Stats.calculate   # Bloquea 200ms
  # Total: 350ms bloqueados, pero otros threads siguen trabajando
end
Enter fullscreen mode Exit fullscreen mode

Node (non-blocking, single-thread):

// src/comparison-demo.ts

// ❌ MAL: Bloquear en serie (like Rails but worse)
async function dashboardBad() {
  const users = await fetchUsers();    // 100ms
  const posts = await fetchPosts();    // 50ms  
  const stats = await fetchStats();    // 200ms
  return { users, posts, stats };      // 350ms total, bloquea TODO
}

// ✅ BIEN: Paralelizar operaciones
async function dashboardGood() {
  const [users, posts, stats] = await Promise.all([
    fetchUsers(),    // Los 3 en paralelo
    fetchPosts(),    
    fetchStats()
  ]);
  return { users, posts, stats }; // ~200ms total (el más lento)
}

// 🔥 MEJOR: Con manejo de errores
async function dashboardBest() {
  const results = await Promise.allSettled([
    fetchUsers(),
    fetchPosts(), 
    fetchStats()
  ]);

  return {
    users: results[0].status === 'fulfilled' ? results[0].value : [],
    posts: results[1].status === 'fulfilled' ? results[1].value : [],
    stats: results[2].status === 'fulfilled' ? results[2].value : null,
  };
}
Enter fullscreen mode Exit fullscreen mode

Event Loop Best Practices

// src/event-loop-best-practices.ts

// ✅ Para operaciones CPU intensivas: usar Worker Threads
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';

if (isMainThread) {
  // Main thread - no bloquear
  export function calculateHeavyTask(data: number[]): Promise<number> {
    return new Promise((resolve, reject) => {
      const worker = new Worker(__filename, { workerData: data });
      worker.on('message', resolve);
      worker.on('error', reject);
    });
  }
} else {
  // Worker thread - aquí sí podemos bloquear
  const data = workerData;
  const result = data.reduce((sum: number, num: number) => sum + num, 0);
  parentPort?.postMessage(result);
}

// ✅ Para I/O: siempre usar versiones async
import { readFile } from 'fs/promises'; // ✅ async
import { readFileSync } from 'fs';      // ❌ sync, bloquea

// ✅ Batch operations para evitar callback hell
async function processMany<T, R>(
  items: T[], 
  processor: (item: T) => Promise<R>, 
  batchSize: number = 10
): Promise<R[]> {
  const results: R[] = [];

  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    const batchResults = await Promise.all(batch.map(processor));
    results.push(...batchResults);
  }

  return results;
}
Enter fullscreen mode Exit fullscreen mode

🏗️ HTTP Server desde cero

Servidor básico (equivalente a Rack)

// src/basic-server.ts
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { parse } from 'url';

// Tipo para nuestras rutas (como Rails routes)
type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
type Routes = Map<string, RouteHandler>;

class BasicServer {
  private routes: Routes = new Map();

  // Como Rails route helper
  get(path: string, handler: RouteHandler) {
    this.routes.set(`GET:${path}`, handler);
    return this;
  }

  post(path: string, handler: RouteHandler) {
    this.routes.set(`POST:${path}`, handler);
    return this;
  }

  // Como Rails middleware stack
  private async handleRequest(req: IncomingMessage, res: ServerResponse) {
    const method = req.method || 'GET';
    const url = parse(req.url || '/', true);
    const routeKey = `${method}:${url.pathname}`;

    // Simple routing
    const handler = this.routes.get(routeKey);
    if (handler) {
      try {
        await handler(req, res);
      } catch (error) {
        res.statusCode = 500;
        res.end(JSON.stringify({ error: 'Internal server error' }));
      }
    } else {
      res.statusCode = 404;
      res.end(JSON.stringify({ error: 'Not found' }));
    }
  }

  listen(port: number, callback?: () => void) {
    const server = createServer(this.handleRequest.bind(this));
    server.listen(port, callback);
    return server;
  }
}

// Uso como Rails routes
const app = new BasicServer();

app.get('/health', (req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString() }));
});

app.get('/users', (req, res) => {
  // Simular consulta async
  setTimeout(() => {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify({ users: [{ id: 1, name: 'John' }] }));
  }, 100);
});

// Como bin/rails server
if (require.main === module) {
  app.listen(3000, () => {
    console.log('🚀 Server running on http://localhost:3000');
    console.log('   GET /health - Health check');
    console.log('   GET /users  - List users');
  });
}

export { BasicServer };
Enter fullscreen mode Exit fullscreen mode

Parsing de request body

// src/body-parser.ts
import { IncomingMessage } from 'http';

// Como Rails params parsing
export function parseBody(req: IncomingMessage): Promise<any> {
  return new Promise((resolve, reject) => {
    let body = '';

    req.on('data', chunk => {
      body += chunk.toString();
    });

    req.on('end', () => {
      try {
        const contentType = req.headers['content-type'] || '';

        if (contentType.includes('application/json')) {
          resolve(JSON.parse(body));
        } else if (contentType.includes('application/x-www-form-urlencoded')) {
          const params = new URLSearchParams(body);
          const result: Record<string, string> = {};
          for (const [key, value] of params) {
            result[key] = value;
          }
          resolve(result);
        } else {
          resolve(body);
        }
      } catch (error) {
        reject(new Error('Invalid body format'));
      }
    });

    req.on('error', reject);
  });
}
Enter fullscreen mode Exit fullscreen mode

🚂 Express: El Rack de Node

Setup básico con middleware stack

// src/express-server.ts
import express, { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

const app = express();

// Middleware stack (como Rails middleware)
// 1. Body parsing (como ActionDispatch::Request)
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// 2. Request ID (como Rails RequestId middleware)
app.use((req: Request, res: Response, next: NextFunction) => {
  const reqId = randomUUID();
  req.headers['x-request-id'] = reqId;
  res.setHeader('X-Request-Id', reqId);
  next();
});

// 3. Logging middleware (como Rails logger)
app.use((req: Request, res: Response, next: NextFunction) => {
  const start = Date.now();
  const reqId = req.headers['x-request-id'];

  console.log(`[${reqId}] → ${req.method} ${req.url}`);

  // Interceptar el end de la respuesta
  const originalEnd = res.end;
  res.end = function(chunk?: any, encoding?: any, cb?: any) {
    const duration = Date.now() - start;
    console.log(`[${reqId}] ← ${res.statusCode} (${duration}ms)`);
    return originalEnd.call(this, chunk, encoding, cb);
  };

  next();
});

// 4. CORS (como Rack::Cors)
app.use((req: Request, res: Response, next: NextFunction) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id');

  if (req.method === 'OPTIONS') {
    res.sendStatus(200);
    return;
  }

  next();
});
Enter fullscreen mode Exit fullscreen mode

Rutas y controladores

// src/controllers/users.controller.ts (como Rails controller)
import { Request, Response } from 'express';

// Simulamos un modelo (en Rails sería User.all, etc)
const users = [
  { id: 1, email: 'user1@example.com', createdAt: new Date() },
  { id: 2, email: 'user2@example.com', createdAt: new Date() },
];

export class UsersController {
  // GET /users (como index action)
  static async index(req: Request, res: Response) {
    // Simulamos query async
    await new Promise(resolve => setTimeout(resolve, 50));

    const page = parseInt(req.query.page as string) || 1;
    const limit = parseInt(req.query.limit as string) || 10;
    const offset = (page - 1) * limit;

    const paginatedUsers = users.slice(offset, offset + limit);

    res.json({
      users: paginatedUsers,
      pagination: {
        page,
        limit,
        total: users.length,
        pages: Math.ceil(users.length / limit)
      }
    });
  }

  // GET /users/:id (como show action)  
  static async show(req: Request, res: Response) {
    const id = parseInt(req.params.id);
    const user = users.find(u => u.id === id);

    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    res.json({ user });
  }

  // POST /users (como create action)
  static async create(req: Request, res: Response) {
    const { email } = req.body;

    if (!email || !email.includes('@')) {
      return res.status(422).json({ 
        error: 'Validation failed',
        details: { email: 'is required and must be valid' }
      });
    }

    const newUser = {
      id: users.length + 1,
      email,
      createdAt: new Date()
    };

    users.push(newUser);

    res.status(201).json({ user: newUser });
  }
}
Enter fullscreen mode Exit fullscreen mode

Montaje de rutas

// src/routes/index.ts (como config/routes.rb)
import { Router } from 'express';
import { UsersController } from '../controllers/users.controller';

const router = Router();

// Health check
router.get('/health', (req, res) => {
  res.json({ 
    status: 'ok', 
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

// Users routes (como resources :users)
router.get('/users', UsersController.index);
router.get('/users/:id', UsersController.show);
router.post('/users', UsersController.create);

export { router };

// En express-server.ts
app.use('/api/v1', router);
Enter fullscreen mode Exit fullscreen mode

Manejo de errores global

// src/middleware/error-handler.ts (como Rails rescue_from)
import { Request, Response, NextFunction } from 'express';

export interface AppError extends Error {
  statusCode?: number;
  isOperational?: boolean;
}

export function errorHandler(
  error: AppError,
  req: Request,
  res: Response,
  next: NextFunction
) {
  const reqId = req.headers['x-request-id'];

  console.error(`[${reqId}] ERROR:`, {
    message: error.message,
    stack: error.stack,
    url: req.url,
    method: req.method,
    body: req.body
  });

  const statusCode = error.statusCode || 500;
  const message = error.isOperational ? error.message : 'Internal server error';

  res.status(statusCode).json({
    error: message,
    requestId: reqId,
    timestamp: new Date().toISOString()
  });
}

// Usar en express-server.ts
app.use(errorHandler);
Enter fullscreen mode Exit fullscreen mode

🧪 Testing con Jest

Configuración y estructura

// test/helpers/test-server.ts
import express from 'express';
import { router } from '../../src/routes';

export function createTestApp() {
  const app = express();
  app.use(express.json());
  app.use('/api/v1', router);
  return app;
}

export const testUsers = [
  { id: 1, email: 'test1@example.com' },
  { id: 2, email: 'test2@example.com' },
];
Enter fullscreen mode Exit fullscreen mode

Tests de integración (como Rails request specs)

// test/users.e2e.spec.ts
import request from 'supertest';
import { createTestApp } from './helpers/test-server';

describe('Users API', () => {
  const app = createTestApp();

  describe('GET /api/v1/users', () => {
    it('devuelve lista de usuarios', async () => {
      const response = await request(app)
        .get('/api/v1/users')
        .expect('Content-Type', /json/)
        .expect(200);

      expect(response.body).toHaveProperty('users');
      expect(Array.isArray(response.body.users)).toBe(true);
      expect(response.body).toHaveProperty('pagination');
    });

    it('maneja paginación correctamente', async () => {
      const response = await request(app)
        .get('/api/v1/users?page=1&limit=1')
        .expect(200);

      expect(response.body.pagination).toMatchObject({
        page: 1,
        limit: 1
      });
    });

    it('incluye request ID en headers', async () => {
      const response = await request(app)
        .get('/api/v1/users')
        .expect(200);

      expect(response.headers['x-request-id']).toBeDefined();
    });
  });

  describe('POST /api/v1/users', () => {
    it('crea usuario con datos válidos', async () => {
      const userData = { email: 'newuser@example.com' };

      const response = await request(app)
        .post('/api/v1/users')
        .send(userData)
        .expect('Content-Type', /json/)
        .expect(201);

      expect(response.body.user).toMatchObject({
        email: userData.email,
        id: expect.any(Number),
        createdAt: expect.any(String)
      });
    });

    it('rechaza email inválido', async () => {
      const invalidData = { email: 'not-an-email' };

      const response = await request(app)
        .post('/api/v1/users')
        .send(invalidData)
        .expect('Content-Type', /json/)
        .expect(422);

      expect(response.body).toHaveProperty('error');
      expect(response.body).toHaveProperty('details');
    });

    it('rechaza request sin email', async () => {
      await request(app)
        .post('/api/v1/users')
        .send({})
        .expect(422);
    });
  });

  describe('GET /api/v1/users/:id', () => {
    it('devuelve usuario existente', async () => {
      const response = await request(app)
        .get('/api/v1/users/1')
        .expect(200);

      expect(response.body.user).toMatchObject({
        id: 1,
        email: expect.any(String)
      });
    });

    it('devuelve 404 para usuario inexistente', async () => {
      await request(app)
        .get('/api/v1/users/999')
        .expect(404);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Tests unitarios de utilidades

// test/utils/body-parser.spec.ts
import { IncomingMessage } from 'http';
import { parseBody } from '../../src/utils/body-parser';

// Mock de IncomingMessage
function createMockRequest(data: string, contentType: string): IncomingMessage {
  const EventEmitter = require('events');
  const req = new EventEmitter() as IncomingMessage;

  req.headers = { 'content-type': contentType };

  // Simular llegada de datos
  setTimeout(() => {
    req.emit('data', Buffer.from(data));
    req.emit('end');
  }, 10);

  return req;
}

describe('parseBody', () => {
  it('parsea JSON correctamente', async () => {
    const jsonData = '{"name": "John", "age": 30}';
    const req = createMockRequest(jsonData, 'application/json');

    const result = await parseBody(req);

    expect(result).toEqual({ name: 'John', age: 30 });
  });

  it('parsea form data correctamente', async () => {
    const formData = 'name=John&age=30';
    const req = createMockRequest(formData, 'application/x-www-form-urlencoded');

    const result = await parseBody(req);

    expect(result).toEqual({ name: 'John', age: '30' });
  });

  it('maneja JSON inválido', async () => {
    const invalidJson = '{"name": John}'; // JSON malformado
    const req = createMockRequest(invalidJson, 'application/json');

    await expect(parseBody(req)).rejects.toThrow('Invalid body format');
  });
});
Enter fullscreen mode Exit fullscreen mode

Setup de tests con lifecycle

// test/setup.ts
beforeEach(() => {
  // Reset de estado antes de cada test
  jest.clearAllMocks();
});

afterEach(() => {
  // Cleanup después de cada test
});

beforeAll(async () => {
  // Setup global (conexiones DB, etc)
});

afterAll(async () => {
  // Cleanup global
});
Enter fullscreen mode Exit fullscreen mode

✅ Validación antes de NestJS

Schema validation con Zod

// src/schemas/user.schema.ts (como Rails strong parameters + validation)
import { z } from 'zod';

export const CreateUserSchema = z.object({
  email: z.string()
    .email('Debe ser un email válido')
    .min(5, 'Email muy corto')
    .max(100, 'Email muy largo'),

  password: z.string()
    .min(8, 'Password debe tener al menos 8 caracteres')
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Password debe contener mayúscula, minúscula y número'),

  name: z.string()
    .min(2, 'Nombre muy corto')
    .max(50, 'Nombre muy largo')
    .optional(),

  age: z.number()
    .int('Edad debe ser un número entero')
    .min(18, 'Debe ser mayor de edad')
    .max(120, 'Edad no válida')
    .optional()
});

export const UpdateUserSchema = CreateUserSchema.partial(); // Todos los campos opcionales

export const UserParamsSchema = z.object({
  id: z.string()
    .regex(/^\d+$/, 'ID debe ser un número')
    .transform(val => parseInt(val, 10))
});

export const PaginationSchema = z.object({
  page: z.string()
    .optional()
    .default('1')
    .transform(val => parseInt(val, 10))
    .refine(val => val > 0, 'Page debe ser mayor que 0'),

  limit: z.string()
    .optional()
    .default('10')
    .transform(val => parseInt(val, 10))
    .refine(val => val > 0 && val <= 100, 'Limit debe estar entre 1 y 100')
});

// Tipos derivados (como Rails type definitions)
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type UserParams = z.infer<typeof UserParamsSchema>;
export type PaginationParams = z.infer<typeof PaginationSchema>;
Enter fullscreen mode Exit fullscreen mode

Middleware de validación

// src/middleware/validation.middleware.ts (como Rails before_action con strong params)
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';

export interface ValidatedRequest<TBody = any, TParams = any, TQuery = any> extends Request {
  validatedBody?: TBody;
  validatedParams?: TParams;
  validatedQuery?: TQuery;
}

// Factory para crear middleware de validación
export function validateRequest<TBody, TParams, TQuery>(schemas: {
  body?: ZodSchema<TBody>;
  params?: ZodSchema<TParams>;
  query?: ZodSchema<TQuery>;
}) {
  return async (req: ValidatedRequest<TBody, TParams, TQuery>, res: Response, next: NextFunction) => {
    try {
      // Validar body
      if (schemas.body) {
        req.validatedBody = await schemas.body.parseAsync(req.body);
      }

      // Validar params
      if (schemas.params) {
        req.validatedParams = await schemas.params.parseAsync(req.params);
      }

      // Validar query
      if (schemas.query) {
        req.validatedQuery = await schemas.query.parseAsync(req.query);
      }

      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        return res.status(422).json({
          error: 'Validation failed',
          details: error.errors.reduce((acc, err) => {
            const path = err.path.join('.');
            acc[path] = err.message;
            return acc;
          }, {} as Record<string, string>)
        });
      }

      next(error);
    }
  };
}

// Helper para validación manual
export async function validateData<T>(schema: ZodSchema<T>, data: unknown): Promise<T> {
  try {
    return await schema.parseAsync(data);
  } catch (error) {
    if (error instanceof z.ZodError) {
      throw new Error(`Validation failed: ${error.errors.map(e => e.message).join(', ')}`);
    }
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Uso en controladores

// src/controllers/users.controller.ts (versión con validación)
import { Response, NextFunction } from 'express';
import { ValidatedRequest, validateRequest } from '../middleware/validation.middleware';
import { CreateUserSchema, UpdateUserSchema, UserParamsSchema, PaginationSchema } from '../schemas/user.schema';
import type { CreateUserInput, UpdateUserInput, UserParams, PaginationParams } from '../schemas/user.schema';

export class UsersController {
  // Middleware de validación para cada acción
  static validateCreate = validateRequest({ body: CreateUserSchema });
  static validateUpdate = validateRequest({ 
    body: UpdateUserSchema, 
    params: UserParamsSchema 
  });
  static validateShow = validateRequest({ params: UserParamsSchema });
  static validateIndex = validateRequest({ query: PaginationSchema });

  static async index(
    req: ValidatedRequest<never, never, PaginationParams>, 
    res: Response
  ) {
    const { page, limit } = req.validatedQuery!; // Ya validado por middleware

    // Simulamos query con paginación
    await new Promise(resolve => setTimeout(resolve, 50));

    const offset = (page - 1) * limit;
    const paginatedUsers = users.slice(offset, offset + limit);

    res.json({
      users: paginatedUsers,
      pagination: {
        page,
        limit,
        total: users.length,
        pages: Math.ceil(users.length / limit)
      }
    });
  }

  static async show(
    req: ValidatedRequest<never, UserParams>, 
    res: Response
  ) {
    const { id } = req.validatedParams!;
    const user = users.find(u => u.id === id);

    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    res.json({ user });
  }

  static async create(
    req: ValidatedRequest<CreateUserInput>, 
    res: Response
  ) {
    const userData = req.validatedBody!; // Ya validado y tipado

    // Simular validación de unicidad (como Rails validates :email, uniqueness: true)
    const existingUser = users.find(u => u.email === userData.email);
    if (existingUser) {
      return res.status(422).json({
        error: 'Validation failed',
        details: { email: 'already exists' }
      });
    }

    const newUser = {
      id: users.length + 1,
      ...userData,
      createdAt: new Date(),
      updatedAt: new Date()
    };

    users.push(newUser);

    res.status(201).json({ user: newUser });
  }

  static async update(
    req: ValidatedRequest<UpdateUserInput, UserParams>, 
    res: Response
  ) {
    const { id } = req.validatedParams!;
    const updateData = req.validatedBody!;

    const userIndex = users.findIndex(u => u.id === id);
    if (userIndex === -1) {
      return res.status(404).json({ error: 'User not found' });
    }

    users[userIndex] = {
      ...users[userIndex],
      ...updateData,
      updatedAt: new Date()
    };

    res.json({ user: users[userIndex] });
  }
}
Enter fullscreen mode Exit fullscreen mode

Rutas con validación

// src/routes/users.routes.ts
import { Router } from 'express';
import { UsersController } from '../controllers/users.controller';

const router = Router();

// Aplicar validación como middleware (como Rails before_action)
router.get('/', 
  UsersController.validateIndex,
  UsersController.index
);

router.get('/:id', 
  UsersController.validateShow,
  UsersController.show
);

router.post('/', 
  UsersController.validateCreate,
  UsersController.create
);

router.put('/:id',
  UsersController.validateUpdate, 
  UsersController.update
);

export { router as usersRouter };
Enter fullscreen mode Exit fullscreen mode

💪 Ejercicios prácticos

🔥 Ejercicio 1: Middleware de métricas (30 min)

Objetivo: Crear un sistema de métricas como los que usarías en Rails con StatsD o New Relic.

Requerimientos:

  • Contar requests por endpoint
  • Medir tiempo de respuesta promedio
  • Exponer métricas en GET /metrics
  • Persistir en memoria durante la sesión
// Starter code en src/middleware/metrics.middleware.ts
interface MetricData {
  count: number;
  totalTime: number;
  avgTime: number;
}

export class MetricsCollector {
  private metrics: Map<string, MetricData> = new Map();

  // TODO: Implementar colección de métricas

  // TODO: Implementar middleware para Express

  // TODO: Implementar endpoint /metrics
}
Enter fullscreen mode Exit fullscreen mode

💡 Solución

// src/middleware/metrics.middleware.ts
import { Request, Response, NextFunction } from 'express';

interface MetricData {
  count: number;
  totalTime: number;
  avgTime: number;
  lastAccess: Date;
}

export class MetricsCollector {
  private static instance: MetricsCollector;
  private metrics: Map = new Map();

  static getInstance(): MetricsCollector {
    if (!MetricsCollector.instance) {
      MetricsCollector.instance = new MetricsCollector();
    }
    return MetricsCollector.instance;
  }

  recordRequest(endpoint: string, responseTime: number) {
    const existing = this.metrics.get(endpoint) || {
      count: 0,
      totalTime: 0,
      avgTime: 0,
      lastAccess: new Date()
    };

    existing.count += 1;
    existing.totalTime += responseTime;
    existing.avgTime = existing.totalTime / existing.count;
    existing.lastAccess = new Date();

    this.metrics.set(endpoint, existing);
  }

  getMetrics(): Record {
    const result: Record = {};
    for (const [endpoint, data] of this.metrics) {
      result[endpoint] = { ...data };
    }
    return result;
  }

  middleware() {
    return (req: Request, res: Response, next: NextFunction) =&gt; {
      const start = Date.now();
      const endpoint = `${req.method} ${req.route?.path || req.path}`;

      res.on('finish', () =&gt; {
        const responseTime = Date.now() - start;
        this.recordRequest(endpoint, responseTime);
      });

      next();
    };
  }

  metricsEndpoint() {
    return (req: Request, res: Response) =&gt; {
      const metrics = this.getMetrics();
      const summary = {
        totalRequests: Object.values(metrics).reduce((sum, m) =&gt; sum + m.count, 0),
        endpoints: metrics,
        uptime: process.uptime(),
        memory: process.memoryUsage(),
        timestamp: new Date().toISOString()
      };

      res.json(summary);
    };
  }
}

// Uso en express-server.ts:
const metricsCollector = MetricsCollector.getInstance();
app.use(metricsCollector.middleware());
app.get('/metrics', metricsCollector.metricsEndpoint());
Enter fullscreen mode Exit fullscreen mode

🔥 Ejercicio 2: Sistema de retry con backoff (45 min)

Objetivo: Implementar una función de retry con backoff exponencial, como las que usas para jobs en Sidekiq.

Requerimientos:

  • Retry automático en caso de fallo
  • Backoff exponencial (1s, 2s, 4s, 8s...)
  • Configuración de número máximo de intentos
  • Logs detallados de cada intento
// Starter code
interface RetryOptions {
  maxAttempts: number;
  initialDelayMs: number;
  backoffMultiplier: number;
  maxDelayMs: number;
}

export async function withRetry<T>(
  operation: () => Promise<T>,
  options: RetryOptions
): Promise<T> {
  // TODO: Implementar lógica de retry
}
Enter fullscreen mode Exit fullscreen mode

💡 Solución

// src/utils/retry.ts
interface RetryOptions {
  maxAttempts: number;
  initialDelayMs: number;
  backoffMultiplier: number;
  maxDelayMs: number;
  onRetry?: (attempt: number, error: Error) =&gt; void;
}

const defaultOptions: RetryOptions = {
  maxAttempts: 3,
  initialDelayMs: 1000,
  backoffMultiplier: 2,
  maxDelayMs: 30000
};

export async function withRetry(
  operation: () =&gt; Promise,
  options: Partial = {}
): Promise {
  const config = { ...defaultOptions, ...options };
  let lastError: Error;

  for (let attempt = 1; attempt &lt;= config.maxAttempts; attempt++) {
    try {
      const result = await operation();

      if (attempt &gt; 1) {
        console.log(`✅ Operation succeeded on attempt ${attempt}`);
      }

      return result;
    } catch (error) {
      lastError = error as Error;

      if (attempt === config.maxAttempts) {
        console.error(`❌ Operation failed after ${config.maxAttempts} attempts`);
        break;
      }

      const delay = Math.min(
        config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt - 1),
        config.maxDelayMs
      );

      console.warn(`⚠️  Attempt ${attempt} failed: ${lastError.message}. Retrying in ${delay}ms...`);

      config.onRetry?.(attempt, lastError);

      await new Promise(resolve =&gt; setTimeout(resolve, delay));
    }
  }

  throw lastError!;
}

// Ejemplo de uso con una API externa
export async function fetchWithRetry(url: string): Promise {
  return withRetry(
    async () =&gt; {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      return response.json();
    },
    {
      maxAttempts: 5,
      initialDelayMs: 500,
      onRetry: (attempt, error) =&gt; {
        console.log(`Retry attempt ${attempt} for ${url}: ${error.message}`);
      }
    }
  );
}

// Test del retry
export async function testRetryFunction() {
  let attempts = 0;

  try {
    const result = await withRetry(async () =&gt; {
      attempts++;
      if (attempts &lt; 3) {
        throw new Error(`Simulated failure on attempt ${attempts}`);
      }
      return `Success on attempt ${attempts}`;
    });

    console.log('Result:', result);
  } catch (error) {
    console.error('Final error:', error.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

🔥 Ejercicio 3: API de Posts con validación completa (60 min)

Objetivo: Crear un CRUD completo para Posts con todas las mejores prácticas.

Requerimientos:

  • Schema de validación con Zod
  • Relación Posts → Comments (simulada)
  • Paginación, filtrado y ordenamiento
  • Tests completos
  • Manejo de errores
// Estructuras esperadas:
interface Post {
  id: number;
  title: string;
  body: string;
  authorId: number;
  status: 'draft' | 'published' | 'archived';
  tags: string[];
  createdAt: Date;
  updatedAt: Date;
}

interface Comment {
  id: number;
  postId: number;
  author: string;
  body: string;
  createdAt: Date;
}

// Endpoints a implementar:
// GET /posts?page=1&limit=10&status=published&sort=createdAt:desc
// GET /posts/:id
// POST /posts
// PUT /posts/:id
// DELETE /posts/:id
// GET /posts/:id/comments
// POST /posts/:id/comments
Enter fullscreen mode Exit fullscreen mode

💡 Solución parcial (estructura base)

// src/schemas/post.schema.ts
import { z } from 'zod';

export const CreatePostSchema = z.object({
  title: z.string()
    .min(5, 'Título muy corto')
    .max(200, 'Título muy largo'),

  body: z.string()
    .min(50, 'Contenido muy corto')
    .max(10000, 'Contenido muy largo'),

  authorId: z.number()
    .int('Author ID debe ser entero')
    .positive('Author ID debe ser positivo'),

  status: z.enum(['draft', 'published', 'archived'])
    .default('draft'),

  tags: z.array(z.string())
    .min(1, 'Al menos un tag es requerido')
    .max(10, 'Máximo 10 tags')
});

export const PostQuerySchema = z.object({
  page: z.string().optional().default('1').transform(Number),
  limit: z.string().optional().default('10').transform(Number),
  status: z.enum(['draft', 'published', 'archived']).optional(),
  sort: z.string()
    .regex(/^(title|createdAt|updatedAt):(asc|desc)$/)
    .default('createdAt:desc')
});

export type CreatePostInput = z.infer;
export type PostQuery = z.infer;
Enter fullscreen mode Exit fullscreen mode

🔥 Ejercicio 4: Rate limiting personalizado (45 min)

Objetivo: Implementar rate limiting sin librerías externas, como lo harías en Rails con Rack::Attack.

Requerimientos:

  • Diferentes límites por endpoint
  • Límites por IP y por API key
  • Ventana deslizante (sliding window)
  • Headers informativos (X-RateLimit-*)

💡 Solución

// src/middleware/rate-limiter.ts
interface RateLimitRule {
  windowMs: number;
  maxRequests: number;
  identifier: (req: Request) =&gt; string;
}

interface RequestRecord {
  timestamps: number[];
  blocked: boolean;
}

export class RateLimiter {
  private requests: Map = new Map();

  constructor(private rules: Record) {}

  middleware(ruleKey: string) {
    return (req: Request, res: Response, next: NextFunction) =&gt; {
      const rule = this.rules[ruleKey];
      if (!rule) return next();

      const identifier = `${ruleKey}:${rule.identifier(req)}`;
      const now = Date.now();

      // Obtener registro o crear uno nuevo
      const record = this.requests.get(identifier) || { timestamps: [], blocked: false };

      // Limpiar timestamps antiguos (sliding window)
      record.timestamps = record.timestamps.filter(
        timestamp =&gt; now - timestamp &lt; rule.windowMs
      );

      // Verificar límite
      if (record.timestamps.length &gt;= rule.maxRequests) {
        record.blocked = true;

        // Headers informativos
        res.setHeader('X-RateLimit-Limit', rule.maxRequests);
        res.setHeader('X-RateLimit-Remaining', 0);
        res.setHeader('X-RateLimit-Reset', new Date(now + rule.windowMs));

        return res.status(429).json({
          error: 'Rate limit exceeded',
          retryAfter: Math.ceil(rule.windowMs / 1000)
        });
      }

      // Registrar request actual
      record.timestamps.push(now);
      record.blocked = false;
      this.requests.set(identifier, record);

      // Headers informativos
      res.setHeader('X-RateLimit-Limit', rule.maxRequests);
      res.setHeader('X-RateLimit-Remaining', rule.maxRequests - record.timestamps.length);

      next();
    };
  }
}

// Configuración de reglas
const rateLimiter = new RateLimiter({
  'api-general': {
    windowMs: 15 * 60 * 1000, // 15 minutos
    maxRequests: 100,
    identifier: (req) =&gt; req.ip
  },
  'api-create': {
    windowMs: 60 * 1000, // 1 minuto  
    maxRequests: 5,
    identifier: (req) =&gt; req.ip
  },
  'api-key': {
    windowMs: 60 * 1000,
    maxRequests: 1000,
    identifier: (req) =&gt; req.headers['x-api-key'] as string || req.ip
  }
});

// Uso en rutas:
app.use('/api', rateLimiter.middleware('api-general'));
app.post('/api/users', rateLimiter.middleware('api-create'), UsersController.create);
Enter fullscreen mode Exit fullscreen mode

🔥 Ejercicio 5: Logger estructurado (30 min)

Objetivo: Crear un sistema de logging estructurado como los que usas en Rails con semantic_logger.

Requerimientos:

  • Diferentes niveles (debug, info, warn, error)
  • Contexto estructurado (JSON)
  • Correlation ID tracking
  • Formato configurable

💡 Solución

// src/utils/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LogContext {
  requestId?: string;
  userId?: number;
  action?: string;
  duration?: number;
  [key: string]: any;
}

export class StructuredLogger {
  private static instance: StructuredLogger;

  static getInstance(): StructuredLogger {
    if (!StructuredLogger.instance) {
      StructuredLogger.instance = new StructuredLogger();
    }
    return StructuredLogger.instance;
  }

  private log(level: LogLevel, message: string, context: LogContext = {}) {
    const logEntry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      ...context,
      pid: process.pid,
      hostname: require('os').hostname()
    };

    const output = JSON.stringify(logEntry);

    switch (level) {
      case 'debug':
        console.debug(output);
        break;
      case 'info':
        console.info(output);
        break;
      case 'warn':
        console.warn(output);
        break;
      case 'error':
        console.error(output);
        break;
    }
  }

  debug(message: string, context?: LogContext) {
    this.log('debug', message, context);
  }

  info(message: string, context?: LogContext) {
    this.log('info', message, context);
  }

  warn(message: string, context?: LogContext) {
    this.log('warn', message, context);
  }

  error(message: string, context?: LogContext) {
    this.log('error', message, context);
  }
}

export const logger = StructuredLogger.getInstance();

// Middleware para logging de requests
export function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();
  const requestId = req.headers['x-request-id'] as string;

  logger.info('Request started', {
    requestId,
    method: req.method,
    url: req.url,
    userAgent: req.headers['user-agent'],
    ip: req.ip
  });

  res.on('finish', () =&gt; {
    const duration = Date.now() - start;

    logger.info('Request completed', {
      requestId,
      method: req.method,
      url: req.url,
      statusCode: res.statusCode,
      duration
    });
  });

  next();
}
Enter fullscreen mode Exit fullscreen mode

✅ Checklist de salida de la Semana 1

🎯 Conceptos fundamentales

  • [ ] Entiendo el Event Loop: Sé por qué await no bloquea y cuándo usar Promise.all
  • [ ] Diferencia threading vs event-driven: Puedo explicar por qué Node es eficiente en I/O pero vulnerable a CPU blocking
  • [ ] TypeScript estructural: Uso interfaces, tipos utilitarios y type guards correctamente
  • [ ] Async/await vs Promises: Manejo correctamente la asincronía sin callback hell

🛠️ Habilidades técnicas

  • [ ] HTTP server desde cero: Puedo crear un servidor básico sin frameworks
  • [ ] Express middleware stack: Entiendo el orden de middleware y cómo crear los míos
  • [ ] Validación de datos: Implemento validación robusta con Zod (preparación para NestJS DTOs)
  • [ ] Testing con Jest: Escribo tests unitarios y de integración efectivos

🏗️ Arquitectura y patrones

  • [ ] Separación de responsabilidades: Controllers delgados, lógica en services
  • [ ] Manejo de errores: Sistema consistente de error handling y logging
  • [ ] Métricas y observabilidad: Implemento logging estructurado y colección de métricas
  • [ ] Rate limiting: Protejo APIs con límites configurables

🚀 Preparación para NestJS

  • [ ] Mindset de inyección de dependencias: Entiendo cómo las dependencias se pasan explícitamente
  • [ ] Decoradores mentalmente: Aunque no los uso aún, entiendo el patrón de metadatos
  • [ ] Estructura modular: Organizo código en módulos reutilizables
  • [ ] Testing como ciudadano de primera clase: Tests son parte integral, no afterthought

🔧 Desarrollo práctico

  • [ ] Setup de proyecto Node: Configuro TypeScript, ESLint, Prettier y Jest desde cero
  • [ ] Debugging efectivo: Uso console, debugger y herramientas de Node para troubleshooting
  • [ ] NPM ecosystem: Entiendo package.json, scripts, y gestión de dependencias
  • [ ] Performance awareness: Identifico bottlenecks y optimizo operaciones I/O

🎯 Reflexiones finales y próximos pasos

Lo que lograste esta semana

Cambio mental clave: Pasaste de pensar en "threads que pueden bloquear" (Rails) a "un loop que debe fluir" (Node). Este cambio mental es fundamental para todo lo que viene.

Foundation sólida: Ahora tienes los conceptos base que NestJS abstraerá con decoradores y inyección de dependencias. Cuando veas @Injectable() o @Controller(), entenderás qué está pasando por debajo.

Herramientas fundamentales: TypeScript, Jest, Express y Zod son el toolkit que usarás constantemente en NestJS. Ya no serán "nueva sintaxis", serán herramientas familiares.

Preparación para Semana 2

En la semana 2 verás cómo NestJS toma todo lo que hiciste manualmente esta semana y lo hace declarativo:

  • Tu validateRequest middleware → @Body() + DTO + ValidationPipe
  • Tu error handling manual → Exception Filters automáticos
  • Tu logging middleware → Interceptors configurables
  • Tu rate limiting → Guards reutilizables
  • Tu estructura manual → Módulos con inyección de dependencias

🏆 Validation final

¿Puedes hacer esto en 10 minutos?

  1. Crear un nuevo proyecto Node + TypeScript
  2. Configurar un endpoint POST /api/users con validación de email y password
  3. Escribir un test que valide tanto casos exitosos como errores 422
  4. Añadir middleware de logging que muestre duración de requests

Si puedes hacerlo cómodamente, estás listo para NestJS. Si no, repasa las secciones donde tengas dudas.


📚 Recursos de profundización

Event Loop y asincronía

TypeScript avanzado

Testing en Node

Express profundo


Conclusión: Esta semana construiste el foundation mental y técnico necesario para aprovechar al máximo NestJS. Ya no será "sintaxis nueva encima de conceptos nuevos", sino "sintaxis nueva encima de conceptos que dominas". ¡Esa diferencia lo cambia todo!

Próxima semana: Toma todo lo que hiciste manualmente y dale superpoderes con decoradores, inyección de dependencias y la arquitectura modular de NestJS.

Top comments (0)