Tema
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:
- Que los tipos no mientan (nada de
anyniasinnecesarios). - Que haya una sola fuente de verdad (derivar en vez de duplicar).
- 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, noITareaniTareasType. - Usa
import typepara importaciones de solo tipos.
8. Migrar JavaScript a TypeScript gradualmente
No necesitas reescribir todo en un fin de semana. Un plan probado:
- Instala TS y crea un tsconfig permisivo:
"allowJs": true(TS acepta archivos.js),"checkJs": falsey"strict": falseal inicio. - 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;
}- Renombra archivo por archivo de
.jsa.ts, empezando por las "hojas" (utilidades sin dependencias) y subiendo hacia los componentes. - Tipa primero los modelos (
Usuario,Producto) y las respuestas de la API: son los que más se reutilizan. - Activa
strictcuando puedas; si son demasiados errores, activa sus opciones una por una (noImplicitAny, luegostrictNullChecks). - Agrega
tsc --noEmital 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: trueynoUncheckedIndexedAccess: true.- Script
typecheck(tsc --noEmit) corriendo en CI y antes de cada commit. - Cero
anyexplícitos (regla de ESLint activa). - Datos externos tratados como
unknowny 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 silenciarnull; sinaspara mentir. - Retornos anotados en funciones exportadas.
import typepara tipos yverbatimModuleSyntaxactivo.satisfiespara 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.tsFí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
- Reescribe este objeto usando
satisfiespara quetemas.oscruomarque error:
ts
const temas: Record<string, { fondo: string }> = {
claro: { fondo: '#fff' },
oscuro: { fondo: '#111' }
};- Agrega al proyecto final una función
eliminarTarea(id: number): Promise<Resultado<null>>(la API responde{}al borrar). - Agrega un tipo de error
'tiempo'aApiError. ¿Qué te dice TypeScript endescribirError? Corrígelo. - 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`;Plan de migración:
Instalar
typescript, creartsconfig.jsonconallowJs: true,strict: falsey el scripttypecheck.Tipar los modelos y respuestas de la API en
src/types/.Renombrar utilidades (archivos sin dependencias) a
.tsy corregir errores.Migrar servicios y luego componentes, de abajo hacia arriba.
Activar
noImplicitAny, despuésstrict, y agregartsc --noEmital CI.
Resumen
strictsiempre, másnoUncheckedIndexedAccess.- Evita
any; usaunknowny valida en los bordes (Zod o type guards). - Deriva tipos (
Omit,typeof,z.infer) para tener una sola fuente de verdad. satisfiesvalida sin perder precisión;asy!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,strictal 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