Skip to content

Buenas prácticas ​

Ya conoces las herramientas de TypeScript. La diferencia entre un proyecto tipado "porque sí" y uno profesional está en cómo las usas: dónde anotar, dónde confiar en la inferencia, cómo evitar que los tipos mientan y cómo organizarlos. En esta lección reunimos las prácticas que usan los equipos profesionales y cerramos con un proyecto: un cliente tipado para una API de tareas.

Concepto ​

Un buen sistema de tipos es como un buen contrato: preciso, sin letras chiquitas y con una sola versión oficial. Las reglas de esta lección persiguen tres metas:

  1. Que los tipos no mientan (nada de any ni as innecesarios).
  2. Que haya una sola fuente de verdad (derivar en vez de duplicar).
  3. Que los datos externos se validen en el borde de la app.

1. strict siempre ​

Activa "strict": true desde el primer día, y considera también:

json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": false,
    "verbatimModuleSyntax": true
  }
}

Sin strict, null cabe en cualquier tipo y los parámetros sin anotar son any en silencio: pierdes la mitad del valor de TypeScript.

2. Evita any ​

any es contagioso: todo lo que toca se vuelve any y TS deja de revisarlo.

ts
// ❌ any se propaga
function procesar(datos: any) {
  const total = datos.items.reduce((s: any, i: any) => s + i.precio, 0);
  return total; // any: nadie sabe qué es
}

// ✅ Describe lo que esperas
interface Pedido { items: { precio: number }[] }

function procesarBien(datos: Pedido): number {
  return datos.items.reduce((s, i) => s + i.precio, 0);
}

Si de verdad no sabes el tipo, usa unknown. Si un any es temporal durante una migración, déjale un comentario // TODO(ts) para encontrarlo después.

Buena práctica

Agrega typescript-eslint a tu proyecto con la regla @typescript-eslint/no-explicit-any. Así ningún any entra al código sin que alguien lo note en la revisión.

3. unknown en los bordes ​

Los "bordes" son los lugares por donde entran datos que no controlas: fetch, JSON.parse, localStorage, formularios, req.body, mensajes de postMessage. Ahí, trata los datos como unknown y valídalos:

ts
import { z } from 'zod';

const PreferenciasSchema = z.object({
  tema: z.enum(['claro', 'oscuro']),
  idioma: z.string()
});
type Preferencias = z.infer<typeof PreferenciasSchema>;

const PREFERENCIAS_DEFECTO: Preferencias = { tema: 'claro', idioma: 'es' };

function leerPreferencias(): Preferencias {
  try {
    const crudo: unknown = JSON.parse(localStorage.getItem('prefs') ?? 'null');
    const r = PreferenciasSchema.safeParse(crudo);
    return r.success ? r.data : PREFERENCIAS_DEFECTO;
  } catch {
    return PREFERENCIAS_DEFECTO;
  }
}

Dentro de la app, una vez validados, los datos ya son confiables y tipados.

4. Deriva tipos en lugar de duplicarlos ​

Cada tipo duplicado es un tipo que algún día quedará desactualizado.

ts
// ❌ Duplicado: si agregas un campo a Usuario, olvidarás los demás
interface Usuario { id: number; nombre: string; email: string }
interface CrearUsuario { nombre: string; email: string }

// ✅ Derivado
type CrearUsuarioDto = Omit<Usuario, 'id'>;

// ✅ Tipos desde valores
const ROLES = ['admin', 'editor', 'lector'] as const;
type Rol = (typeof ROLES)[number];

// ✅ Tipos desde funciones
function crearEstadoInicial() {
  return { cargando: false, items: [] as Usuario[], filtro: '' };
}
type Estado = ReturnType<typeof crearEstadoInicial>;

// ✅ Tipos desde esquemas de validación
// type Usuario = z.infer<typeof UsuarioSchema>;

5. satisfies ​

satisfies comprueba que un valor cumple un tipo sin perder la información precisa que TS infirió. Es la herramienta ideal para objetos de configuración:

ts
type Ruta = { path: string; publica: boolean };

// Con anotación: TS "olvida" qué claves existen
const rutasA: Record<string, Ruta> = {
  inicio: { path: '/', publica: true },
  panel: { path: '/panel', publica: false }
};
rutasA.inicoo; // sin error: cualquier string vale como clave 😬

// Con satisfies: valida la forma y conserva las claves exactas
const rutas = {
  inicio: { path: '/', publica: true },
  panel: { path: '/panel', publica: false }
} satisfies Record<string, Ruta>;

rutas.inicio.path;  // ✅ autocompletado de 'inicio' y 'panel'
rutas.inicoo;
// ❌ Property 'inicoo' does not exist. Did you mean 'inicio'?

// También atrapa errores de forma
const colores = {
  primario: '#2563eb',
  peligro: 123
} satisfies Record<string, string>;
// ❌ Type 'number' is not assignable to type 'string'.

Regla práctica:

  • Anotación (const x: Tipo = ...): cuando quieres que la variable sea exactamente de ese tipo, más amplio.
  • satisfies: cuando quieres validar la forma pero conservar los detalles (claves, literales).
  • as: casi nunca; solo cuando sabes algo que TS no puede saber.

6. Cuidado con as ​

as no convierte nada: solo le dice a TS "créeme". Si te equivocas, el error aparece en producción.

ts
// ❌ Mentira peligrosa
const usuario = {} as Usuario;
console.log(usuario.email.toLowerCase()); // 💥 undefined

// ✅ Construye el valor completo o valida
const usuarioOk: Usuario = { id: 1, nombre: 'Ana', email: 'ana@correo.com' };

Usos legítimos de as: as const, estrechar después de una comprobación que TS no entiende, o el resultado de res.json() en un cliente que ya validas en otro lugar.

Error común

El "doble cast" valor as unknown as OtroTipo siempre compila. Si lo necesitas, casi siempre es señal de que el diseño de tus tipos tiene un problema.

7. Organización de tipos ​

txt
src/
  api/
    cliente.ts          ← fetchJson y manejo de errores
    tareas.api.ts       ← funciones de la API de tareas
  tareas/
    tarea.types.ts      ← Tarea, DTOs, esquemas de la feature
    tarea.utils.ts
  types/
    api.ts              ← ApiResponse, Paginado, ApiError (compartidos)
    globales.d.ts       ← declare global, módulos sin tipos
  • Coloca los tipos junto a la funcionalidad que los usa.
  • Comparte en types/ solo lo que de verdad es transversal.
  • Exporta tipos con nombres claros y en singular: Tarea, no ITarea ni TareasType.
  • Usa import type para importaciones de solo tipos.

8. Migrar JavaScript a TypeScript gradualmente ​

No necesitas reescribir todo en un fin de semana. Un plan probado:

  1. Instala TS y crea un tsconfig permisivo: "allowJs": true (TS acepta archivos .js), "checkJs": false y "strict": false al inicio.
  2. Prueba con JSDoc en archivos JS para obtener revisión sin renombrarlos:
js
// @ts-check
/**
 * @param {number} precio
 * @param {number} [descuento]
 * @returns {number}
 */
export function precioFinal(precio, descuento = 0) {
  return precio - precio * descuento;
}
  1. Renombra archivo por archivo de .js a .ts, empezando por las "hojas" (utilidades sin dependencias) y subiendo hacia los componentes.
  2. Tipa primero los modelos (Usuario, Producto) y las respuestas de la API: son los que más se reutilizan.
  3. Activa strict cuando puedas; si son demasiados errores, activa sus opciones una por una (noImplicitAny, luego strictNullChecks).
  4. Agrega tsc --noEmit al CI para que nadie introduzca errores nuevos.

Usa // @ts-expect-error (no @ts-ignore) para errores temporales: si el error desaparece, TS te avisará que ese comentario sobra.

Checklist profesional ​

  • strict: true y noUncheckedIndexedAccess: true.
  • Script typecheck (tsc --noEmit) corriendo en CI y antes de cada commit.
  • Cero any explícitos (regla de ESLint activa).
  • Datos externos tratados como unknown y validados (Zod o type guards).
  • Tipos derivados (Omit, Pick, typeof, z.infer) en vez de duplicados.
  • Uniones discriminadas para estados, con exhaustividad usando never.
  • Sin ! para silenciar null; sin as para mentir.
  • Retornos anotados en funciones exportadas.
  • import type para tipos y verbatimModuleSyntax activo.
  • satisfies para objetos de configuración.

Proyecto final: cliente tipado de una API de tareas ​

Construirás un pequeño cliente que consume una API REST de tareas con: errores tipados, validación de respuestas, DTOs derivados y un resultado tipo Resultado<T> sin excepciones para la UI. Usaremos https://jsonplaceholder.typicode.com/todos, una API de prueba gratuita.

1. Tipos y esquemas ​

ts
// src/tareas/tarea.types.ts
import { z } from 'zod';

// Lo que llega de la API (en inglés)
export const ApiTareaSchema = z.object({
  id: z.number(),
  userId: z.number(),
  title: z.string(),
  completed: z.boolean()
});
export type ApiTarea = z.infer<typeof ApiTareaSchema>;

// El modelo de nuestra app (en español)
export interface Tarea {
  id: number;
  usuarioId: number;
  titulo: string;
  completada: boolean;
}

// DTOs derivados del modelo
export type CrearTareaDto = Omit<Tarea, 'id'>;
export type ActualizarTareaDto = Partial<Omit<Tarea, 'id' | 'usuarioId'>>;

// Filtros posibles
export type FiltroTareas = 'todas' | 'pendientes' | 'completadas';

// Conversión API → app
export function aTarea(t: ApiTarea): Tarea {
  return { id: t.id, usuarioId: t.userId, titulo: t.title, completada: t.completed };
}

2. Cliente HTTP genérico ​

ts
// src/api/cliente.ts
import type { z } from 'zod';

export type ApiError =
  | { tipo: 'red'; mensaje: string }
  | { tipo: 'http'; status: number; mensaje: string }
  | { tipo: 'validacion'; mensaje: string };

export type Resultado<T> =
  | { ok: true; datos: T }
  | { ok: false; error: ApiError };

const BASE_URL = 'https://jsonplaceholder.typicode.com';

// Hace la petición y valida la respuesta con el esquema recibido
export async function pedir<T>(
  ruta: string,
  esquema: z.ZodType<T>,
  opciones: RequestInit = {}
): Promise<Resultado<T>> {
  let res: Response;

  try {
    res = await fetch(`${BASE_URL}${ruta}`, {
      ...opciones,
      headers: { 'Content-Type': 'application/json', ...opciones.headers }
    });
  } catch {
    return { ok: false, error: { tipo: 'red', mensaje: 'Sin conexión con el servidor' } };
  }

  if (!res.ok) {
    return {
      ok: false,
      error: { tipo: 'http', status: res.status, mensaje: `Error HTTP ${res.status}` }
    };
  }

  const json: unknown = await res.json();
  const validado = esquema.safeParse(json);

  if (!validado.success) {
    return {
      ok: false,
      error: { tipo: 'validacion', mensaje: 'La respuesta no tiene el formato esperado' }
    };
  }

  return { ok: true, datos: validado.data };
}

// Mensaje amigable para cualquier error (con exhaustividad)
export function describirError(error: ApiError): string {
  switch (error.tipo) {
    case 'red':
      return `📡 ${error.mensaje}`;
    case 'http':
      return error.status === 404 ? 'No encontrado' : `Servidor: ${error.mensaje}`;
    case 'validacion':
      return `⚠️ ${error.mensaje}`;
    default: {
      const imposible: never = error;
      return imposible;
    }
  }
}

3. Funciones de la API de tareas ​

ts
// src/tareas/tareas.api.ts
import { z } from 'zod';
import { pedir, type Resultado } from '../api/cliente';
import {
  ApiTareaSchema,
  aTarea,
  type Tarea,
  type CrearTareaDto,
  type ActualizarTareaDto,
  type FiltroTareas
} from './tarea.types';

// Transforma el resultado exitoso sin tocar el error
function mapear<A, B>(r: Resultado<A>, fn: (a: A) => B): Resultado<B> {
  return r.ok ? { ok: true, datos: fn(r.datos) } : r;
}

export async function listarTareas(limite = 10): Promise<Resultado<Tarea[]>> {
  const r = await pedir(`/todos?_limit=${limite}`, z.array(ApiTareaSchema));
  return mapear(r, (lista) => lista.map(aTarea));
}

export async function obtenerTarea(id: number): Promise<Resultado<Tarea>> {
  const r = await pedir(`/todos/${id}`, ApiTareaSchema);
  return mapear(r, aTarea);
}

export async function crearTarea(dto: CrearTareaDto): Promise<Resultado<Tarea>> {
  const r = await pedir('/todos', ApiTareaSchema, {
    method: 'POST',
    body: JSON.stringify({ userId: dto.usuarioId, title: dto.titulo, completed: dto.completada })
  });
  return mapear(r, aTarea);
}

export async function actualizarTarea(
  id: number,
  cambios: ActualizarTareaDto
): Promise<Resultado<Tarea>> {
  const r = await pedir(`/todos/${id}`, ApiTareaSchema, {
    method: 'PATCH',
    body: JSON.stringify({ title: cambios.titulo, completed: cambios.completada })
  });
  return mapear(r, aTarea);
}

export function filtrarTareas(tareas: Tarea[], filtro: FiltroTareas): Tarea[] {
  switch (filtro) {
    case 'todas':
      return tareas;
    case 'pendientes':
      return tareas.filter((t) => !t.completada);
    case 'completadas':
      return tareas.filter((t) => t.completada);
  }
}

4. Uso ​

ts
// src/main.ts
import { listarTareas, crearTarea, filtrarTareas } from './tareas/tareas.api';
import { describirError } from './api/cliente';

async function main(): Promise<void> {
  const resultado = await listarTareas(5);

  if (!resultado.ok) {
    console.error(describirError(resultado.error));
    return;
  }

  // Aquí resultado.datos es Tarea[]
  const pendientes = filtrarTareas(resultado.datos, 'pendientes');
  console.log(`Tienes ${pendientes.length} tareas pendientes:`);
  pendientes.forEach((t) => console.log(`- [ ] ${t.titulo}`));

  const nueva = await crearTarea({ usuarioId: 1, titulo: 'Aprender TypeScript', completada: false });
  if (nueva.ok) console.log('Creada con id', nueva.datos.id);
}

main();

Ejecútalo con:

bash
npm i zod
npm i -D typescript tsx
npx tsx src/main.ts

Fíjate en lo que logramos: ninguna función lanza excepciones hacia la UI, todos los errores están tipados y enumerados, las respuestas se validan en el borde, los DTOs se derivan del modelo y TypeScript te obliga a revisar ok antes de usar datos.

Ejercicios ​

  1. Reescribe este objeto usando satisfies para que temas.oscruo marque error:
ts
const temas: Record<string, { fondo: string }> = {
  claro: { fondo: '#fff' },
  oscuro: { fondo: '#111' }
};
  1. Agrega al proyecto final una función eliminarTarea(id: number): Promise<Resultado<null>> (la API responde {} al borrar).
  2. Agrega un tipo de error 'tiempo' a ApiError. ¿Qué te dice TypeScript en describirError? Corrígelo.
  3. Escribe un plan de 5 pasos para migrar a TypeScript un proyecto JS con 40 archivos.
Ver solución
ts
// 1
const temas = {
  claro: { fondo: '#fff' },
  oscuro: { fondo: '#111' }
} satisfies Record<string, { fondo: string }>;
// temas.oscruo → ❌ Property 'oscruo' does not exist

// 2 (en tareas.api.ts)
export async function eliminarTarea(id: number): Promise<Resultado<null>> {
  const r = await pedir(`/todos/${id}`, z.object({}), { method: 'DELETE' });
  return mapear(r, () => null);
}

// 3: al agregar | { tipo: 'tiempo'; segundos: number } a ApiError,
// la línea `const imposible: never = error` marca error porque
// ese caso no se maneja. Se corrige agregando:
//   case 'tiempo':
//     return `⏱️ La petición tardó más de ${error.segundos} s`;
  1. Plan de migración:

  2. Instalar typescript, crear tsconfig.json con allowJs: true, strict: false y el script typecheck.

  3. Tipar los modelos y respuestas de la API en src/types/.

  4. Renombrar utilidades (archivos sin dependencias) a .ts y corregir errores.

  5. Migrar servicios y luego componentes, de abajo hacia arriba.

  6. Activar noImplicitAny, después strict, y agregar tsc --noEmit al CI.

Resumen ​

  • strict siempre, más noUncheckedIndexedAccess.
  • Evita any; usa unknown y valida en los bordes (Zod o type guards).
  • Deriva tipos (Omit, typeof, z.infer) para tener una sola fuente de verdad.
  • satisfies valida sin perder precisión; as y ! solo cuando no hay alternativa.
  • Organiza los tipos junto a su funcionalidad; comparte solo lo transversal.
  • Migra de JS a TS gradualmente: allowJs, archivo por archivo, strict al final.
  • Un cliente de API profesional valida respuestas, tipa errores y devuelve Resultado<T>.

¡Felicidades, terminaste el curso de TypeScript! Aplícalo en tus proyectos con React y Node.

Siguiente: Curso de React

Academia Web — aprende, copia y construye.