TypeScript tips que aprendí después de 3 años en producción

Después de tres años escribiendo TypeScript en producción — refactors grandes, codebases de 200k+ líneas, equipos de 5+ devs — aprendí que los tips de blog típicos (strict mode, no-any, etc.) son la base, pero el salto real viene de patrones que cambian cómo pensás el código.

No es sobre tipos. Es sobre contratos explícitos.

1. Discriminated unions: el superpoder oculto

La mayoría del código que veo usa enums o strings sueltos para modelar estados. Eso es un anti-patrón.

❌ Lo que veo siempre

type UserStatus = 'active' | 'inactive' | 'pending';

function getUserStatus(user: User) {
  if (user.status === 'active') {
    // do something
    return { canLogin: true, features: user.features };
  }
  // 'inactive' o 'pending' — qué devuelvo acá?
}

✅ Lo que funciona

type User = 
  | { status: 'active'; features: string[]; lastSeen: Date }
  | { status: 'inactive'; deactivatedAt: Date; reason: string }
  | { status: 'pending'; inviteToken: string; expiresAt: Date };

function getLoginInfo(user: User) {
  switch (user.status) {
    case 'active':
      // TypeScript sabe que `user.features` existe
      return { canLogin: true, features: user.features };
    case 'inactive':
      // TypeScript sabe que `user.reason` existe
      return { canLogin: false, reason: user.reason };
    case 'pending':
      // TypeScript sabe que `user.inviteToken` existe
      return { canLogin: false, token: user.inviteToken };
  }
}

Por qué importa: TypeScript te obliga a manejar cada caso explícitamente. Si agregás un nuevo status y olvidás un caso, el compilador te avisa. No más bugs en producción porque “se nos pasó validar ese caso”.

En producción: este patrón solo nos ahorró como 8 bugs en el último año. Vale oro.

2. Utility types: el toolkit que ya tenés

TypeScript trae utility types que resuelven el 80% de las transformaciones que hacés a mano.

Partial, Required, Pick, Omit

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  preferences: { theme: 'light' | 'dark' };
}

// "Partial" para updates
type UserUpdate = Partial<Pick<User, 'name' | 'email' | 'preferences'>>;

// "Pick" para DTOs de respuesta
type PublicUser = Pick<User, 'id' | 'name' | 'avatar'>;

// "Omit" para crear
type NewUser = Omit<User, 'id' | 'createdAt'>;

Record, Readonly, NonNullable

// Mapeo de roles a permisos
type Permissions = Record<'admin' | 'user' | 'guest', string[]>;

// "Readonly" para immutability a nivel tipos
type FrozenConfig = Readonly<{
  apiUrl: string;
  retries: number;
}>;

// "NonNullable" después de un filter
const validUsers: NonNullable<User>[] = users.filter(Boolean) as NonNullable<User>[];

Tip: antes de escribir un nuevo type, pensá si Pick, Omit, Partial o Required ya lo resuelven.

3. Exhaustiveness checks con never

Este es el patrón que más uso. Obliga al compilador a verificar que manejaste todos los casos.

type Shape = 'circle' | 'square' | 'triangle';

function area(shape: Shape): number {
  switch (shape) {
    case 'circle': return Math.PI;
    case 'square': return 1;
    case 'triangle': return 0.5;
    default:
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Si agregás un nuevo shape ('hexagon') y olvidás un case, TypeScript marca error: Type 'string' is not assignable to type 'never'. El compilador te obliga a manejarlo.

En CI: este patrón nos agarró 12+ casos no manejados en el último año, antes de que lleguen a producción.

4. Type guards: que el código fluya, no pelee

Type guards le dicen a TypeScript “si este check pasa, sabé que el tipo cambió”.

interface ApiResponse<T> {
  data: T;
  error?: { code: number; message: string };
}

function handleResponse<T>(res: ApiResponse<T>): T {
  if (res.error) {
    // Acá `res.error` existe, está narrowed
    throw new Error(`${res.error.code}: ${res.error.message}`);
  }
  // Acá TypeScript sabe que `res.error` es undefined
  return res.data;
}

Custom type guards

interface Dog { bark(): void; }
interface Cat { meow(): void; }

function isDog(pet: Dog | Cat): pet is Dog {
  return (pet as Dog).bark !== undefined;
}

function interact(pet: Dog | Cat) {
  if (isDog(pet)) {
    pet.bark(); // OK
  } else {
    pet.meow(); // OK
  }
}

5. Template literal types: tipos dinámicos sin perder safety

type Event = 'click' | 'focus' | 'blur';
type Element = 'button' | 'input' | 'select';

type Handler = `on${Capitalize<Event>}`; // 'onClick' | 'onFocus' | 'onBlur'
type ElementEvent = `${Element}:${Event}`; // 'button:click' | 'input:focus' | etc.

const handler: Handler = 'onClick'; // OK
const invalid: Handler = 'onHover'; // ERROR

Útil para analytics events, action types de Redux, keys de query params, etc.

6. as const vs tipos explícitos

// ❌ Pierdes literal types
const config = {
  apiUrl: 'https://api.example.com',
  retries: 3,
};

// ✅ TypeScript infiere los literal types exactos
const config = {
  apiUrl: 'https://api.example.com',
  retries: 3,
} as const;

// Ahora `config.retries` es tipo `3`, no `number`

as const es especialmente útil para arrays:

const ROUTES = ['/', '/blog', '/contact'] as const;
type Route = typeof ROUTES[number]; // '/' | '/blog' | '/contact'

7. El truco del satisfies (TS 4.9+)

as fuerza un tipo (a veces mintiendo). satisfies verifica que el valor matchee el tipo sin cambiar la inferencia.

type Config = Record<string, string>;

const config = {
  apiUrl: 'https://api.example.com',
  retries: 3, // ⚠️ debería ser string
} satisfies Config;
//      ^^^^^^ Error: Type 'number' is not assignable to type 'string'

Mejor que as Config porque preserva los tipos literales exactos para autocomplete.

Bonus: el patrón que más uso en APIs

type ApiResult<T> = 
  | { ok: true; data: T }
  | { ok: false; error: { code: number; message: string } };

async function fetchUser(id: string): Promise<ApiResult<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return { ok: false, error: { code: res.status, message: res.statusText } };
    }
    const data = await res.json();
    return { ok: true, data };
  } catch (e) {
    return { ok: false, error: { code: 0, message: String(e) } };
  }
}

// Y el consumer:
const result = await fetchUser('123');
if (result.ok) {
  // result.data está narrowed a User
  console.log(result.data.name);
} else {
  // result.error está narrowed
  console.error(result.error.message);
}

Cero exceptions en runtime. Cada branch está type-checked.

Cierre

TypeScript no es sobre agregar tipos a JavaScript. Es sobre hacer que el compilador sea tu primer reviewer. Cuanto más restrictivo seas en los tipos, más bugs atrapás antes de que lleguen a producción.

Estos patrones me cambiaron cómo pienso el código. No son teoría — son cosas que aplico todos los días en proyectos reales.

Si querés que profundice en alguno (discriminated unions, template literal types, type-level programming), dejámelo en los comentarios.


René Kuhm · Senior Fullstack Engineer · TecnoDespegue