En esta página

Migración de JavaScript a TypeScript

12 min lectura TextoCap. 5 — TypeScript en práctica

¿Por qué migrar de JavaScript a TypeScript?

Migrar un proyecto JavaScript existente a TypeScript puede parecer una tarea desalentadora, pero los beneficios son concretos: errores detectados en tiempo de compilación en lugar de producción, autocompletado preciso, refactoring seguro y documentación viva a través de los tipos. La clave está en hacerlo de forma gradual, sin detener el desarrollo ni generar cientos de errores de golpe.


Estrategias de migración

Existen dos enfoques principales:

Estrategia Descripción Ideal para
Gradual Habilitas TypeScript junto a JS existente, migras archivo a archivo Proyectos grandes en producción activa
Completa Conviertes todo de una vez Proyectos pequeños o con tiempo de pausa disponible

La estrategia gradual es la más común y es la que aprenderás en esta lección.


Paso 1: Instalar TypeScript y crear tsconfig.json

npm install --save-dev typescript @types/node
npx tsc --init

Configuración inicial permisiva para proyectos JavaScript existentes:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": false,
    "allowJs": true,
    "checkJs": false,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Con allowJs: true, TypeScript compila tanto archivos .js como .ts. Con checkJs: false, los archivos JS no se verifican tipado inicialmente.


Paso 2: Activar checkJs de forma gradual

En lugar de activar checkJs globalmente (lo que generaría muchos errores), puedes activarlo archivo por archivo añadiendo la directiva al inicio del fichero JS:

// src/utils/formatear.js
// @ts-check  ← activa verificación en este archivo específico

/**
 * Formatea un precio con moneda.
 * @param {number} precio
 * @param {string} moneda
 * @returns {string}
 */
function formatearPrecio(precio, moneda) {
  return new Intl.NumberFormat("es-ES", {
    style: "currency",
    currency: moneda,
  }).format(precio);
}

module.exports = { formatearPrecio };

Los comentarios JSDoc @param y @returns son reconocidos por TypeScript para inferir tipos en archivos JS con @ts-check.


Paso 3: Renombrar archivos .js → .ts

Empieza por los módulos más pequeños y sin dependencias, generalmente las utilidades y los modelos:

# Orden recomendado:
# 1. Tipos / modelos / interfaces
# 2. Utilidades puras (sin efectos secundarios)
# 3. Servicios y repositorios
# 4. Controladores / rutas
# 5. Punto de entrada (app.js → app.ts)

Al renombrar un archivo, TypeScript mostrará errores donde los tipos son any implícito o donde hay incompatibilidades. Corrígelos progresivamente.


Paso 4: Aumentar la severidad del tsconfig

A medida que migras archivos, endurece el tsconfig.json progresivamente:

{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false
  }
}

Luego, cuando la mayoría del código esté migrado:

{
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true
  }
}

@ts-ignore vs @ts-expect-error

Cuando encuentres un error que no puedes resolver de inmediato (deuda técnica), tienes dos directivas:

// @ts-ignore: suprime CUALQUIER error en la siguiente línea
// ⚠️ No avisa si el error desaparece (puede quedar obsoleta)
// @ts-ignore
const resultado = funcionLegada(dato);

// @ts-expect-error: documenta que ESPERAS un error
// ✅ TypeScript avisa si el error se resuelve y la directiva ya no es necesaria
// @ts-expect-error TODO: tipar funcionLegada en [issue #42]
const resultado2 = funcionLegada(dato);

Usa siempre @ts-expect-error con un comentario que explique el contexto y, si es posible, un enlace al ticket donde está registrada la deuda.


Tipar librerías de terceros sin tipos

DefinitelyTyped (@types/*)

La mayoría de librerías populares tienen paquetes de tipos en DefinitelyTyped:

# Express
npm install --save-dev @types/express

# Node.js
npm install --save-dev @types/node

# Lodash
npm install --save-dev @types/lodash

# Jest
npm install --save-dev @types/jest

Comprobar si una librería tiene tipos propios

# Opción 1: buscar en node_modules
ls node_modules/mi-libreria/index.d.ts

# Opción 2: consultar el campo "types" en package.json
cat node_modules/mi-libreria/package.json | grep '"types"'

# Opción 3: buscar en DefinitelyTyped
npx @definitelytyped/dts-critic mi-libreria

Crear tipos para una librería sin soporte

// types/libreria-sin-tipos.d.ts
declare module "libreria-sin-tipos" {
  export interface OpcionesCliente {
    baseUrl: string;
    apiKey: string;
    timeout?: number;
  }

  export interface RespuestaBase<T = unknown> {
    datos: T;
    meta: {
      total: number;
      pagina: number;
    };
  }

  export class Cliente {
    constructor(opciones: OpcionesCliente);
    get<T>(ruta: string): Promise<RespuestaBase<T>>;
    post<T>(ruta: string, cuerpo: unknown): Promise<RespuestaBase<T>>;
    put<T>(ruta: string, cuerpo: unknown): Promise<RespuestaBase<T>>;
    delete(ruta: string): Promise<void>;
  }

  export const version: string;
  export default Cliente;
}

Ejemplo completo: migración de una app Express

A continuación verás el proceso completo de migrar una pequeña API Express desde JavaScript puro hasta TypeScript estricto.

Estado inicial (JavaScript)

// app.js — antes de la migración
const express = require("express");
const app = express();

app.use(express.json());

const usuarios = [];
let siguienteId = 1;

app.get("/usuarios", (req, res) => {
  res.json(usuarios);
});

app.post("/usuarios", (req, res) => {
  const { nombre, email } = req.body;
  if (!nombre || !email) {
    return res.status(400).json({ error: "Nombre y email son obligatorios" });
  }
  const usuario = { id: siguienteId++, nombre, email };
  usuarios.push(usuario);
  res.status(201).json(usuario);
});

app.get("/usuarios/:id", (req, res) => {
  const usuario = usuarios.find(u => u.id === parseInt(req.params.id));
  if (!usuario) return res.status(404).json({ error: "No encontrado" });
  res.json(usuario);
});

app.listen(3000);

Estado final (TypeScript estricto)

// app.ts — después de la migración completa
import express from "express";
import type { Request, Response, NextFunction } from "express";

// ──────────────────────────────────────────────────────────────
// Tipos del dominio
// ──────────────────────────────────────────────────────────────
interface Usuario {
  id: number;
  nombre: string;
  email: string;
  creadoEn: Date;
}

interface CrearUsuarioBody {
  nombre: string;
  email: string;
}

interface ErrorResponse {
  error: string;
  codigo?: string;
}

// ──────────────────────────────────────────────────────────────
// Repositorio en memoria
// ──────────────────────────────────────────────────────────────
class RepositorioUsuarios {
  private usuarios: Usuario[] = [];
  private siguienteId = 1;

  obtenerTodos(): Usuario[] {
    return [...this.usuarios];
  }

  obtenerPorId(id: number): Usuario | undefined {
    return this.usuarios.find((u) => u.id === id);
  }

  crear(datos: CrearUsuarioBody): Usuario {
    const usuario: Usuario = {
      ...datos,
      id: this.siguienteId++,
      creadoEn: new Date(),
    };
    this.usuarios.push(usuario);
    return usuario;
  }
}

// ──────────────────────────────────────────────────────────────
// Aplicación
// ──────────────────────────────────────────────────────────────
const app = express();
const repo = new RepositorioUsuarios();

app.use(express.json());

// GET /usuarios
app.get("/usuarios", (_req: Request, res: Response<Usuario[]>) => {
  res.json(repo.obtenerTodos());
});

// POST /usuarios
app.post(
  "/usuarios",
  (req: Request<object, Usuario | ErrorResponse, CrearUsuarioBody>, res: Response) => {
    const { nombre, email } = req.body;

    if (!nombre?.trim() || !email?.trim()) {
      res.status(400).json({ error: "Nombre y email son obligatorios" });
      return;
    }

    if (!email.includes("@")) {
      res.status(400).json({ error: "El email no tiene un formato válido" });
      return;
    }

    const usuario = repo.crear({ nombre: nombre.trim(), email: email.trim() });
    res.status(201).json(usuario);
  }
);

// GET /usuarios/:id
app.get(
  "/usuarios/:id",
  (req: Request<{ id: string }>, res: Response<Usuario | ErrorResponse>) => {
    const id = parseInt(req.params.id, 10);

    if (isNaN(id)) {
      res.status(400).json({ error: "El id debe ser un número" });
      return;
    }

    const usuario = repo.obtenerPorId(id);

    if (!usuario) {
      res.status(404).json({ error: `Usuario con id ${id} no encontrado` });
      return;
    }

    res.json(usuario);
  }
);

// Manejador de errores global
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error("Error no manejado:", err);
  res.status(500).json({ error: "Error interno del servidor" });
});

// Arranque
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Servidor escuchando en http://localhost:${PORT}`);
});

export default app;

Lista de verificación de migración

Sigue este orden para una migración sin sorpresas:

  1. Instala dependencias: typescript, @types/node, tipos de tus librerías principales.
  2. Crea tsconfig.json con allowJs: true, strict: false.
  3. No rompe nada: compila el proyecto y asegúrate de que el JavaScript existente sigue funcionando.
  4. Activa @ts-check en archivos pequeños y añade JSDoc donde sea útil.
  5. Renombra archivos de más atómicos a más complejos: modelos → utilidades → servicios → rutas → app.
  6. Corrige errores archivos por archivos; usa @ts-expect-error para los que no puedes resolver aún.
  7. Endurece tsconfig.json: activa noImplicitAny, luego strictNullChecks, finalmente strict: true.
  8. Elimina directivas temporales: revisa todos los @ts-expect-error y ciérralos cuando la deuda esté saldada.
  9. Configura CI: añade tsc --noEmit como paso del pipeline para evitar regresiones.

Con este proceso puedes migrar incluso proyectos grandes de forma segura, manteniendo la aplicación funcional en todo momento. En la lección final pondrás en práctica todos los conceptos del curso en un proyecto completo de gestión de tareas.

Empieza por los archivos más usados
En lugar de migrar en orden de carpetas, identifica los módulos que más archivos importan (utilidades, modelos, servicios core). Al tipar esos primero, propagar los tipos al resto del proyecto es mucho más fácil porque TypeScript infiere automáticamente.
@ts-ignore oculta el problema, @ts-expect-error lo documenta
Prefiere `@ts-expect-error` sobre `@ts-ignore`. Si el error que anotaste desaparece (porque lo arreglaste), `@ts-expect-error` te avisa que la directiva ya no es necesaria. `@ts-ignore` suprime siempre, incluso cuando ya no hay error.