Saltar al contenido principal
55 min de lectura3 de agosto de 2026
TypeScriptGenericsUtility TypesTipadoReact

Requisitos previos

  • typescript

Lo que aprenderás

  • Aplicar narrowing, type guards y uniones discriminadas
  • Diseñar generics avanzados con constraints
  • Componer tipos con utility types y mapped types
  • Tipar hooks, contextos y props de React correctamente
  • Configurar un tsconfig estricto
Tutorial55 min

TypeScript avanzado: tipos, generics y utilidades

TypeScript no es solo "JavaScript con tipos": su sistema de tipos es un lenguaje en sí mismo, capaz de modelar uniones, inferir resultados y transformar tipos. Este tutorial profundiza en narrowing, uniones discriminadas, generics avanzados, utility types, mapped types y cómo aplicar todo esto al tipado de componentes React. Prerequisito conceptual: TypeScript básico (interfaces y tipos simples).


Narrowing y type guards

El narrowing es el proceso por el que TypeScript reduce el tipo de una variable dentro de un bloque, en función de las condiciones de control de flujo. Los guardas básicos: typeof, in e instanceof:

function procesarValor(valor: string | number | Date) {
  // typeof: reduce tipos primitivos
  if (typeof valor === "string") {
    return valor.toUpperCase();
  }

  // instanceof: reduce instancias de clases
  if (valor instanceof Date) {
    return valor.toISOString();
  }

  // tras los guardas, TS sabe que queda number
  return valor.toFixed(2);
}

function obtenerNombre(entidad: Usuario | Empresa) {
  // in: reduce objetos por su propiedad distintiva
  if ("email" in entidad) {
    return entidad.email;
  }
  return entidad.cif;
}
Con objetos, prefiere in o una propiedad discriminante antes que instanceof (que falla con objetos creados como literales o provenientes de JSON).

Predicados de tipo (is)

Cuando un guarda es complejo, se extrae a una función con un type predicate: el operador is le dice a TypeScript qué tipo tiene el valor cuando la función devuelve true:

type Producto = { id: number; nombre: string; precio: number };
type Oferta = Producto & { descuento: number };

function esOferta(item: Producto): item is Oferta {
  return "descuento" in item;
}

const items: Producto[] = obtenerItems();

// Dentro del filter, TS ya sabe que 'oferta' es Oferta
const ofertas = items.filter(esOferta);
ofertas.forEach((oferta) => {
  console.log(oferta.descuento); // sin error de tipo
});
Un type predicate promete a TypeScript lo que tu lógica debe cumplir: si el cuerpo de la función está mal, los errores se propagan a todo el que use el predicado. Escríbelo correcto la primera vez — no hay compilador que valide la promesa en sí.

El narrowing también funciona en uniones con literales: comparar un valor contra un literal concreto reduce el resto de la unión:

type Estado = "cargando" | "exito" | "error";

function mostrar(estado: Estado) {
  if (estado === "cargando") {
    return <Spinner />;
  }
  if (estado === "error") {
    return <ErrorBanner />;
  }
  // aquí TS infiere que estado es "exito"
  return <Resultado />;
}

Uniones discriminadas y exhaustiveness

Una unión discriminada usa un campo literal común (el discriminante) para distinguir cada variante. Es la forma estándar de modelar datos que pueden tener varias formas:

type Evento =
  | { tipo: "click"; x: number; y: number }
  | { tipo: "tecla"; codigo: string }
  | { tipo: "scroll"; posicion: number };

function manejarEvento(evento: Evento) {
  switch (evento.tipo) {
    case "click":
      console.log(evento.x, evento.y); // TS conoce x, y
      break;
    case "tecla":
      console.log(evento.codigo); // TS conoce codigo
      break;
    case "scroll":
      console.log(evento.posicion); // TS conoce posicion
      break;
  }
}

El exhaustiveness checking con el tipo never garantiza que si mañana añades una variante nueva, el compilador te obligue a tratarla. En el default del switch, el valor debe ser never; si no lo es, algo quedó sin cubrir:

function manejarEvento(evento: Evento) {
  switch (evento.tipo) {
    case "click":
      return manejarClick(evento);
    case "tecla":
      return manejarTecla(evento);
    case "scroll":
      return manejarScroll(evento);
    default:
      // Si añades una variante a Evento, esto deja de compilar
      const nunca: never = evento;
      return nunca;
  }
}
La técnica never funciona porque never es asignable a todo tipo, pero ningún tipo (salvo él mismo) es asignable a never. Si evento todavía puede ser una variante sin tratar, la asignación falla y te avisa el compilador.

Generics avanzados

Un generic parametriza un tipo: la misma función o tipo funciona para muchos tipos, y la relación entre parámetros se conserva. Con constraints (extends) limitas a qué tipos puede aplicarse:

function primero<T>(lista: T[]): T | undefined {
  return lista[0];
}

// Constraint: T debe tener al menos 'id'
function buscarPorId<T extends { id: number }>(items: T[], id: number) {
  return items.find((item) => item.id === id);
}

buscarPorId(
  [{ id: 1, nombre: "Ana" }, { id: 2, nombre: "Luis" }],
  2
);

Los generics admiten parámetros múltiples y tipos por defecto:

function emparejar<A, B>(a: A, b: B): { primero: A; segundo: B } {
  return { primero: a, segundo: b };
}

// Inferencia en la llamada: A = string, B = number
const par = emparejar("clave", 42);

// Tipo por defecto
function almacenar<T = string>(valor: T): void {
  console.log(valor);
}

También se aplican a clases e interfaces:

// Clase genérica: una pila tipada
class Pila<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }
}

const pilaDeNumeros = new Pila<number>();
pilaDeNumeros.push(1);
pilaDeNumeros.push(2);
const ultimo = pilaDeNumeros.pop(); // number | undefined

// Interface genérica
interface Resultado<T> {
  ok: boolean;
  datos?: T;
  error?: string;
}

const r: Resultado<Usuario> = { ok: true, datos: usuario }
La inferencia es tu amiga: no escribas new Pila<number>() cuando el contexto ya lo deduce. Escribe el tipo explícito solo cuando la inferencia no llegue al resultado que buscas.

Utility types

TypeScript trae tipos predefinidos que transforman otros tipos. Los más usados en el día a día:

interface Usuario {
  id: number;
  nombre: string;
  email: string;
  telefono?: string;
}

// Todas las propiedades opcionales
type UsuarioParcial = Partial<Usuario>;

// Todas obligatorias (quita las opcionales)
type UsuarioCompleto = Required<Usuario>;

// Todas de solo lectura
type UsuarioInmutable = Readonly<Usuario>;

// Solo un subconjunto
type Credenciales = Pick<Usuario, "email" | "telefono">;

// Todo menos un subconjunto
type UsuarioSinId = Omit<Usuario, "id">;

// Mapa de claves a un tipo
type MapaRoles = Record<"admin" | "editor" | "lector", boolean>;

// Excluye miembros de una unión
type SinError = Exclude<"a" | "b" | "error", "error">; // "a" | "b"

// Extrae miembros de una unión
type SoloB = Extract<"a" | "b" | 42, string>; // "a" | "b"

Los que se usan con funciones y promesas:

function crearUsuario(nombre: string, edad: number): Usuario {
  // ...
}

// Tipo de retorno de una función
type TipoRetorno = ReturnType<typeof crearUsuario>; // Usuario

// Parámetros de una función como tupla
type Params = Parameters<typeof crearUsuario>; // [nombre: string, edad: number]

// Aplanar una promesa
type Valor = Awaited<Promise<Promise<number>>>; // number

// En la práctica, con funciones async
async function cargarUsuario(): Promise<Usuario> {
  return fetch("/api/usuario").then((r) => r.json());
}
type UsuarioCargado = Awaited<ReturnType<typeof cargarUsuario>>; // Usuario
Los utility types producen tipos nuevos, no mutan los originales. Partial<Usuario> no hace que Usuario tenga propiedades opcionales: es un tipo derivado que convive con el original.

Mapped types y template literal types

Un mapped type recorre las claves de otro tipo y produce un tipo nuevo por cada una. La sintaxis as permite renombrar las claves:

type Configuracion = {
  ancho: number;
  alto: number;
  tema: string;
};

// Todas las claves booleanas
type MapaBooleano = { [K in keyof Configuracion]: boolean };

// Todas las claves con sufijo "Valor"
type Valores = { [K in keyof Configuracion as `${K}Valor`]: Configuracion[K] };
// { anchoValor: number; altoValor: number; temaValor: string }

// Con '?' hacemos todo opcional (así está implementado Partial)
type Opcional<T> = { [K in keyof T]?: T[K] }
El ejemplo de Valores usa un template literal type: tipos que construyen strings a partir de otros. Combinando template literals con mapped types puedes generar APIs de tipos completas a partir de un modelo de datos.

Conditional types e infer

Un conditional type elige un tipo u otro según una condición (T extends U ? X : Y). La palabra clave infer extrae un tipo desde dentro de la estructura:

// Extrae el tipo de un array
type Elemento<T> = T extends (infer E)[] ? E : never;

type A = Elemento<string[]>; // string
type B = Elemento<number[]>; // number

// Extrae el tipo de una promesa
type Desempaqueta<T> = T extends Promise<infer U> ? U : T;

type C = Desempaqueta<Promise<boolean>>; // boolean

// Aplica de forma recursiva
type DesempaquetaProfunda<T> = T extends Promise<infer U>
  ? DesempaquetaProfunda<U>
  : T;

type D = DesempaquetaProfunda<Promise<Promise<string>>>; // string
ReturnType, Parameters y Awaited están implementados internamente con conditional types e infer. Aprender a escribirlos te permite crear utilidades propias.

Tipado en React

Props con type/interface y children

La forma moderna de tipar un componente: definir las props con type (preferible para props) o interface (para extensión), y usar React.ReactNode para children:

import type { ReactNode } from "react";

type BotonProps = {
  children: ReactNode;
  variant: "primario" | "secundario";
  onClick?: () => void;
};

function Boton({ children, variant, onClick }: BotonProps) {
  return (
    <button className={variant} onClick={onClick}>
      {children}
    </button>
  );
}
Sobre React.FC: sigue existiendo, pero la comunidad y el equipo de React recomiendan tipar las props directamente. Motivos: no infiere children implícitamente (en React 18 deben declararse), complica los generics y añade verbosidad sin valor. La firma (props: Props) => JSX.Element es la norma actual.

Hooks tipados

useState y useReducer aceptan un generic explícito cuando el estado no se puede inferir, o cuando el valor inicial no coincide con el estado completo:

// Inferido
const [contador, setContador] = useState(0); // number

// Explícito: estado que aún no existe
const [usuario, setUsuario] = useState<Usuario | null>(null);

// Unión para estados de carga
type CargaEstado = "idle" | "cargando" | "exito" | "error";
const [estado, setEstado] = useState<CargaEstado>("idle");

// useReducer con unión discriminada de acciones
type Accion =
  | { tipo: "incrementar"; cantidad: number }
  | { tipo: "reiniciar" };

function reducer(contador: number, accion: Accion): number {
  switch (accion.tipo) {
    case "incrementar":
      return contador + accion.cantidad;
    case "reiniciar":
      return 0;
  }
}

const [contador, dispatch] = useReducer(reducer, 0);

Los contextos tipados evitan el undefined y obligan a un valor por defecto que solo existe fuera del Provider:

type TemaContexto = {
  tema: "claro" | "oscuro";
  alternarTema: () => void;
};

// undefined fuera del Provider: nos obliga a comprobar el hook
const TemaContext = createContext<TemaContexto | undefined>(undefined);

function useTema(): TemaContexto {
  const contexto = useContext(TemaContext);
  if (!contexto) {
    throw new Error("useTema debe usarse dentro de <TemaProvider>");
  }
  return contexto;
}
No definas el contexto con un objeto "falso" por defecto (como tema: "claro") sin comprobarlo: oculta errores de uso fuera del Provider. La combinación de undefined + throw es el patrón canónico.

as const y satisfies

as const convierte literales en tipos de solo lectura, y satisfies verifica que un valor cumple un tipo sin ampliarlo a ese tipo. Juntos dan el mejor de ambos mundos:

// as const: literales exactos, inmutables
const direcciones = ["arriba", "abajo", "izq", "der"] as const;
type Direccion = (typeof direcciones)[number]; // "arriba" | "abajo" | ...

// satisfies: valida la forma sin perder el tipo literal
const colores = {
  primario: "#2563eb",
  secundario: "#7c3aed",
} as const satisfies Record<string, string>;

colores.primario; // literal "#2563eb", no string genérico
as const no es un casting "seguro": as fuerza un tipo y puede ocultar errores reales si se usa mal. satisfies (TS 4.9+) existe justo para validar sin forzar. Prefiere siempre satisfaces sobre as cuando solo quieras comprobar.

tsconfig estricto

La configuración marca la diferencia entre "tipos que existen" y "tipos que te protegen". Estas cuatro flags son las que más se notan:

// tsconfig.json
{
  "compilerOptions": {
    // Activa todas las comprobaciones estrictas a la vez
    "strict": true,

    // arr[0] es T | undefined: fuerza a comprobar índices
    "noUncheckedIndexedAccess": true,

    // las props opcionales no aceptan undefined explícito
    "exactOptionalPropertyTypes": true,

    // separa imports de tipo de imports de valor
    "verbatimModuleSyntax": true
  }
}
  • strict — paquete de flags: strictNullChecks, noImplicitAny, strictFunctionTypes y más.
  • noUncheckedIndexedAccess — el acceso por índice de un array devuelve T | undefined.
  • exactOptionalPropertyTypes — { a?: string } no admite { a: undefined } explícito.
  • verbatimModuleSyntax — obliga a import type para tipos puros.
// verbatimModuleSyntax: separar tipos de valores
import { useEffect } from "react";       // valor
import type { ReactNode } from "react";  // solo tipo

// exactOptionalPropertyTypes en acción
interface Config {
  retraso?: number;
}

// ❌ Error con la flag activada
const c1: Config = { retraso: undefined };

// ✅ Correcto: o no está, o es number
const c2: Config = {};
const c3: Config = { retraso: 300 };
Migrar un proyecto legacy a strict: true genera errores a miles; hazlo de a poco (flag por flag) en vez de todo de golpe. Los proyectos nuevos no deberían arrancar sin strict ni noUncheckedIndexedAccess.

Ejercicios


El sistema de tipos de TypeScript es una herramienta de diseño, no un trámite: narrowing para expresar flujos, uniones discriminadas para modelar datos reales, generics para reutilizar sin perder seguridad y utility types para transformar tipos en vez de escribirlos. Cuando lo aplicas a React y a un tsconfig estricto, los errores que antes aparecían en producción se convierten en errores de compilación. Ese es el objetivo final: que el compilador haga de red de seguridad.

Siguiente lección

React: componentes a apps

Aprende React moderno con hooks, routing, Context API y optimización de rendimiento. Incluye proyecto dashboard completo.