TypeScript tiene un rico sistema de tipos, pero no necesitas entenderlo todo para ser productivo. La realidad es que el 90% del trabajo lo hace un puñado de características que usas cada día. Esta es la referencia práctica — primitivos, uniones, genéricos, tipos de utilidad y los patrones de estrechez que hacen que todo encaje. Sin teoría abstracta; cada ejemplo es el tipo de código que escribes en un proyecto real. Si conoces bien JavaScript y has hecho algo de TypeScript pero quieres solidificar tu modelo mental, esto es para ti. La historia completa está en el Manual de TypeScript — este artículo es la versión 80/20.

Primitivos y literales

Los tipos primitivos de TypeScript se corresponden directamente con los tipos de tiempo de ejecución de JavaScript: string, number, boolean, null, undefined, bigint y symbol. Usarás los tres primeros constantemente; los demás aparecen en contextos específicos.

ts
// Primitives — the foundation
let userId: number = 42;
let username: string = 'alice';
let isActive: boolean = true;
let deletedAt: Date | null = null;     // nullable pattern
let refreshToken: string | undefined;  // optional pattern

Donde TypeScript se vuelve interesante es con los tipos literales. En lugar de simplemente decir que un valor es un string, puedes decir que debe ser uno de un conjunto específico de cadenas. Esto es mucho más útil que una anotación string plana porque TypeScript puede detectar valores inválidos en tiempo de compilación:

ts
type Direction = 'north' | 'south' | 'east' | 'west';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;

function navigate(dir: Direction) {
  console.log(`Moving ${dir}`);
}

navigate('north');  // ✅
navigate('up');     // ❌ Argument of type '"up"' is not assignable to type 'Direction'

// Combine with string for "known values plus free-form"
type EventName = 'click' | 'focus' | 'blur' | (string & {});
El truco de (string & {}) al final mantiene el autocompletado funcionando para los valores conocidos mientras sigue aceptando cualquier cadena. Es un patrón común en las bibliotecas de sistemas de diseño.

interface vs type — cuándo usar cada uno

Esta es la pregunta que hace todo principiante de TypeScript. La respuesta práctica: usa interface para formas de objetos — especialmente las que representan contratos de API públicos o que otros tipos extenderán. Usa type para uniones, intersecciones, tipos mapeados, y cualquier cosa que no sea puramente una forma de objeto. En la práctica son en gran medida intercambiables para formas de objetos — elige una convención y sé consistente dentro de una base de código.

ts
// interface — object shapes, extensible via extends
interface User {
  id: number;
  email: string;
  createdAt: Date;
}

interface AdminUser extends User {
  role: 'admin';
  permissions: string[];
}

// type — unions, intersections, aliases for anything
type ID = string | number;
type Nullable<T> = T | null;

// Intersection — combine two shapes (common for mixins / HOC props)
type WithTimestamps = {
  createdAt: Date;
  updatedAt: Date;
};

type UserRecord = User & WithTimestamps;

// type is required here — interface can't express a union of shapes
type ApiResponse<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; code: number };

Una diferencia significativa: las interfaces soportan la fusión de declaraciones — puedes declarar la misma interfaz dos veces y TypeScript fusiona las definiciones. Así es como las bibliotecas aumentan los tipos globales (p.ej. agregando propiedades a Window). Los tipos no se fusionan; redeclarar un tipo es un error.

Tipos unión e intersección

Los tipos unión (A | B) dicen «este valor es A o B». Los tipos intersección (A & B) dicen «este valor es tanto A como B al mismo tiempo». Las uniones están en todas partes en código real — el patrón más poderoso que permiten es la unión discriminada, que es cómo modelas datos que pueden estar en diferentes estados sin recurrir a campos opcionales en todas partes.

ts
// Discriminated union — model API response states cleanly
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; retryable: boolean };

// TypeScript narrows the type inside each branch
function render<T>(state: FetchState<T>) {
  switch (state.status) {
    case 'idle':    return '<p>Not started</p>';
    case 'loading': return '<p>Loading...</p>';
    case 'success': return `<pre>${JSON.stringify(state.data)}</pre>`;
    case 'error':   return `<p>Error: ${state.error}</p>`;
  }
}

// Intersection — common in React/Angular for composing prop types
type ButtonBaseProps = {
  label: string;
  disabled?: boolean;
};

type IconButtonProps = ButtonBaseProps & {
  icon: string;
  iconPosition: 'left' | 'right';
};

El patrón de unión discriminada se basa en un discriminante — una propiedad literal (aquí status) que es única para cada variante. TypeScript usa esa propiedad para reducir el tipo completo dentro de ramas condicionales, dándote acceso con tipos seguros a los campos específicos de la variante como data o error.

Los genéricos — la parte que tropieza a todos

El problema motivador: quieres una función que funcione en múltiples tipos, pero usar any desecha toda la información de tipo. Los genéricos lo resuelven — son parámetros de tipo, escritos entre paréntesis angulares, que permiten a una función o interfaz funcionar sobre una familia de tipos mientras mantiene la seguridad de tipos completa.

ts
// Without generics — any loses all information
function first(arr: any[]): any {
  return arr[0];
}
const x = first([1, 2, 3]); // x is 'any' — TypeScript can't help you here

// With generics — T is inferred from the argument
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const n = first([1, 2, 3]);       // n: number | undefined ✅
const s = first(['a', 'b', 'c']); // s: string | undefined ✅

La sintaxis <T> declara un parámetro de tipo. Puedes nombrarlo como quieras — T es solo la convención para un único tipo genérico. Cuando llamas a la función, TypeScript infiere T de los argumentos, por lo que raramente necesitas escribirlo explícitamente. Las interfaces genéricas son igual de útiles para modelar formas de API:

ts
// Generic interface — the API wrapper pattern every codebase has
interface ApiResponse<T> {
  data: T;
  meta: {
    page: number;
    totalPages: number;
    totalItems: number;
  };
}

interface User {
  id: number;
  name: string;
  email: string;
}

// The response type is fully typed — no casting needed
async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const res = await fetch('/api/users');
  return res.json();
}

const response = await fetchUsers();
response.data[0].email; // ✅ TypeScript knows this is a string
response.meta.totalPages; // ✅ TypeScript knows this is a number

// Constrained generics — T must have an 'id' field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const user = findById(users, 42); // T inferred as User
Cuándo usar genéricos: cuando te encuentras escribiendo la misma firma de función dos veces con diferentes tipos, o haciendo casting con as para obtener el tipo de retorno que quieres. Esas son las dos señales de que un genérico sería más limpio.

Tipos de utilidad que realmente usarás

TypeScript incluye un conjunto de tipos de utilidad integrados que transforman tipos existentes en nuevos tipos. Eliminan la necesidad de duplicar manualmente o ajustar definiciones de tipos. Aquí están los que aparecen constantemente en bases de código reales:

ts
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// Partial<T> — all properties become optional
// Perfect for PATCH/update payloads
type UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; ... }

async function updateUser(id: number, payload: Partial<User>) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(payload)
  });
}

// Required<T> — all properties become required (opposite of Partial)
interface Config {
  apiUrl?: string;
  timeout?: number;
  retries?: number;
}
type ResolvedConfig = Required<Config>;
// { apiUrl: string; timeout: number; retries: number }

// Pick<T, K> — keep only specified properties
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Use in list views where you only need the display fields

// Omit<T, K> — exclude specified properties
type PublicUser = Omit<User, 'role' | 'createdAt'>;
// Safe to expose in API responses

// Readonly<T> — prevents mutation (great for config and frozen state)
const config: Readonly<ResolvedConfig> = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};
// config.timeout = 10000; // ❌ Cannot assign to 'timeout' because it is a read-only property

// Record<K, V> — typed dictionary
type UserCache = Record<number, User>;
const cache: UserCache = {};
cache[42] = { id: 42, name: 'Alice', email: '[email protected]', role: 'user', createdAt: new Date() };

// Common pattern: mapping string keys to a known value shape
type FeatureFlags = Record<string, { enabled: boolean; rolloutPct: number }>;

// ReturnType<T> — infer the return type of a function
function createSession(userId: number) {
  return {
    token: crypto.randomUUID(),
    userId,
    expiresAt: new Date(Date.now() + 86_400_000)
  };
}

type Session = ReturnType<typeof createSession>;
// { token: string; userId: number; expiresAt: Date }
// — no need to define the type separately and keep it in sync

unknown vs any vs never

Estos tres tipos confunden a la mayoría de los desarrolladores hasta que entienden qué problema resuelve cada uno. any es la vía de escape — deshabilita la verificación de tipos por completo para ese valor. Es útil al migrar JavaScript a TypeScript, pero el abuso derrota el propósito de TypeScript. unknown es la alternativa con tipos seguros: el valor podría ser cualquier cosa, pero debes reducirlo antes de poder hacer algo con él. never es un tipo que nunca puede ocurrir — el fondo de la jerarquía de tipos.

ts
// any — type checking disabled, use sparingly
function dangerousTransform(input: any) {
  return input.toUpperCase(); // TypeScript won't warn even if this crashes at runtime
}

// unknown — safe alternative, forces you to check before using
function safeTransform(input: unknown): string {
  if (typeof input === 'string') {
    return input.toUpperCase(); // ✅ narrowed to string inside this block
  }
  if (typeof input === 'number') {
    return input.toFixed(2);    // ✅ narrowed to number
  }
  throw new Error(`Cannot transform value of type ${typeof input}`);
}

// Common use: parsing untrusted data (API responses, localStorage, user input)
function parseConfig(raw: unknown): ResolvedConfig {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'apiUrl' in raw &&
    typeof (raw as any).apiUrl === 'string'
  ) {
    return raw as ResolvedConfig;
  }
  throw new Error('Invalid config format');
}

// never — the exhaustive switch pattern
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    default:
      // If you add a new Shape variant and forget to handle it here,
      // TypeScript will error: Type 'NewShape' is not assignable to type 'never'
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Estrechez de tipos en práctica

La estrechez de tipos es cómo TypeScript refina un tipo amplio (string | number, unknown, una unión discriminada) a un tipo específico dentro de un bloque condicional. La documentación de estrechez de TypeScript cubre cada guardia en profundidad — aquí están los patrones que escribirás diariamente.

ts
// typeof — primitive narrowing
function formatValue(val: string | number | boolean): string {
  if (typeof val === 'string')  return val.trim();
  if (typeof val === 'number')  return val.toLocaleString();
  return val ? 'Yes' : 'No';
  // TypeScript knows val must be boolean here
}

// instanceof — class/object narrowing
function handleError(err: unknown): string {
  if (err instanceof Error)   return err.message;
  if (typeof err === 'string') return err;
  return 'An unknown error occurred';
}

// in operator — property existence check
type Cat = { meow(): void };
type Dog = { bark(): void };

function makeNoise(animal: Cat | Dog) {
  if ('meow' in animal) {
    animal.meow(); // TypeScript narrows to Cat
  } else {
    animal.bark(); // TypeScript narrows to Dog
  }
}

// Discriminated union narrowing (most common in component state)
type LoadState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string };

function renderState<T>(state: LoadState<T>) {
  if (state.status === 'success') {
    console.log(state.data);    // ✅ data is accessible
  } else if (state.status === 'error') {
    console.error(state.message); // ✅ message is accessible
  }
}

// Type predicates — custom type guards
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj
  );
}

// After isUser() returns true, TypeScript knows obj is User
function processPayload(payload: unknown) {
  if (isUser(payload)) {
    console.log(payload.email); // ✅ fully typed
  }
}

El operador typeof es la herramienta de estrechez más fundamental. Combinado con instanceof, el operador in y las verificaciones de propiedades discriminantes, puedes manejar prácticamente cualquier escenario de estrechez sin recurrir a any o castings inseguros.

Conclusión

El sistema de tipos de TypeScript recompensa aprender bien los fundamentos sobre memorizar características oscuras. Los tipos literales y las uniones discriminadas eliminan categorías enteras de bugs de tiempo de ejecución. Los genéricos te permiten escribir abstracciones reutilizables y con tipos seguros. Los tipos de utilidad como Partial, Pick, Omit y ReturnType mantienen tus tipos DRY. Y unknown con estrechez te da la seguridad de los tipos incluso en los bordes donde los datos llegan del mundo exterior. La mejor manera de solidificar todo esto es experimentar interactivamente — el Playground de TypeScript te permite pegar cualquier fragmento y ver los tipos inferidos instantáneamente, sin configuración necesaria. Cuando trabajas con datos JSON en proyectos TypeScript, las herramientas Formateador JSON y JSON a TypeScript en este sitio pueden generar definiciones de interfaz desde payloads de API reales automáticamente.