Módulo 13: TypeScript con React

Objetivos del módulo

  • Distinguir lo que TypeScript comprueba al compilar de lo que ocurre de verdad en el navegador
  • Entender las opciones del tsconfig de la plantilla y por qué Vite no comprueba tipos
  • Tipar props, children, valores por defecto y funciones de retorno, y elegir bien entre ReactNode, ReactElement y JSX.Element
  • Reutilizar los tipos de HTML y de otros componentes con ComponentProps, Omit y ComponentRef
  • Tipar eventos con ChangeEvent, MouseEvent, KeyboardEvent y SubmitEvent, y evitar la trampa de los tipos globales del DOM
  • Tipar estado, referencias, reducers y Context sin recurrir a any ni a !
  • Modelar datos y props con uniones discriminadas para que los estados imposibles no compilen
  • Escribir componentes y hooks genéricos: tablas, selectores y useFetch<T>
  • Construir un componente polimórfico con la prop as y saber cuándo no merece la pena
  • Tipar las respuestas y los errores de la API EMPRESA
  • Validar en ejecución con Zod 4 y deducir los tipos a partir de los esquemas
  • Leer los mensajes de error del compilador y reconocer los fallos más frecuentes

💡 Consejo: Este módulo no estudia TypeScript desde cero: da por hecho que conoces lo visto en los módulos anteriores, donde ya has escrito props y estado con tipos. Aquí vamos al fondo. Todos los ejemplos se han compilado con TypeScript 7.0.2 y @types/react 19.3.0, y se han ejecutado con React 19.3.1. Los mensajes de error que aparecen son los que da de verdad el compilador.


1. Qué hace TypeScript y qué no hace

TypeScript añade dos cosas a JavaScript: una sintaxis para escribir tipos y un programa, tsc, que los comprueba. Lo más importante que debes saber es que los tipos desaparecen antes de ejecutar el código. El navegador recibe JavaScript normal, sin rastro de type, interface ni <Empleado>.

Eso tiene una consecuencia directa. TypeScript puede demostrar que tu código es coherente con lo que has declarado, pero no puede saber si lo que has declarado es verdad. Si dices que la API devuelve salario: number y un día devuelve "6500", el compilador no se entera, porque ese dato llega cuando el programa ya está en marcha.

Dos fases: en tiempo de compilación tsc comprueba props, eventos, estado y referencias; en tiempo de ejecución el navegador recibe JavaScript sin tipos y los datos externos deben validarse con Zod

Figura 1. TypeScript protege el código que escribes. Lo que entra desde fuera hay que comprobarlo en ejecución.

Pregunta ¿Quién responde?
¿Falta una prop obligatoria en este componente? TypeScript
¿Este valor puede ser null y lo estoy usando sin comprobar? TypeScript
¿He olvidado un caso de mi reducer? TypeScript
¿La API ha devuelto los campos que esperaba? Nadie, salvo que lo valides (sección 13)
¿El usuario ha escrito un email correcto? Nadie, salvo que lo valides (sección 13)
¿El JSON guardado en localStorage sigue teniendo el formato de ayer? Nadie, salvo que lo valides (sección 13)

📘 Concepto: La comprobación estática ocurre al compilar y mira el código sin ejecutarlo. La validación en ejecución ocurre mientras el programa funciona y mira los datos reales. Se complementan: la primera vale para todo lo que está dentro de tu aplicación, y la segunda para todo lo que cruza su frontera.

Prepara el proyecto

Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell, e instala Zod para la sección 13:

npm create vite@latest modulo-13 -- --template react-ts
cd modulo-13
npm install
npm install zod
npm run dev

Muchos ejemplos de este módulo ocupan varios ficheros, así que la primera línea de cada bloque completo indica dónde va: // src/Boton.tsx. Los ficheros que terminan en Demo.tsx son pantallas de prueba. Para ver una, deja src/App.tsx así y cambia solo la importación:

import Demo from './BotonesDemo.tsx';

export default function App() {
  return <Demo />;
}

Los ejemplos que usan datos reales necesitan la API EMPRESA. Descarga recursos/empresa-api.zip, descomprímelo y arráncala en otra terminal con node server.mjs. Queda en http://localhost:3000/api.

Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:

/* src/index.css */
:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 720px;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.5rem;
  font-size: 1.15rem;
  color: #1a237e;
}

.fila {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.pista {
  color: #607d8b;
  font-size: 0.9rem;
}

.campo {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  margin-bottom: 0.6rem;
  font-size: 0.9rem;
}

.campo input,
.campo select {
  padding: 0.4rem 0.5rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
  font: inherit;
}

.error {
  color: #c62828;
  font-size: 0.85rem;
}

.boton {
  padding: 0.45rem 0.9rem;
  border: 1px solid transparent;
  border-radius: 6px;
  font: inherit;
  cursor: pointer;
}

.boton:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.boton-primario {
  background: #3949ab;
  color: #fff;
}

.boton-peligro {
  background: #c62828;
  color: #fff;
}

.boton-neutro {
  background: #eceff1;
  color: #263238;
  border-color: #b0bec5;
}

table {
  border-collapse: collapse;
  width: 100%;
}

th,
td {
  padding: 0.35rem 0.6rem;
  border-bottom: 1px solid #e0e0e0;
  text-align: left;
}

th {
  background: #e8eaf6;
  color: #1a237e;
}

.derecha {
  text-align: right;
}

.aviso {
  border-left: 4px solid #3f51b5;
  background: #e8eaf6;
  padding: 0.5rem 0.75rem;
  margin: 0.5rem 0;
}

El tsconfig de la plantilla

La plantilla trae tres ficheros: tsconfig.json solo enlaza a los otros dos, tsconfig.app.json se aplica a src y tsconfig.node.json a vite.config.ts. Lo que te interesa está en tsconfig.app.json:

Opción Efecto Por qué importa
"jsx": "react-jsx" Transforma JSX en llamadas a jsx() de react/jsx-runtime Por eso no hace falta importar React en cada fichero
"noEmit": true tsc solo comprueba, no genera ficheros Quien genera el JavaScript es Vite
"moduleResolution": "bundler" Resuelve las importaciones como un empaquetador Permite importar sin más react o zod
"allowImportingTsExtensions": true Permite import App from './App.tsx' Es la forma que usa la plantilla
"verbatimModuleSyntax": true Exige import type para lo que solo sea un tipo Deja claro qué importación desaparece al compilar
"erasableSyntaxOnly": true Prohíbe sintaxis que genere código, como enum El tipo debe poder borrarse sin dejar nada
"noUnusedLocals": true Variables sin usar son un error Detecta restos de código
"noUnusedParameters": true Parámetros sin usar son un error Si no lo necesitas, ponle un prefijo _
"noFallthroughCasesInSwitch": true Prohíbe caer de un case al siguiente Muy útil en reducers

Fíjate en que strict no aparece. No hace falta: en TypeScript 7 el modo estricto está activo por defecto. Una función con un parámetro sin tipo da error aunque el tsconfig no diga nada:

error TS7006: Parameter 'x' implicitly has an 'any' type.

⚠️ Importante: Si trabajas en un proyecto con una versión anterior de TypeScript, comprueba que tu tsconfig lleva "strict": true. Todo lo que se explica en este módulo cuenta con ese modo: sin él, null encaja en cualquier tipo y gran parte de las protecciones desaparecen.

erasableSyntaxOnly explica por qué en este curso no verás enum. Si lo intentas, el compilador responde:

error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

Un enum no es solo un tipo: genera un objeto en el JavaScript final. En React se usan uniones de literales, que no dejan rastro y se escriben igual que el valor:

// Una unión de literales: desaparece al compilar
type Rol = 'ADMIN' | 'USER';

// Si además necesitas la lista en ejecución, parte del valor
const ESTADOS = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'] as const;
type Estado = (typeof ESTADOS)[number]; // 'PENDIENTE' | 'ENVIADO' | ...

Vite no comprueba tipos

Este error sorprende a casi todo el mundo. npm run dev no mira los tipos. Vite solo elimina la sintaxis de TypeScript, fichero a fichero y sin analizarla, porque eso es mucho más rápido. Hemos introducido a propósito un error de tipos en un componente y este es el resultado:

Orden Con un error de tipos
npm run dev Arranca y la página funciona
npx vite build Termina bien y genera dist
npx tsc -b Falla y muestra el error
npm run build Falla, porque su guion es tsc -b && vite build

Los errores que ves mientras programas los muestra el editor. VS Code lleva su propio servicio de TypeScript y subraya el código al momento. La comprobación definitiva es npx tsc -b, y debe formar parte de tu integración continua, como verás en el módulo 25.


2. Tipar props

Las props de un componente son los parámetros de una función, y se tipan igual. Se declara un tipo con la forma del objeto y se desestructura en la firma:

// src/TarjetaEmpleado.tsx
import type { ReactNode } from 'react';

type TarjetaEmpleadoProps = {
  nombre: string;
  oficio: string;
  salario: number;
  departamento?: string | null; // opcional, y además puede venir null
  activo?: boolean;
  acciones?: ReactNode; // cualquier cosa que React sepa pintar
  onSeleccionar: (nombre: string) => void; // función que recibe el padre
};

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });

export function TarjetaEmpleado({
  nombre,
  oficio,
  salario,
  departamento,
  activo = true,
  acciones,
  onSeleccionar,
}: TarjetaEmpleadoProps) {
  return (
    <article className="tarjeta">
      <h2>{nombre}</h2>
      <p>
        {oficio} en {departamento ?? 'ningún departamento'} · {euros.format(salario)}
      </p>
      <p className="pista">{activo ? 'En activo' : 'De baja'}</p>
      <div className="fila">
        <button className="boton boton-neutro" onClick={() => onSeleccionar(nombre)}>
          Seleccionar
        </button>
        {acciones}
      </div>
    </article>
  );
}
// src/TarjetasDemo.tsx
import { useState } from 'react';
import { TarjetaEmpleado } from './TarjetaEmpleado.tsx';

export default function TarjetasDemo() {
  const [elegido, setElegido] = useState<string | null>(null);

  return (
    <>
      <p>Seleccionado: {elegido ?? 'nadie'}</p>
      <TarjetaEmpleado
        nombre="Ana García"
        oficio="DIRECTOR GENERAL"
        salario={6500}
        departamento="DIRECCIÓN"
        onSeleccionar={setElegido}
        acciones={<a href="mailto:ana.garcia@empresa.es">Escribir</a>}
      />
      <TarjetaEmpleado
        nombre="Luis Martínez"
        oficio="DIRECTOR"
        salario={4800}
        departamento={null}
        activo={false}
        onSeleccionar={setElegido}
      />
    </>
  );
}

Observa onSeleccionar={setElegido}. Funciona porque setElegido acepta un string. El compilador lo comprueba aunque no hayas escrito una función de por medio.

Fíjate también en departamento ?? 'ningún departamento'. Un valor por defecto en la desestructuración, como activo = true, solo actúa con undefined, nunca con null. Si escribieras departamento = 'ninguno' y el padre pasara null, se quedaría null. Cuando una prop admite los dos, usa ??.

Lo que ganas se ve en cuanto te equivocas al usar el componente. Estos son los mensajes reales para un componente con las props texto y nivel: 'info' | 'error':

<Etiqueta texto="hola" />
// error TS2741: Property 'nivel' is missing in type '{ texto: string; }'
// but required in type '{ texto: string; nivel: "error" | "info"; }'.

<Etiqueta texto="hola" nivel="aviso" />
// error TS2322: Type '"aviso"' is not assignable to type '"error" | "info"'.

<Etiqueta texto="hola" nivel="info" color="red" />
// error TS2322: Type '{ texto: string; nivel: "info"; color: string; }' is not assignable
// to type 'IntrinsicAttributes & { texto: string; nivel: "error" | "info"; }'.
//   Property 'color' does not exist on type 'IntrinsicAttributes & ...'.

type o interface

Las dos sirven para describir props. Esta es la diferencia práctica:

Característica type interface
Describir un objeto Sí Sí
Uniones (A \| B) Sí No
Tipos condicionales y mapeados Sí No
Extender otro tipo Con & Con extends
Declaraciones que se fusionan No Sí

En este curso usamos type, porque las uniones aparecen por todas partes y así todo se escribe igual. Si tu equipo prefiere interface para las props, también es correcto. Lo importante es ser coherente.

FC y por qué no lo usamos

FC<Props> significa function component. Verás mucho código escrito así:

import type { FC } from 'react';

type SaludoProps = { nombre: string };

const Saludo: FC<SaludoProps> = ({ nombre }) => <p>Hola, {nombre}</p>;

Funciona, pero no aporta nada frente a una función con el parámetro tipado, y tiene una pega: no se puede usar con componentes genéricos (sección 10). Tampoco te da children sin declararlo, algo que sí hacía hace años y que explica por qué tantos tutoriales lo recomendaban. Escribe funciones normales y deja que TypeScript deduzca el tipo de retorno.

Props de solo lectura

Las props no se modifican, como viste en el módulo 03. Si quieres que el compilador lo vigile, usa Readonly. Un array readonly no tiene push ni sort, y evita mutar sin querer lo que te pasa el padre:

type ListaProps = Readonly<{
  titulo: string;
  empleados: readonly string[];
}>;

function Lista({ titulo, empleados }: ListaProps) {
  // empleados.sort();       // error: 'sort' no existe en 'readonly string[]'
  const ordenados = empleados.toSorted(); // copia ordenada: correcto
  return <p>{titulo}: {ordenados.join(', ')}</p>;
}

ReactNode, ReactElement y JSX.Element

Estos tres nombres se confunden mucho. La diferencia es sencilla: ReactNode es todo lo que React sabe pintar, y los otros dos solo incluyen elementos JSX.

ReactNode incluye textos, números, booleanos, null, undefined, iterables, portales, promesas y ReactElement, que a su vez incluye a JSX.Element

Figura 2. ReactNode contiene a ReactElement, y JSX.Element es un ReactElement. Un texto es un ReactNode, pero no un ReactElement.

Tipo Admite Úsalo para
ReactNode Textos, números, elementos, listas, null, undefined, booleanos, promesas children y props que se pintan: casi siempre es este
ReactElement Solo un elemento creado con JSX Cuando vas a inspeccionarlo o a clonarlo
JSX.Element Lo mismo que ReactElement con cualquier prop El tipo que deduce TypeScript para una expresión JSX
string Solo texto Un título que usarás también en un aria-label

Elige ReactNode para children, salvo que tengas un motivo. Si declaras children: string, el componente rechaza cualquier etiqueta dentro:

error TS2745: This JSX tag's 'children' prop expects type 'string' which requires
multiple children, but only a single child was provided.

El mensaje confunde un poco: lo que falla es el tipo. Y ojo con lo que no es un ReactNode. Pintar un objeto directamente es un error de compilación que te ahorra un fallo en pantalla:

<p>{new Date()}</p>
error TS2322: Type 'Date' is not assignable to type 'ReactNode'.

Un componente debe devolver algo pintable. Si olvidas el return, el tipo de la función es () => void, y el compilador lo rechaza al usarla:

error TS2786: 'Vacio' cannot be used as a JSX component.
  Its type '() => void' is not a valid JSX element type.

PropsWithChildren<P> es un atajo que añade children?: ReactNode a tus props. Es correcto, pero escribir children a mano deja claro si es opcional o no:

import type { PropsWithChildren, ReactNode } from 'react';

type CajaA = PropsWithChildren<{ titulo: string }>; // children opcional
type CajaB = { titulo: string; children: ReactNode }; // children obligatorio

El namespace JSX ya no es global 🔄 19.0

🔄 Cambia en React 19.0. Los tipos de React 19 ya no declaran JSX como global: JSX.Element da el error Cannot find namespace 'JSX'. Si usas una versión anterior: el JSX global sigue existiendo, pero React.JSX.Element funciona en 18.3 y en 19, así que escríbelo siempre de esa forma.

Rara vez necesitas escribir este tipo, porque TypeScript deduce el retorno. Si lo necesitas, impórtalo:

import type { JSX } from 'react';

function Pie(): JSX.Element {
  return <footer>EMPRESA</footer>;
}

Las props de ReactElement son unknown 🔄 19.0

🔄 Cambia en React 19.0. En los tipos de React 19, ReactElement sin parámetros tiene props: unknown, cuando antes era any. Si usas una versión anterior: el código compila sin indicar el tipo, pero declarar ReactElement<{ titulo: string }> funciona en todas las versiones y es más seguro.

Antes podías leer cualquier prop de un elemento sin que nadie protestara. Ahora debes decir qué props esperas:

import type { ReactElement } from 'react';

function Titulo({ hijo }: { hijo: ReactElement<{ titulo: string }> }) {
  return <h2>{hijo.props.titulo}</h2>;
}

Sin el parámetro, el mensaje es error TS18046: 'hijo.props' is of type 'unknown'.


3. Reutilizar tipos: ComponentProps

Imagina un botón propio. Quieres añadirle variantes, pero también que acepte onClick, disabled, type, aria-label, title y todas las demás props que admite un <button>. Copiarlas no es una opción. ComponentProps te da el tipo de las props de cualquier etiqueta o componente:

// src/Boton.tsx
import type { ComponentProps } from 'react';

type BotonProps = ComponentProps<'button'> & {
  variante?: 'primario' | 'peligro' | 'neutro';
  cargando?: boolean;
};

export function Boton({
  variante = 'neutro',
  cargando = false,
  type = 'button',
  className = '',
  disabled,
  children,
  ...resto
}: BotonProps) {
  return (
    <button
      {...resto}
      type={type}
      disabled={disabled || cargando}
      aria-busy={cargando}
      className={`boton boton-${variante} ${className}`}
    >
      {cargando ? 'Guardando...' : children}
    </button>
  );
}

La clave está en ...resto: separas lo que usa tu componente y reenvías todo lo demás a la etiqueta real. El type = 'button' evita el error clásico de un botón dentro de un formulario que lo envía sin querer, porque el valor por defecto en HTML es submit.

ComponentProps conoce los valores exactos de cada atributo. Un type incorrecto da error:

error TS2322: Type '"enviar"' is not assignable to type '"button" | "reset" | "submit" | undefined'.

También sirve con tus propios componentes. Así haces un envoltorio que se mantiene al día solo:

// src/BotonesDemo.tsx
import { useState } from 'react';
import type { ComponentProps } from 'react';
import { Boton } from './Boton.tsx';

// Las props de Boton, pero sin 'variante': este componente la fija
type BotonBorrarProps = Omit<ComponentProps<typeof Boton>, 'variante' | 'children'> & {
  elemento: string;
};

function BotonBorrar({ elemento, ...resto }: BotonBorrarProps) {
  return (
    <Boton variante="peligro" aria-label={`Borrar ${elemento}`} {...resto}>
      Borrar
    </Boton>
  );
}

export default function BotonesDemo() {
  const [cargando, setCargando] = useState(false);
  const [mensaje, setMensaje] = useState('Sin acciones todavía');

  function guardar() {
    setCargando(true);
    setTimeout(() => {
      setCargando(false);
      setMensaje('Guardado');
    }, 1500);
  }

  return (
    <section className="tarjeta">
      <h2>Botones tipados</h2>
      <div className="fila">
        <Boton variante="primario" onClick={guardar} cargando={cargando}>
          Guardar
        </Boton>
        <Boton title="Cancela la edición" onClick={() => setMensaje('Cancelado')}>
          Cancelar
        </Boton>
        <BotonBorrar elemento="empleado 100" onClick={() => setMensaje('Borrado')} />
        <Boton variante="primario" disabled>
          Deshabilitado
        </Boton>
      </div>
      <p className="pista">{mensaje}</p>
    </section>
  );
}

Cuatro botones con las variantes primario, neutro y peligro, y uno deshabilitado

Figura 3. Boton hereda todas las props de <button> y añade variante y cargando.

Cuando una prop tuya tiene el mismo nombre que una nativa pero otro tipo, una intersección no vale: las dos declaraciones se combinan y sale un tipo imposible. Primero quita la nativa con Omit. Es el caso de un campo que entrega el texto en lugar del evento:

// src/CampoTexto.tsx
import { useId } from 'react';
import type { ChangeEvent, ComponentProps } from 'react';

type CampoTextoProps = Omit<ComponentProps<'input'>, 'onChange' | 'value'> & {
  etiqueta: string;
  value: string;
  onChange: (valor: string) => void;
  error?: string;
};

export function CampoTexto({ etiqueta, value, onChange, error, ...resto }: CampoTextoProps) {
  const id = useId();
  const idError = `${id}-error`;

  function cambiar(e: ChangeEvent<HTMLInputElement>) {
    onChange(e.target.value);
  }

  return (
    <div className="campo">
      <label htmlFor={id}>{etiqueta}</label>
      <input
        {...resto}
        id={id}
        value={value}
        onChange={cambiar}
        aria-invalid={error !== undefined}
        aria-describedby={error ? idError : undefined}
      />
      {error && (
        <span id={idError} className="error">
          {error}
        </span>
      )}
    </div>
  );
}

Las tres variantes se parecen, pero no son iguales:

Tipo Qué da Cuándo usarlo
ComponentProps<'input'> Todas las props, incluida ref Es la opción normal desde React 19
ComponentPropsWithRef<'input'> Lo mismo, y admite también componentes de clase Código genérico, como los componentes polimórficos
ComponentPropsWithoutRef<'input'> Todas menos ref Cuando no quieres que pase ref, o usas forwardRef
ComponentRef<typeof Componente> El tipo del objeto que recibe la ref Para declarar useRef hacia un componente ajeno

ElementRef, que verás en código anterior, está marcado como obsoleto en favor de ComponentRef. Este último existe ya en los tipos de 18.3, así que puedes usarlo en cualquier caso.

Si quieres pasar atributos data-*, puedes hacerlo sin declararlos: JSX acepta cualquier atributo con guion en las etiquetas y en tus componentes, y por eso <Boton data-prueba="guardar"> compila.


4. Tipar eventos

La regla práctica es no tipar lo que TypeScript ya sabe. Si escribes la función en línea, el evento se deduce del contexto:

<input onChange={(e) => setTexto(e.target.value)} />
// e es ChangeEvent<HTMLInputElement, HTMLInputElement>, deducido

Cuando sacas la función fuera, ese contexto se pierde y tienes que indicar el tipo. El truco para saber cuál es: escribe el manejador en línea, pasa el ratón sobre e en VS Code y copia lo que te enseña. El parámetro entre <> es el elemento que escucha, y determina currentTarget.

Evento en JSX Tipo Datos que aporta
onChange en input, textarea, select ChangeEvent<HTMLInputElement> target.value, target.checked
onClick, onDoubleClick, onMouseEnter MouseEvent<HTMLButtonElement> clientX, ctrlKey, button
onKeyDown, onKeyUp KeyboardEvent<HTMLInputElement> key, code, shiftKey
onFocus, onBlur FocusEvent<HTMLInputElement> relatedTarget
onSubmit SubmitEvent<HTMLFormElement> nativeEvent.submitter
onInput, onBeforeInput InputEvent<HTMLInputElement> data
onDrag, onDrop DragEvent<HTMLDivElement> dataTransfer
onPointerDown, onPointerMove PointerEvent<HTMLDivElement> pointerType
onScroll UIEvent<HTMLDivElement> currentTarget.scrollTop
Cualquiera SyntheticEvent<HTMLElement> Lo común a todos

Para tipar una prop que recibe un manejador tienes la familia ...EventHandler, que ahorra escribir la firma:

import type { ChangeEventHandler, MouseEventHandler } from 'react';

type BarraProps = {
  onBuscar: ChangeEventHandler<HTMLInputElement>; // (e: ChangeEvent<...>) => void
  onLimpiar: MouseEventHandler<HTMLButtonElement>;
};

Con todo esto, un ejemplo completo:

// src/EventosDemo.tsx
import { useState } from 'react';
import type { ChangeEvent, KeyboardEvent, MouseEvent, SubmitEvent } from 'react';

type Orden = 'nombre' | 'salario';

// Comprueba en ejecución que un string es uno de los valores permitidos
function esOrden(valor: string): valor is Orden {
  return valor === 'nombre' || valor === 'salario';
}

export default function EventosDemo() {
  const [texto, setTexto] = useState('');
  const [orden, setOrden] = useState<Orden>('nombre');
  const [registro, setRegistro] = useState<string[]>([]);

  function anotar(linea: string) {
    setRegistro((anteriores) => [linea, ...anteriores].slice(0, 4));
  }

  function cambiarTexto(e: ChangeEvent<HTMLInputElement>) {
    setTexto(e.target.value);
  }

  function cambiarOrden(e: ChangeEvent<HTMLSelectElement>) {
    const valor = e.target.value; // string, no Orden
    if (esOrden(valor)) {
      setOrden(valor); // aquí TypeScript ya sabe que es Orden
    }
  }

  function pulsarTecla(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === 'Escape') {
      setTexto('');
      anotar('Escape: buscador vacío');
    }
  }

  function limpiar(e: MouseEvent<HTMLButtonElement>) {
    setTexto('');
    anotar(`Clic en ${e.currentTarget.textContent} (Ctrl: ${e.ctrlKey})`);
  }

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const datos = new FormData(e.currentTarget);
    const consulta = datos.get('q'); // FormDataEntryValue | null
    const boton = e.nativeEvent.submitter?.textContent ?? 'Intro';
    if (typeof consulta === 'string') {
      anotar(`Enviado con "${boton}": q=${consulta}, orden=${orden}`);
    }
  }

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Buscador de empleados</h2>
      <div className="fila">
        <input name="q" value={texto} onChange={cambiarTexto} onKeyDown={pulsarTecla} />
        <select value={orden} onChange={cambiarOrden}>
          <option value="nombre">Por nombre</option>
          <option value="salario">Por salario</option>
        </select>
        <button className="boton boton-primario" type="submit">
          Buscar
        </button>
        <button className="boton boton-neutro" type="submit">
          Buscar y guardar
        </button>
        <button className="boton boton-neutro" type="button" onClick={limpiar}>
          Limpiar
        </button>
      </div>
      <ul>
        {registro.map((linea, i) => (
          <li key={i}>{linea}</li>
        ))}
      </ul>
    </form>
  );
}

Al pulsar Intro en el campo, el navegador usa el primer botón submit del formulario, así que en el registro aparece Buscar. Hay tres detalles que merecen una explicación:

target y currentTarget. currentTarget es el elemento que tiene el manejador, y su tipo es el que pusiste entre <>. target es donde ocurrió el evento, que puede ser un descendiente, así que TypeScript no siempre conoce su tipo. En ChangeEvent el tipo de target sí se ajusta, porque un campo de formulario no tiene descendientes que cambien. En un MouseEvent, e.target es solo un EventTarget, sin textContent ni value. Por eso en limpiar leemos currentTarget.

e.target.value siempre es string. Aunque el <select> solo tenga dos opciones, el tipo es string. Escribir e.target.value as Orden compilaría, pero es una mentira que el compilador no puede comprobar. La función esOrden es una guarda de tipo: el retorno valor is Orden significa que, si devuelve true, TypeScript debe tratar el valor como Orden. Aquí sí hay comprobación real en ejecución.

FormData.get devuelve FormDataEntryValue | null. Es decir, string, File o null. Por eso comprobamos con typeof antes de usarlo.

SubmitEvent sustituye a FormEvent 🔄 19.2

🔄 Cambia en React 19.2. Solo afecta a los tipos: desde @types/react 19.2.10 existen SubmitEvent y SubmitEventHandler, y FormEvent está marcado como obsoleto. Si usas una versión anterior: esos tipos no existen, así que escribe FormEvent<HTMLFormElement> en onSubmit, que funciona igual y sigue compilando en 19.3.

La versión de @types/react no va ligada a la de react: es otro paquete, con su propio número. Compruébala con npm ls @types/react, porque es ella la que decide qué tipos tienes.

FormEvent llevaba años usándose en todos los tutoriales, pero su nombre era engañoso: no existe ningún evento form en el navegador. El nuevo aviso del tipo lo dice claramente:

@deprecated FormEvent doesn't actually exist.
You probably meant to use ChangeEvent, InputEvent, SubmitEvent, or just SyntheticEvent instead
depending on the event type.

Un tipo obsoleto no da error de compilación. VS Code lo tacha, y ya está. El código antiguo sigue funcionando, así que no hay prisa por migrar, pero en código nuevo usa SubmitEvent.

También cambió ChangeEvent, que ahora admite dos parámetros: ChangeEvent<CurrentTarget, Target>. Con uno solo funciona como siempre.

La trampa de los tipos globales

MouseEvent, KeyboardEvent, SubmitEvent, ChangeEvent… Varios de estos nombres existen dos veces: como tipos del DOM, que son globales, y como tipos de React, que hay que importar. Si olvidas el import type, TypeScript no se queja del nombre: usa el tipo del DOM, y el error aparece más lejos:

// Sin importar SubmitEvent de 'react'
function enviar(e: SubmitEvent) {
  e.preventDefault();
}

<form onSubmit={enviar} />;
error TS2322: Type '(e: SubmitEvent) => void' is not assignable to type 'SubmitEventHandler<HTMLFormElement>'.
  Types of parameters 'e' and 'event' are incompatible.

Dos tipos con el mismo nombre que no encajan: si ves un mensaje así, casi siempre falta la importación. Con e.target u otros campos pueden aparecer errores todavía menos claros.

submitter en el evento de React 🔄 19.3

🔄 Cambia en React 19.3. El evento submit de React expone submitter con el botón que lo envió. Si usas una versión anterior: e.submitter compila con @types/react 19.2.10 o superior, pero en ejecución vale undefined; lee e.nativeEvent.submitter, que funciona en todas las versiones.

En el ejemplo hemos usado e.nativeEvent.submitter para que funcione en cualquier versión. Con React 19.3 puedes escribir directamente e.submitter. Es útil cuando un formulario tiene dos botones con acciones distintas: en EventosDemo, el registro muestra cuál de los dos se ha pulsado, y new FormData(e.currentTarget, e.submitter) incluye su name y su value.


5. Tipar el estado

useState deduce el tipo del valor inicial, así que lo normal es no escribir nada:

const [texto, setTexto] = useState('');       // string
const [total, setTotal] = useState(0);        // number
const [abierto, setAbierto] = useState(false); // boolean

Necesitas el parámetro <T> cuando el valor inicial no describe todos los valores posibles. Son tres casos:

// 1. Algo que empieza vacío
const [elegido, setElegido] = useState<Empleado | null>(null);

// 2. Un array vacío
const [empleados, setEmpleados] = useState<Empleado[]>([]);

// 3. Una unión de literales
const [vista, setVista] = useState<'tabla' | 'tarjetas'>('tabla');

Sin ese parámetro, los dos primeros dan errores que despistan, porque el tipo deducido es demasiado estrecho:

const [lista, setLista] = useState([]);
setLista(['a']);
// error TS2322: Type 'string' is not assignable to type 'never'.

const [elegido, setElegido] = useState(null);
setElegido(3);
// error TS2345: Argument of type '3' is not assignable to parameter of type 'SetStateAction<null>'.

never es el tipo que no admite ningún valor. Un array never[] es un array en el que no cabe nada. Cuando veas never en un mensaje sobre un array, falta el <T> en un useState([]).

Si un hijo recibe el setter tal cual, su tipo es Dispatch<SetStateAction<T>>. Pero suele ser mejor pasar una función de nombre claro, que oculta cómo guardas el dato:

import type { Dispatch, SetStateAction } from 'react';

type ContadorProps = {
  valor: number;
  // Acepta un número y también una función actualizadora
  setValor: Dispatch<SetStateAction<number>>;
};

// Mejor en la mayoría de los casos
type ContadorProps2 = {
  valor: number;
  onCambiar: (siguiente: number) => void;
};

6. Tipar referencias

Una referencia puede guardar un nodo del DOM o cualquier valor. El tipo cambia ligeramente en cada caso:

// src/RefsDemo.tsx
import { useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ComponentProps, Ref } from 'react';

type CampoProps = ComponentProps<'input'> & { etiqueta: string };

// ref llega en las props, dentro de ...resto, y se reenvía al <input>
function Campo({ etiqueta, ...resto }: CampoProps) {
  return (
    <label className="campo">
      {etiqueta}
      <input {...resto} />
    </label>
  );
}

// Lo que este componente deja hacer a quien tenga su ref
export type ManejadorCronometro = {
  reiniciar: () => void;
  leer: () => number;
};

function Cronometro({ ref }: { ref?: Ref<ManejadorCronometro> }) {
  const [segundos, setSegundos] = useState(0);
  // Un valor mutable: el identificador del temporizador
  const temporizador = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    temporizador.current = setInterval(() => setSegundos((s) => s + 1), 1000);
    return () => {
      if (temporizador.current !== null) {
        clearInterval(temporizador.current);
      }
    };
  }, []);

  useImperativeHandle(
    ref,
    () => ({
      reiniciar: () => setSegundos(0),
      leer: () => segundos,
    }),
    [segundos],
  );

  return <p>Tiempo en la ficha: {segundos} s</p>;
}

export default function RefsDemo() {
  // Referencia a un nodo: empieza en null y React la rellena
  const buscador = useRef<HTMLInputElement>(null);
  const cronometro = useRef<ManejadorCronometro>(null);
  const [lectura, setLectura] = useState<number | null>(null);

  function enfocar() {
    buscador.current?.focus(); // puede ser null
  }

  return (
    <section className="tarjeta">
      <h2>Referencias tipadas</h2>
      <Campo etiqueta="Buscar" ref={buscador} placeholder="Nombre" />
      <Cronometro ref={cronometro} />
      <div className="fila">
        <button className="boton boton-neutro" onClick={enfocar}>
          Enfocar
        </button>
        <button
          className="boton boton-neutro"
          onClick={() => setLectura(cronometro.current?.leer() ?? null)}
        >
          Leer
        </button>
        <button className="boton boton-neutro" onClick={() => cronometro.current?.reiniciar()}>
          Reiniciar
        </button>
      </div>
      <p className="pista">Última lectura: {lectura ?? 'ninguna'}</p>
    </section>
  );
}
Declaración Tipo de .current Uso
useRef<HTMLInputElement>(null) HTMLInputElement \| null Nodo del DOM
useRef<ManejadorCronometro>(null) ManejadorCronometro \| null Manejador de useImperativeHandle
useRef(0) number Valor mutable
useRef<number \| null>(null) number \| null Valor que empieza vacío

Como una referencia a un nodo puede ser null, usarla sin comprobar da error TS18047: 'b.current' is possibly 'null'. La solución es ?. o un if. El operador ! de b.current!.focus() calla al compilador, pero si estás en el primer render, o el nodo está oculto tras un condicional, falla en ejecución. Resérvalo para los pocos casos en que sabes algo que TypeScript no puede saber, como document.getElementById('root')! en main.tsx.

Hemos usado ReturnType<typeof setInterval> porque el valor devuelto por setInterval es number en el navegador y un objeto Timeout en Node. Así el tipo es correcto en los dos entornos. Si vieras NodeJS.Timeout en un componente, normalmente sobra.

useRef exige argumento 🔄 19.0

🔄 Cambia en React 19.0. useRef necesita un argumento y RefObject deja de ser de solo lectura, aunque se cree con null. Si usas una versión anterior: useRef<T>() sin argumento compila, y useRef<T>(null) devuelve un objeto con .current de solo lectura; para escribir en él declara useRef<T | null>(null).

Con los tipos actuales, useRef<HTMLInputElement>() da error TS2554: Expected 1 arguments, but got 0. Escribe useRef<HTMLInputElement>(null), o useRef<number | undefined>(undefined) si de verdad quieres undefined.

ref como prop 🆕 19.0

🆕 Desde React 19.0. Un componente función recibe ref como una prop normal y se tipa con Ref<T>. No existe en React 18.3 ni anteriores, donde ref no llega a las props. Si usas una versión anterior: envuelve el componente con forwardRef, como se muestra a continuación; en esas versiones usa ComponentPropsWithoutRef para las props.

Es el motivo por el que Campo no necesita nada especial: ComponentProps<'input'> ya incluye ref, y ...resto la reenvía. Para exponer otra cosa se declara ref?: Ref<Tipo>, como en Cronometro. Este es el mismo componente tal como se escribía antes de React 19, y lo seguirás viendo en muchas bibliotecas:

import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef } from 'react';

type CampoProps = ComponentPropsWithoutRef<'input'> & { etiqueta: string };

// Ojo: el orden de los parámetros de tipo es el inverso al de la función
const Campo = forwardRef<HTMLInputElement, CampoProps>(function Campo(
  { etiqueta, ...resto },
  ref,
) {
  return (
    <label className="campo">
      {etiqueta}
      <input ref={ref} {...resto} />
    </label>
  );
});

Retorno de una ref función 🔄 19.0

🔄 Cambia en React 19.0. Una función ref puede devolver una función de limpieza, y por eso los tipos rechazan que devuelva cualquier otra cosa. Si usas una versión anterior: el retorno se ignora, no hay limpieza y debes tratar el caso en que la función recibe null.

Un retorno implícito en una flecha es una forma muy fácil de tropezar con esto, porque push devuelve un número:

<div ref={(nodo) => guardados.push(nodo!)} />
error TS2322: Type '(n: HTMLDivElement | null) => number' is not assignable to type 'Ref<HTMLDivElement> | undefined'.

Usa llaves, para que la función no devuelva nada: ref={(nodo) => { guardados.push(nodo!); }}.


7. Tipar reducers

Aquí TypeScript brilla. Una acción es una unión discriminada: varios tipos de objeto con un campo común, tipo, cuyo valor literal distingue a cada uno. Dentro de cada case, el compilador sabe qué campos tiene la acción, como ya viste en el módulo 11. Vamos a añadir una protección más: que olvidar un caso sea un error de compilación.

// src/carritoReducer.ts
import type { Producto } from './tipos.ts';

export type ProductoCarrito = Pick<Producto, 'productoId' | 'nombre' | 'precio'>;

export type Linea = { producto: ProductoCarrito; cantidad: number };

export type Carrito = { lineas: Linea[]; descuento: number };

export type AccionCarrito =
  | { tipo: 'agregar'; producto: ProductoCarrito }
  | { tipo: 'quitar'; productoId: number }
  | { tipo: 'cantidad'; productoId: number; cantidad: number }
  | { tipo: 'descuento'; porcentaje: number }
  | { tipo: 'vaciar' };

export const carritoInicial: Carrito = { lineas: [], descuento: 0 };

// Si llega aquí algún valor, es que falta un caso
function noContemplado(valor: never): never {
  throw new Error(`Acción sin tratar: ${JSON.stringify(valor)}`);
}

export function carritoReducer(estado: Carrito, accion: AccionCarrito): Carrito {
  switch (accion.tipo) {
    case 'agregar': {
      const existe = estado.lineas.some((l) => l.producto.productoId === accion.producto.productoId);
      return {
        ...estado,
        lineas: existe
          ? estado.lineas.map((l) =>
              l.producto.productoId === accion.producto.productoId
                ? { ...l, cantidad: l.cantidad + 1 }
                : l,
            )
          : [...estado.lineas, { producto: accion.producto, cantidad: 1 }],
      };
    }
    case 'quitar':
      return {
        ...estado,
        lineas: estado.lineas.filter((l) => l.producto.productoId !== accion.productoId),
      };
    case 'cantidad':
      return {
        ...estado,
        lineas: estado.lineas.map((l) =>
          l.producto.productoId === accion.productoId
            ? { ...l, cantidad: Math.max(1, accion.cantidad) }
            : l,
        ),
      };
    case 'descuento':
      return { ...estado, descuento: accion.porcentaje };
    case 'vaciar':
      return carritoInicial;
    default:
      return noContemplado(accion);
  }
}

export function calcularTotal({ lineas, descuento }: Carrito): number {
  const bruto = lineas.reduce((suma, l) => suma + l.producto.precio * l.cantidad, 0);
  return bruto * (1 - descuento / 100);
}

El truco está en noContemplado. Tras todos los case, TypeScript ha ido descartando opciones de la unión y en default no queda ninguna, así que accion es de tipo never y la llamada es válida. Si mañana añades { tipo: 'copiar' } y olvidas el case, accion ya no es never y la compilación falla justo en esa línea:

error TS2345: Argument of type '{ tipo: "copiar"; }' is not assignable to parameter of type 'never'.

Es exactamente lo que quieres: el compilador te lleva de la mano por cada switch afectado. La llamada a dispatch también queda protegida:

d({ tipo: 'multiplicar' });
error TS2322: Type '"multiplicar"' is not assignable to type '"restar" | "sumar" | "vaciar"'.

Para usar el reducer no hace falta escribir ningún tipo, porque useReducer los deduce. Solo los necesitas cuando pasas dispatch a un hijo, y entonces el tipo es Dispatch<AccionCarrito>:

// src/CarritoDemo.tsx
import { useReducer } from 'react';
import type { Dispatch } from 'react';
import { calcularTotal, carritoInicial, carritoReducer } from './carritoReducer.ts';
import type { AccionCarrito, Linea, ProductoCarrito } from './carritoReducer.ts';

const CATALOGO: ProductoCarrito[] = [
  { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899 },
  { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9 },
  { productoId: 9, nombre: 'Monitor 24 FHD', precio: 139 },
];

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });

function FilaCarrito({ linea, dispatch }: { linea: Linea; dispatch: Dispatch<AccionCarrito> }) {
  const { producto, cantidad } = linea;
  return (
    <tr>
      <td>{producto.nombre}</td>
      <td>
        <input
          type="number"
          min={1}
          aria-label={`Cantidad de ${producto.nombre}`}
          value={cantidad}
          onChange={(e) =>
            dispatch({
              tipo: 'cantidad',
              productoId: producto.productoId,
              cantidad: e.target.valueAsNumber,
            })
          }
        />
      </td>
      <td className="derecha">{euros.format(producto.precio * cantidad)}</td>
      <td>
        <button
          className="boton boton-neutro"
          onClick={() => dispatch({ tipo: 'quitar', productoId: producto.productoId })}
        >
          Quitar
        </button>
      </td>
    </tr>
  );
}

export default function CarritoDemo() {
  const [carrito, dispatch] = useReducer(carritoReducer, carritoInicial);

  return (
    <section className="tarjeta">
      <h2>Nuevo pedido</h2>
      <div className="fila">
        {CATALOGO.map((producto) => (
          <button
            key={producto.productoId}
            className="boton boton-primario"
            onClick={() => dispatch({ tipo: 'agregar', producto })}
          >
            + {producto.nombre}
          </button>
        ))}
      </div>
      <table>
        <tbody>
          {carrito.lineas.map((linea) => (
            <FilaCarrito key={linea.producto.productoId} linea={linea} dispatch={dispatch} />
          ))}
        </tbody>
      </table>
      <div className="fila">
        <label>
          Descuento{' '}
          <select
            aria-label="Descuento"
            value={carrito.descuento}
            onChange={(e) => dispatch({ tipo: 'descuento', porcentaje: Number(e.target.value) })}
          >
            <option value={0}>0 %</option>
            <option value={5}>5 %</option>
            <option value={10}>10 %</option>
          </select>
        </label>
        <strong>Total: {euros.format(calcularTotal(carrito))}</strong>
        <button className="boton boton-neutro" onClick={() => dispatch({ tipo: 'vaciar' })}>
          Vaciar
        </button>
      </div>
    </section>
  );
}

Pantalla de nuevo pedido con tres botones de producto, dos líneas con cantidad e importe, selector de descuento y total

Figura 4. El reducer del carrito, con todas sus acciones comprobadas por el compilador.

Fíjate en que import type { Producto } from './tipos.ts' usa el fichero que crearemos en la sección 12. Si vas siguiendo el módulo en orden, créalo ya.

Parámetros de tipo de useReducer 🔄 19.0

🔄 Cambia en React 19.0. Los tipos de useReducer cambian: ahora son useReducer<S, A>, donde A es una tupla de argumentos, y aparece ActionDispatch. Si usas una versión anterior: se escribe useReducer<Reducer<Estado, Accion>>. Lo mejor es no escribir parámetros: useReducer(reductor, inicial) compila igual en todas las versiones.

Si copias de un tutorial useReducer<Estado, Accion>(...), verás este error, porque Accion no es una tupla:

error TS2344: Type 'Accion' does not satisfy the constraint 'AnyActionArg'.

Quita los parámetros y que los deduzca de la función. En el caso poco frecuente de tener que escribirlos, son useReducer<Estado, [Accion]>.


8. Tipar Context

El problema de createContext es el valor por defecto. Normalmente el valor real solo existe dentro del proveedor, porque depende del estado. El patrón habitual es declarar null y envolver la lectura en un hook que elimina ese null:

// src/sesion.tsx
import { createContext, use, useMemo, useState } from 'react';
import type { ReactNode } from 'react';

export type Usuario = {
  email: string;
  nombre: string;
  rol: 'ADMIN' | 'USER';
};

type Sesion = {
  usuario: Usuario | null;
  entrar: (usuario: Usuario) => void;
  salir: () => void;
};

const SesionContexto = createContext<Sesion | null>(null);

export function ProveedorSesion({ children }: { children: ReactNode }) {
  const [usuario, setUsuario] = useState<Usuario | null>(null);

  const valor = useMemo<Sesion>(
    () => ({ usuario, entrar: setUsuario, salir: () => setUsuario(null) }),
    [usuario],
  );

  return <SesionContexto value={valor}>{children}</SesionContexto>;
}

// Quien use este hook recibe Sesion, nunca null
export function useSesion(): Sesion {
  const sesion = use(SesionContexto);
  if (sesion === null) {
    throw new Error('useSesion debe usarse dentro de <ProveedorSesion>');
  }
  return sesion;
}
// src/SesionDemo.tsx
import { ProveedorSesion, useSesion } from './sesion.tsx';

function Cabecera() {
  const { usuario, entrar, salir } = useSesion();

  if (usuario === null) {
    return (
      <button
        className="boton boton-primario"
        onClick={() =>
          entrar({ email: 'ana.garcia@empresa.es', nombre: 'Ana García', rol: 'ADMIN' })
        }
      >
        Entrar como Ana
      </button>
    );
  }

  return (
    <div className="fila">
      <span>
        {usuario.nombre} ({usuario.rol})
      </span>
      <button className="boton boton-neutro" onClick={salir}>
        Salir
      </button>
    </div>
  );
}

export default function SesionDemo() {
  return (
    <ProveedorSesion>
      <section className="tarjeta">
        <h2>Sesión</h2>
        <Cabecera />
      </section>
    </ProveedorSesion>
  );
}

El throw tiene dos efectos. En ejecución, si alguien olvida el proveedor recibe un mensaje claro en vez de un Cannot read properties of null. Y para el compilador, tras ese if el tipo se estrecha de Sesion | null a Sesion, así que los componentes no tienen que comprobar nada.

Al revisar este fichero, oxlint da un aviso only-export-components, porque exporta un componente y un hook a la vez. Fast Refresh recarga entonces más de lo necesario, pero el código es correcto. Si te molesta, separa el hook en useSesion.ts.

Un atajo muy extendido, createContext<Sesion>(null!) o {} as Sesion, compila, pero engaña al compilador. Si falta el proveedor, el error sale en cualquier punto y sin ninguna pista.

<Context> y use 🆕 19.0

🆕 Desde React 19.0. <Contexto value> sustituye a <Contexto.Provider value>, y use(Contexto) lee el contexto. No existen en React 18.3 ni anteriores. Si usas una versión anterior: escribe <SesionContexto.Provider value={valor}> y useContext(SesionContexto); todo lo demás, incluidos los tipos, es igual.


9. Uniones discriminadas: estados imposibles

Mira este estado, escrito como se escribe tantas veces:

type EstadoMalo<T> = {
  cargando: boolean;
  datos: T | null;
  error: string | null;
};

Tiene ocho combinaciones posibles, y solo tres tienen sentido. ¿Qué significa cargando: true con datos y error a la vez? El código debe contemplarlo o rezar para que no ocurra, y cada lector tiene que preguntarse si datos puede ser null tras comprobar cargando. Con una unión discriminada cada estado lleva sus datos, y los absurdos no se pueden escribir:

// src/estadoPeticion.ts
export type EstadoPeticion<T> =
  | { tipo: 'cargando' }
  | { tipo: 'error'; mensaje: string }
  | { tipo: 'correcto'; datos: T };

Tres cajas con los estados cargando, error y correcto, y las transiciones entre ellos; cada estado lleva solo los datos que le corresponden

Figura 5. Con una unión discriminada, datos solo existe en el estado correcto y mensaje solo en error.

Al usarla, comprobar tipo da acceso a los campos de ese estado:

function Pantalla({ estado }: { estado: EstadoPeticion<Empleado[]> }) {
  switch (estado.tipo) {
    case 'cargando':
      return <p>Cargando...</p>;
    case 'error':
      return <p className="error">{estado.mensaje}</p>;
    case 'correcto':
      return <p>{estado.datos.length} empleados</p>; // datos solo existe aquí
  }
}

No hace falta un default: como la función debe devolver algo, si añades un cuarto estado y no lo tratas, el compilador avisa.

📘 Concepto: Este principio se resume en la frase make illegal states unrepresentable: haz que los estados imposibles no se puedan representar. Cada if defensivo que no escribes es un fallo menos.

En las props

La misma idea sirve para props que dependen unas de otras. Una alerta puede llevar un enlace o un botón, pero nunca los dos:

// src/Alerta.tsx
import type { ReactNode } from 'react';

type Comun = { titulo: string; children?: ReactNode };

type AlertaProps = Comun &
  (
    | { tipo: 'informativa' }
    | { tipo: 'enlace'; href: string; textoEnlace: string }
    | { tipo: 'accion'; onAccion: () => void; textoBoton: string }
  );

export function Alerta(props: AlertaProps) {
  return (
    <div className="aviso" role="status">
      <strong>{props.titulo}</strong> {props.children}{' '}
      {props.tipo === 'enlace' && <a href={props.href}>{props.textoEnlace}</a>}
      {props.tipo === 'accion' && (
        <button className="boton boton-neutro" onClick={props.onAccion}>
          {props.textoBoton}
        </button>
      )}
    </div>
  );
}
// src/AlertasDemo.tsx
import { useState } from 'react';
import { Alerta } from './Alerta.tsx';

export default function AlertasDemo() {
  const [vistas, setVistas] = useState(0);

  return (
    <section className="tarjeta">
      <h2>Avisos</h2>
      <Alerta tipo="informativa" titulo="Copia completada">
        Datos de hoy guardados.
      </Alerta>
      <Alerta tipo="enlace" titulo="Nueva versión" href="#novedades" textoEnlace="Ver novedades" />
      <Alerta
        tipo="accion"
        titulo={`Pedidos revisados: ${vistas}`}
        textoBoton="Revisar otro"
        onAccion={() => setVistas((v) => v + 1)}
      />
      {/* @ts-expect-error Una alerta de enlace necesita href */}
      <Alerta tipo="enlace" titulo="Sin destino" textoEnlace="Ver" />
    </section>
  );
}

Hemos recibido props entero, sin desestructurar en la firma, porque href no existe en todas las opciones de la unión y escribir { href } en los parámetros daría error. Hay que comprobar primero props.tipo.

Y hemos dejado un ejemplo de @ts-expect-error. Este comentario dice que la línea siguiente debe dar error de tipos. Si algún día deja de darlo, el error es que el comentario sobra. Es ideal para probar que tus tipos rechazan lo que deben.

Cuando no hay un campo discriminante, puedes hacer dos props excluyentes con never:

// Un enlace interno o uno externo, pero nunca los dos
type Destino =
  | { ruta: string; url?: never }
  | { url: string; ruta?: never };

url?: never significa “esta prop no puede aparecer”: solo admite undefined.


10. Genéricos en componentes

Un genérico es un tipo con un hueco que se rellena al usarlo. Ya usas varios: en useState<Empleado[]>, T es Empleado[]. Un componente también puede tenerlos, y es la forma de hacer tablas, listas y selectores que funcionen con cualquier dato sin perder el tipo.

// src/Tabla.tsx
import type { Key, ReactNode } from 'react';

export type Columna<T> = {
  titulo: string;
  // El nombre de un campo de T, o una función que calcula la celda
  celda: keyof T | ((fila: T) => ReactNode);
  derecha?: boolean;
};

type TablaProps<T> = {
  filas: readonly T[];
  columnas: readonly Columna<T>[];
  claveFila: (fila: T) => Key;
  onElegir?: (fila: T) => void;
  vacio?: ReactNode;
};

function pintarCelda<T>(fila: T, celda: Columna<T>['celda']): ReactNode {
  if (typeof celda === 'function') {
    return celda(fila);
  }
  return String(fila[celda]);
}

export function Tabla<T>({ filas, columnas, claveFila, onElegir, vacio = 'Sin datos' }: TablaProps<T>) {
  if (filas.length === 0) {
    return <p className="pista">{vacio}</p>;
  }

  return (
    <table>
      <thead>
        <tr>
          {columnas.map((columna) => (
            <th key={columna.titulo} className={columna.derecha ? 'derecha' : undefined}>
              {columna.titulo}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {filas.map((fila) => (
          <tr key={claveFila(fila)} onClick={() => onElegir?.(fila)}>
            {columnas.map((columna) => (
              <td key={columna.titulo} className={columna.derecha ? 'derecha' : undefined}>
                {pintarCelda(fila, columna.celda)}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

T se deduce de las props, no hace falta escribirlo al usar el componente. Todo lo que recibe T queda enlazado: si filas es Empleado[], claveFila recibe un Empleado y celda solo admite campos de empleado.

Con el componente hecho, necesitamos datos. Este hook genérico devuelve la unión de la sección anterior, y evita llamar a setState de forma síncrona dentro del efecto, como pide la regla set-state-in-effect que viste en el módulo 09: si el resultado guardado no pertenece a la URL actual, es que estamos cargando.

// src/useFetch.ts
import { useEffect, useState } from 'react';
import type { EstadoPeticion } from './estadoPeticion.ts';

// Un resultado terminado: todo salvo 'cargando'
type Terminado<T> = Exclude<EstadoPeticion<T>, { tipo: 'cargando' }>;

export function useFetch<T>(url: string): EstadoPeticion<T> {
  const [resultado, setResultado] = useState<{ url: string; estado: Terminado<T> } | null>(null);

  useEffect(() => {
    const control = new AbortController();

    async function cargar() {
      try {
        const respuesta = await fetch(url, { signal: control.signal });
        if (!respuesta.ok) {
          throw new Error(`Error ${respuesta.status}`);
        }
        const datos = (await respuesta.json()) as T; // sin comprobar: ver sección 13
        setResultado({ url, estado: { tipo: 'correcto', datos } });
      } catch (e: unknown) {
        if (control.signal.aborted) {
          return;
        }
        const mensaje = e instanceof Error ? e.message : 'Error desconocido';
        setResultado({ url, estado: { tipo: 'error', mensaje } });
      }
    }

    void cargar();
    return () => control.abort();
  }, [url]);

  if (resultado === null || resultado.url !== url) {
    return { tipo: 'cargando' };
  }
  return resultado.estado;
}

Las pantallas ya solo describen qué columnas quieren:

// src/TablaDemo.tsx
import { useState } from 'react';
import { Tabla } from './Tabla.tsx';
import type { Columna } from './Tabla.tsx';
import { useFetch } from './useFetch.ts';
import type { Empleado, Pagina, Producto } from './tipos.ts';

const API = 'http://localhost:3000/api';
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });

const COLUMNAS_EMPLEADO: Columna<Empleado>[] = [
  { titulo: 'Id', celda: 'empId' },
  { titulo: 'Empleado', celda: (e) => `${e.nombre} ${e.apellidos}` },
  { titulo: 'Departamento', celda: (e) => e.departamento ?? 'Sin asignar' },
  { titulo: 'Salario', celda: (e) => euros.format(e.salario), derecha: true },
];

const COLUMNAS_PRODUCTO: Columna<Producto>[] = [
  { titulo: 'Producto', celda: 'nombre' },
  { titulo: 'Categoría', celda: 'categoria' },
  { titulo: 'Stock', celda: 'stock', derecha: true },
  { titulo: 'Precio', celda: (p) => euros.format(p.precio), derecha: true },
];

export default function TablaDemo() {
  const empleados = useFetch<Pagina<Empleado>>(`${API}/empleados?tamano=5&orden=-salario`);
  const productos = useFetch<Pagina<Producto>>(`${API}/productos?tamano=4&orden=-precio`);
  const [elegido, setElegido] = useState<Empleado | null>(null);

  return (
    <>
      <section className="tarjeta">
        <h2>Empleados mejor pagados</h2>
        {empleados.tipo === 'cargando' && <p>Cargando...</p>}
        {empleados.tipo === 'error' && <p className="error">{empleados.mensaje}</p>}
        {empleados.tipo === 'correcto' && (
          <Tabla
            filas={empleados.datos.contenido}
            columnas={COLUMNAS_EMPLEADO}
            claveFila={(e) => e.empId}
            onElegir={setElegido}
          />
        )}
        <p className="pista">Elegido: {elegido?.email ?? 'ninguno (pulsa una fila)'}</p>
      </section>
      <section className="tarjeta">
        <h2>Productos más caros</h2>
        {productos.tipo === 'correcto' && (
          <Tabla
            filas={productos.datos.contenido}
            columnas={COLUMNAS_PRODUCTO}
            claveFila={(p) => p.productoId}
          />
        )}
      </section>
    </>
  );
}

Dos tablas generadas por el mismo componente: una de empleados con salarios y otra de productos con stock y precio

Figura 6. Un único componente Tabla<T> pinta empleados y productos con datos reales de la API.

El mismo componente da servicio a dos tipos distintos, y un error en cualquiera de ellos se detecta al compilar. { titulo: 'Sueldo', celda: 'sueldo' } no compila en Columna<Empleado>, porque sueldo no es un campo de Empleado.

Genéricos y funciones flecha

En un fichero .tsx, <T> delante de unos paréntesis se confunde con una etiqueta JSX:

const Lista = <T>({ items }: { items: T[] }) => <p>{items.length}</p>;
// error TS17008: JSX element 'T' has no corresponding closing tag.

Tienes tres soluciones, y la primera es la más limpia:

// 1. Una función normal (lo que hacemos en el curso)
function Lista1<T>({ items }: { items: T[] }) {
  return <p>{items.length}</p>;
}

// 2. Una coma tras el parámetro
const Lista2 = <T,>({ items }: { items: T[] }) => <p>{items.length}</p>;

// 3. Una restricción, que además elimina la ambigüedad
const Lista3 = <T extends object>({ items }: { items: T[] }) => <p>{items.length}</p>;

Restricciones

extends limita qué tipos valen para T. Así puedes usar campos concretos dentro del componente y dejar de pedir funciones como claveFila:

type ConId = { id: number; etiqueta: string };

function ListaOpciones<T extends ConId>({ opciones, onElegir }: {
  opciones: readonly T[];
  onElegir: (opcion: T) => void;
}) {
  return (
    <ul>
      {opciones.map((o) => (
        <li key={o.id}>
          <button onClick={() => onElegir(o)}>{o.etiqueta}</button>
        </li>
      ))}
    </ul>
  );
}

La ventaja frente a escribir opciones: ConId[] a secas es que onElegir recibe el tipo completo: si le pasas objetos con más campos, los conservas.

Hooks genéricos y tuplas

Un hook genérico se escribe igual. Lo que sorprende es el retorno: si devuelves un array, TypeScript deduce un array de una unión, no una tupla. Con as const queda una tupla como la de useState:

// src/useAlternar.ts
import { useCallback, useState } from 'react';

export function useAlternar<T>(a: T, b: T) {
  const [valor, setValor] = useState(a);
  const alternar = useCallback(() => setValor((v) => (v === a ? b : a)), [a, b]);
  // Sin 'as const' sería (T | (() => void))[] y tendrías que comprobar cada elemento
  return [valor, alternar] as const;
}

memo pierde el genérico

Al envolver un componente genérico con memo, se pierde el parámetro y T pasa a ser unknown:

const TablaMemo = memo(Tabla);
// El parámetro de los callbacks es 'unknown'
// error TS18046: 'x' is of type 'unknown'.

La solución habitual es una aserción que devuelva el tipo original, un caso de los pocos en que as está justificado, porque memo no cambia las props:

const TablaMemo = memo(Tabla) as typeof Tabla;

11. Componentes polimórficos

Un componente polimórfico es aquel que elige qué etiqueta pinta mediante una prop, normalmente as. Es lo que hacen las bibliotecas de componentes: <Texto as="h2">, <Boton as="a" href="...">. Lo difícil es que las props válidas dependan de esa etiqueta: href debe aceptarse si as es a, y rechazarse si es button.

// src/Texto.tsx
import type { ComponentPropsWithRef, ElementType, ReactNode } from 'react';

type PropsPropias<C extends ElementType> = {
  as?: C;
  tono?: 'normal' | 'suave' | 'error';
  children?: ReactNode;
};

// Las mías, más las de la etiqueta C salvo las que chocan con las mías
type TextoProps<C extends ElementType> = PropsPropias<C> &
  Omit<ComponentPropsWithRef<C>, keyof PropsPropias<C>>;

const COLORES = { normal: '#263238', suave: '#607d8b', error: '#c62828' };

export function Texto<C extends ElementType = 'span'>({
  as,
  tono = 'normal',
  children,
  ...resto
}: TextoProps<C>) {
  const Etiqueta: ElementType = as ?? 'span';
  return (
    <Etiqueta style={{ color: COLORES[tono] }} {...resto}>
      {children}
    </Etiqueta>
  );
}
// src/TextoDemo.tsx
import { useRef } from 'react';
import type { ComponentProps } from 'react';
import { Texto } from './Texto.tsx';

function Enlace({ destino, ...resto }: ComponentProps<'a'> & { destino: string }) {
  return <a href={`#${destino}`} {...resto} />;
}

export default function TextoDemo() {
  const titulo = useRef<HTMLHeadingElement>(null);

  return (
    <section className="tarjeta">
      <Texto as="h2" ref={titulo}>
        Un título en h2
      </Texto>
      <p>
        <Texto>Un span normal.</Texto> <Texto tono="suave">Otro más suave.</Texto>
      </p>
      <p>
        <Texto as="a" href="#ayuda" tono="error" target="_blank">
          Un enlace, con href
        </Texto>
      </p>
      <p>
        <Texto as={Enlace} destino="ficha">
          Un componente propio
        </Texto>
      </p>
      {/* @ts-expect-error Un span no tiene href */}
      <Texto as="span" href="#no" />
    </section>
  );
}

Paso a paso:

  1. C extends ElementType admite cualquier etiqueta o componente. Con = 'span', si no hay as se asume span.
  2. TypeScript deduce C del valor de as: en <Texto as="a">, C es 'a'.
  3. ComponentPropsWithRef<C> da las props de esa etiqueta, y Omit quita las que chocan con las nuestras.
  4. Dentro del componente anotamos Etiqueta como ElementType a secas, porque TypeScript no puede razonar sobre JSX con un C sin resolver. Es una concesión: dentro de esta función las props no se comprueban, y por fuera sí.

⚠️ Importante: Los tipos polimórficos son lentos de compilar y dan los mensajes de error más largos de todos, porque el compilador debe resolver tipos condicionales anidados. Tienen sentido en una biblioteca de componentes. En una aplicación normal, suele haber opciones más sencillas.

La alternativa habitual es no ser polimórfico. Si solo necesitas un botón que a veces es un enlace, una unión de dos opciones, decidida por la presencia de href, resuelve el problema con muchos menos tipos:

// src/Accion.tsx
import type { ComponentProps } from 'react';

type AccionProps =
  | (ComponentProps<'a'> & { href: string })
  | (ComponentProps<'button'> & { href?: never });

export function Accion(props: AccionProps) {
  if (props.href !== undefined) {
    return <a className="boton boton-primario" {...props} />;
  }
  return <button type="button" className="boton boton-primario" {...props} />;
}

12. Tipos de la API

El paso más rentable de un proyecto real es tener en un solo fichero el tipo de lo que devuelve la API. Lo hemos escrito mirando las respuestas reales de EMPRESA. Fíjate en que telefono, comision, jefeId, deptId y departamento pueden ser null: si te lo inventas, fallarás justo con el empleado que no tiene jefe.

// src/tipos.ts
export type Pagina<T> = {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
};

export type Empleado = {
  empId: number;
  nombre: string;
  apellidos: string;
  email: string;
  telefono: string | null;
  fechaAlta: string; // la fecha llega como texto ISO: '2010-01-15'
  oficio: string;
  salario: number;
  comision: number | null;
  jefeId: number | null;
  deptId: number | null;
  departamento: string | null;
};

export type Producto = {
  productoId: number;
  nombre: string;
  descripcion: string;
  categoriaId: number;
  precio: number;
  stock: number;
  fechaAlta: string;
  descatalogado: boolean;
  categoria: string;
};

export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';

export type ErrorApi = {
  status: number;
  mensaje: string;
  detalles?: Record<string, string>;
};

// Tipos derivados: se actualizan solos si cambia Empleado
export type EmpleadoNuevo = Omit<Empleado, 'empId' | 'departamento'>;
export type EmpleadoCambios = Partial<EmpleadoNuevo>;
export type ResumenEmpleado = Pick<Empleado, 'empId' | 'nombre' | 'apellidos'>;

Una fecha en JSON es siempre texto, porque JSON no tiene tipo de fecha. Declarar fechaAlta: Date sería una mentira: compilaría, y e.fechaAlta.getFullYear() fallaría en ejecución.

Los últimos tres tipos ilustran por qué conviene derivar. Si escribes EmpleadoNuevo a mano, un campo nuevo en Empleado te obliga a recordar otro cambio. Con Omit, no.

Ahora, una función que centralice las peticiones. Con TypeScript hay un punto clave: response.json() devuelve Promise<any>, y any desactiva el compilador. Por eso le ponemos un tipo en cuanto llega:

// src/api.ts
import type { ErrorApi } from './tipos.ts';

const API = 'http://localhost:3000/api';

export class ApiError extends Error {
  status: number;
  detalles: Record<string, string>;

  constructor(cuerpo: ErrorApi) {
    super(cuerpo.mensaje);
    this.name = 'ApiError';
    this.status = cuerpo.status;
    this.detalles = cuerpo.detalles ?? {};
  }
}

function esErrorApi(valor: unknown): valor is ErrorApi {
  return (
    typeof valor === 'object' &&
    valor !== null &&
    'status' in valor &&
    'mensaje' in valor &&
    typeof valor.mensaje === 'string'
  );
}

export async function peticion<T>(ruta: string, opciones?: RequestInit): Promise<T> {
  const respuesta = await fetch(`${API}${ruta}`, {
    ...opciones,
    headers: { 'Content-Type': 'application/json', ...opciones?.headers },
  });
  const cuerpo: unknown = await respuesta.json();

  if (!respuesta.ok) {
    throw new ApiError(
      esErrorApi(cuerpo) ? cuerpo : { status: respuesta.status, mensaje: 'Error inesperado' },
    );
  }
  return cuerpo as T; // la aserción: la sección 13 la sustituye por una comprobación real
}

type RespuestaLogin = {
  token: string;
  usuario: { email: string; nombre: string; rol: 'ADMIN' | 'USER' };
  expiraEn: number;
};

export function iniciarSesion(email: string, password: string) {
  return peticion<RespuestaLogin>('/auth/login', {
    method: 'POST',
    body: JSON.stringify({ email, password }),
  });
}

export function crearEmpleado(token: string, empleado: unknown) {
  return peticion<unknown>('/empleados', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: JSON.stringify(empleado),
  });
}

Observa tres decisiones:

  • const cuerpo: unknown. unknown es el opuesto seguro de any: admite cualquier valor, pero no te deja usarlo hasta que compruebes qué es.
  • esErrorApi. Una guarda de tipo para un objeto. Con in, TypeScript aprende que la propiedad existe y permite comprobar su tipo.
  • catch (e: unknown). En una cláusula catch el error es unknown, porque en JavaScript se puede lanzar cualquier cosa. Antes de usarlo hay que estrecharlo con instanceof.

Probamos todo esto sin navegador. Node 24 ejecuta TypeScript directamente, borrando los tipos, algo que encaja con erasableSyntaxOnly:

// src/prueba-api.ts
import { ApiError, crearEmpleado, iniciarSesion, peticion } from './api.ts';
import type { Empleado, Pagina } from './tipos.ts';

const pagina = await peticion<Pagina<Empleado>>('/empleados?tamano=2&orden=-salario');
console.log(`Total: ${pagina.total}`);
console.log(pagina.contenido.map((e) => `${e.nombre}: ${e.salario}`));

try {
  await peticion<Empleado>('/empleados/9999');
} catch (e: unknown) {
  if (e instanceof ApiError) {
    console.log(`Error ${e.status}: ${e.message}`);
  }
}

const { token, usuario } = await iniciarSesion('ana.garcia@empresa.es', 'secreto');
console.log(`Sesión de ${usuario.nombre} (${usuario.rol})`);

try {
  await crearEmpleado(token, { nombre: 'Eva' });
} catch (e: unknown) {
  if (e instanceof ApiError) {
    console.log(`Error ${e.status}: ${e.message}`);
    console.log(e.detalles);
  }
}
node src/prueba-api.ts
Total: 24
[ 'Ana: 6500', 'Marta: 5000' ]
Error 404: Empleado 9999 no encontrado
Sesión de Ana García (ADMIN)
Error 422: Hay datos no válidos
{
  apellidos: 'Los apellidos son obligatorios',
  email: 'El email no es válido',
  oficio: 'El oficio es obligatorio',
  salario: 'El salario debe ser mayor que cero'
}

13. Validación en ejecución con Zod

Hasta aquí, la línea return cuerpo as T es una promesa sin garantía. as no comprueba nada: le dice al compilador “confía en mí”. Si la API cambia, el error no sale ahí, sino mucho después, en un componente que hace e.salario.toFixed(2) y recibe un texto.

Los datos externos (API, formularios, localStorage y URL) cruzan una frontera donde Zod los valida; dentro de la aplicación los tipos ya son fiables

Figura 7. Valida en la frontera. Dentro de la aplicación, los tipos cuentan la verdad.

Zod es una biblioteca que describe la forma de los datos mediante esquemas, que son objetos normales de JavaScript. Un esquema existe en ejecución, puede comprobar datos reales y además TypeScript puede deducir de él un tipo. Así la descripción se escribe una sola vez.

// src/esquemas.ts
import { z } from 'zod';

export const EmpleadoEsquema = z.object({
  empId: z.number().int(),
  nombre: z.string(),
  apellidos: z.string(),
  email: z.email(),
  telefono: z.string().nullable(),
  fechaAlta: z.iso.date(),
  oficio: z.string(),
  salario: z.number(),
  comision: z.number().nullable(),
  jefeId: z.number().int().nullable(),
  deptId: z.number().int().nullable(),
  departamento: z.string().nullable(),
});

// El tipo sale del esquema: no hay que escribirlo dos veces
export type Empleado = z.infer<typeof EmpleadoEsquema>;

export const EstadoPedidoEsquema = z.enum(['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO']);
export type EstadoPedido = z.infer<typeof EstadoPedidoEsquema>;

export const LineaPedidoEsquema = z.object({
  numLinea: z.number().int(),
  productoId: z.number().int(),
  producto: z.string(),
  cantidad: z.number().int().positive(),
  precioUnitario: z.number(),
  descuento: z.number(),
  importe: z.number(),
});

export const PedidoEsquema = z.object({
  pedidoId: z.number().int(),
  cliente: z.string(),
  fechaPedido: z.iso.date(),
  fechaEnvio: z.iso.date().nullable(),
  estado: EstadoPedidoEsquema,
  lineas: z.array(LineaPedidoEsquema),
  total: z.number(),
});

export type Pedido = z.infer<typeof PedidoEsquema>;

// Un esquema genérico: una función que recibe un esquema y devuelve otro
export function esquemaPagina<T extends z.ZodType>(elemento: T) {
  return z.object({
    contenido: z.array(elemento),
    pagina: z.number(),
    tamano: z.number(),
    total: z.number(),
    paginas: z.number(),
  });
}

Algunas notas sobre lo que acabamos de escribir:

  • z.infer<typeof EmpleadoEsquema> produce un tipo igual al que escribimos a mano en tipos.ts. Pasa el ratón por encima en VS Code para verlo. Si cambias el esquema, cambia el tipo.
  • En Zod 4 los formatos son funciones de primer nivel: z.email() y z.iso.date().
  • PedidoEsquema no enumera todos los campos del pedido. Por defecto, z.object acepta el objeto y elimina del resultado las claves que no declara, así que tu aplicación solo ve lo que dice el esquema.
  • z.enum da a la vez la lista en ejecución (EstadoPedidoEsquema.options) y la unión de literales.

La función pedir sustituye la aserción por una comprobación real. Su tipo de retorno sale del esquema que recibe:

// src/apiSegura.ts
import { z } from 'zod';
import { peticion } from './api.ts';

export async function pedir<E extends z.ZodType>(
  ruta: string,
  esquema: E,
  opciones?: RequestInit,
): Promise<z.output<E>> {
  const datos = await peticion<unknown>(ruta, opciones);
  return esquema.parse(datos); // lanza ZodError si no encaja
}
// src/prueba-zod.ts
import { z } from 'zod';
import { pedir } from './apiSegura.ts';
import { EmpleadoEsquema, PedidoEsquema, esquemaPagina } from './esquemas.ts';

const empleados = await pedir('/empleados?tamano=100', esquemaPagina(EmpleadoEsquema));
console.log(`Empleados válidos: ${empleados.contenido.length}`);

const pedidos = await pedir('/pedidos?tamano=100', esquemaPagina(PedidoEsquema));
console.log(`Pedidos válidos: ${pedidos.contenido.length}`);
console.log(pedidos.contenido[0]?.estado, pedidos.contenido[0]?.total);

// Simulamos que la API cambia: un salario en texto, un email roto y un campo ausente
const { telefono: _telefono, ...sinTelefono } = empleados.contenido[0]!;
const resultado = EmpleadoEsquema.safeParse({ ...sinTelefono, salario: '6500', email: 'ana' });

if (resultado.success) {
  console.log(resultado.data.salario.toFixed(2)); // aquí 'data' es un Empleado
} else {
  console.log(z.prettifyError(resultado.error));
}
node src/prueba-zod.ts
Empleados válidos: 24
Pedidos válidos: 24
ENTREGADO 1802.9
✖ Invalid email address
  → at email
✖ Invalid input: expected string, received undefined
  → at telefono
✖ Invalid input: expected number, received string
  → at salario

Los 24 empleados y los 24 pedidos reales pasan la validación, y con los datos alterados Zod señala cada campo y su problema, sin detenerse en el primer fallo. Los textos salen en inglés. Puedes cambiarlos con z.config(z.locales.es()) al arrancar la aplicación, aunque la traducción automática no siempre queda fina. En una interfaz es mejor escribir un mensaje propio en cada regla, como haremos en el formulario.

Las dos formas de validar se diferencian en cómo informan del fallo:

Método Si los datos son válidos Si no lo son Úsalo en
esquema.parse(x) Devuelve los datos Lanza ZodError Respuestas de la API, donde un fallo es un error de verdad
esquema.safeParse(x) { success: true, data } { success: false, error } Formularios, donde un fallo es lo normal

Fíjate en que safeParse devuelve una unión discriminada, justo lo que aprendiste en la sección 9: success es el campo discriminante y por eso data solo existe en la rama correcta.

Combinarlo con useFetch

El hook de la sección 10 tenía un as T. Con un esquema desaparece. Lo que cambia es poco, y el resultado ya no necesita el parámetro <T>: se deduce del esquema. El esquema debe ser una constante de módulo, porque es una dependencia del efecto y crear uno nuevo en cada render repetiría la petición sin fin.

// src/useConsulta.ts
import { useEffect, useState } from 'react';
import { z } from 'zod';
import type { EstadoPeticion } from './estadoPeticion.ts';

type Terminado<T> = Exclude<EstadoPeticion<T>, { tipo: 'cargando' }>;

export function useConsulta<E extends z.ZodType>(url: string, esquema: E): EstadoPeticion<z.output<E>> {
  const [resultado, setResultado] = useState<{
    url: string;
    estado: Terminado<z.output<E>>;
  } | null>(null);

  useEffect(() => {
    const control = new AbortController();

    fetch(url, { signal: control.signal })
      .then((respuesta) => respuesta.json())
      .then((cuerpo: unknown) => {
        const validado = esquema.safeParse(cuerpo);
        setResultado({
          url,
          estado: validado.success
            ? { tipo: 'correcto', datos: validado.data }
            : { tipo: 'error', mensaje: z.prettifyError(validado.error) },
        });
      })
      .catch((e: unknown) => {
        if (!control.signal.aborted) {
          const mensaje = e instanceof Error ? e.message : 'Error desconocido';
          setResultado({ url, estado: { tipo: 'error', mensaje } });
        }
      });

    return () => control.abort();
  }, [url, esquema]);

  if (resultado === null || resultado.url !== url) {
    return { tipo: 'cargando' };
  }
  return resultado.estado;
}
// src/ConsultaDemo.tsx
import { useConsulta } from './useConsulta.ts';
import { PedidoEsquema, esquemaPagina } from './esquemas.ts';

const API = 'http://localhost:3000/api';
const ListaPedidos = esquemaPagina(PedidoEsquema);

export default function ConsultaDemo() {
  const pedidos = useConsulta(`${API}/pedidos?tamano=5&estado=PENDIENTE`, ListaPedidos);

  if (pedidos.tipo === 'cargando') {
    return <p>Cargando...</p>;
  }
  if (pedidos.tipo === 'error') {
    return <pre className="error">{pedidos.mensaje}</pre>;
  }

  return (
    <section className="tarjeta">
      <h2>Pedidos pendientes ({pedidos.datos.total})</h2>
      <ul>
        {pedidos.datos.contenido.map((p) => (
          <li key={p.pedidoId}>
            {p.pedidoId}: {p.cliente}, {p.lineas.length} líneas, {p.total} €
          </li>
        ))}
      </ul>
    </section>
  );
}

Formularios: validar lo que escribe el usuario

Con un formulario hay un matiz: todo lo que llega de FormData es texto. z.coerce.number() convierte el valor con Number() antes de validarlo. Por eso un esquema tiene dos tipos: el de entrada y el de salida.

// src/AltaEmpleadoDemo.tsx
import { useState } from 'react';
import type { SubmitEvent } from 'react';
import { z } from 'zod';
import { ApiError, crearEmpleado, iniciarSesion } from './api.ts';
import { Boton } from './Boton.tsx';

const AltaEsquema = z.object({
  nombre: z.string().trim().min(2, 'Escribe al menos 2 letras'),
  apellidos: z.string().trim().min(2, 'Escribe al menos 2 letras'),
  email: z.email('El email no es válido'),
  oficio: z.string().trim().min(1, 'Indica el oficio'),
  fechaAlta: z.iso.date('Indica una fecha'),
  salario: z.coerce
    .number('Debe ser un número')
    .positive('Debe ser mayor que cero')
    .max(20000, 'No puede pasar de 20 000'),
  deptId: z.coerce.number().int(),
});

// Salida: salario y deptId son number
type AltaEmpleado = z.output<typeof AltaEsquema>;

type Campo = keyof AltaEmpleado;
type Errores = Partial<Record<Campo, string[]>>;

type Envio =
  | { fase: 'editando' }
  | { fase: 'enviando' }
  | { fase: 'creado'; nombre: string }
  | { fase: 'fallido'; mensaje: string };

const CAMPOS: { campo: Exclude<Campo, 'deptId'>; etiqueta: string; tipo: string }[] = [
  { campo: 'nombre', etiqueta: 'Nombre', tipo: 'text' },
  { campo: 'apellidos', etiqueta: 'Apellidos', tipo: 'text' },
  { campo: 'email', etiqueta: 'Email', tipo: 'text' },
  { campo: 'oficio', etiqueta: 'Oficio', tipo: 'text' },
  { campo: 'fechaAlta', etiqueta: 'Fecha de alta', tipo: 'date' },
  { campo: 'salario', etiqueta: 'Salario', tipo: 'text' },
];

async function guardar(datos: AltaEmpleado) {
  const { token } = await iniciarSesion('ana.garcia@empresa.es', 'secreto');
  await crearEmpleado(token, datos);
}

export default function AltaEmpleadoDemo() {
  const [errores, setErrores] = useState<Errores>({});
  const [envio, setEnvio] = useState<Envio>({ fase: 'editando' });

  async function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const crudo = Object.fromEntries(new FormData(e.currentTarget));
    const resultado = AltaEsquema.safeParse(crudo);

    if (!resultado.success) {
      setErrores(z.flattenError(resultado.error).fieldErrors);
      setEnvio({ fase: 'editando' });
      return;
    }

    setErrores({});
    setEnvio({ fase: 'enviando' });
    try {
      await guardar(resultado.data);
      setEnvio({ fase: 'creado', nombre: resultado.data.nombre });
    } catch (error: unknown) {
      if (error instanceof ApiError) {
        setEnvio({ fase: 'fallido', mensaje: `${error.message}: ${Object.values(error.detalles).join(', ')}` });
      } else {
        setEnvio({ fase: 'fallido', mensaje: 'No se ha podido contactar con el servidor' });
      }
    }
  }

  return (
    <form className="tarjeta" onSubmit={enviar} noValidate>
      <h2>Alta de empleado</h2>
      {CAMPOS.map(({ campo, etiqueta, tipo }) => (
        <div className="campo" key={campo}>
          <label htmlFor={campo}>{etiqueta}</label>
          <input id={campo} name={campo} type={tipo} aria-invalid={errores[campo] !== undefined} />
          {errores[campo]?.map((mensaje) => (
            <span className="error" key={mensaje}>
              {mensaje}
            </span>
          ))}
        </div>
      ))}
      <input type="hidden" name="deptId" value="30" />
      <Boton type="submit" variante="primario" cargando={envio.fase === 'enviando'}>
        Dar de alta
      </Boton>
      {envio.fase === 'creado' && <p className="aviso">Se ha creado a {envio.nombre}.</p>}
      {envio.fase === 'fallido' && <p className="error">{envio.mensaje}</p>}
    </form>
  );
}

Formulario de alta de empleado con mensajes de error debajo del nombre, el email, la fecha y el salario

Figura 8. Los mensajes salen del esquema de Zod y se reparten por campo con z.flattenError.

Este ejemplo reúne casi todo el módulo: SubmitEvent, una unión discriminada para la fase de envío, Record y Partial para los errores, keyof para que los nombres de campo no se puedan escribir mal, y Exclude para sacar de la lista el campo oculto.

Hay dos capas de validación, y ninguna sobra. La del cliente da respuesta inmediata, pero se puede saltar: cualquiera puede enviar una petición sin pasar por tu formulario. La del servidor es la que protege los datos. Por eso tratamos también la respuesta de la API.

Herramienta de Zod Para qué sirve
z.infer<typeof E>, z.output<typeof E> Tipo de salida, tras transformaciones
z.input<typeof E> Tipo de entrada, antes de transformar. Con z.coerce es unknown
.nullable(), .optional() Admite null, admite undefined
.default(valor) Si falta, usa ese valor
.trim(), .toLowerCase() Transforman el texto antes de seguir
.min(), .max(), .positive() Reglas, con mensaje opcional
z.coerce.number() Convierte con Number() antes de validar
.transform(fn) Convierte el valor validado en otro, por ejemplo un texto en Date
.refine(fn, mensaje) Una regla propia
z.enum([...]), z.discriminatedUnion(campo, [...]) Uniones de literales y uniones discriminadas
z.prettifyError(e), z.flattenError(e), z.treeifyError(e) Da formato al error: texto, por campos o en árbol

En el módulo 21 usarás estos mismos esquemas con React Hook Form, que se encarga de los errores y del estado de cada campo.

💡 Consejo: Valida en las fronteras y no por todas partes. Una vez que un dato ha pasado por el esquema, no vuelvas a comprobarlo en cada componente. Un esquema de una respuesta grande tiene un coste, así que en una lista de miles de filas compensa medir. Y decide qué hacer al fallar: mostrar el error y avisar al equipo es mejor que pintar datos a medias.


14. Utilidades de tipos

TypeScript trae transformaciones ya hechas. Estas son las que más vas a usar en React:

Utilidad Resultado Ejemplo en EMPRESA
Partial<T> Todos los campos opcionales Cambios de un PATCH
Required<T> Todos obligatorios Configuración con valores ya rellenos
Readonly<T> Todos de solo lectura Props
Pick<T, 'a' \| 'b'> Solo esos campos Una fila de resumen
Omit<T, 'a'> Todos menos esos Un alta, sin empId
Record<K, V> Objeto con claves K y valores V Etiqueta de cada estado
Exclude<U, X> Quita de la unión Estados terminados
Extract<U, X> Se queda con lo que encaja de la unión Una acción concreta
NonNullable<T> Quita null y undefined Tras un filtro
ReturnType<typeof f> Lo que devuelve una función El tipo de un hook
Parameters<typeof f> Tupla con sus parámetros Envolver una función
Awaited<T> El valor de una promesa Resultado de una función async

Tres herramientas del lenguaje completan la caja:

// src/utilidades.ts
import type { EstadoPedido, Empleado } from './tipos.ts';

// typeof convierte un valor en tipo; keyof da la unión de sus claves
const TEMAS = { claro: '#ffffff', oscuro: '#263238' };
export type Tema = keyof typeof TEMAS; // 'claro' | 'oscuro'

// as const conserva los literales y marca todo de solo lectura
export const ROLES = ['ADMIN', 'USER'] as const;
export type Rol = (typeof ROLES)[number]; // 'ADMIN' | 'USER'

// Record con una unión como clave obliga a escribir todos los casos
export const ETIQUETAS: Record<EstadoPedido, string> = {
  PENDIENTE: 'Pendiente',
  ENVIADO: 'Enviado',
  ENTREGADO: 'Entregado',
  CANCELADO: 'Cancelado',
};

// satisfies comprueba el tipo sin ensancharlo: se conservan los literales
export const COLUMNAS = {
  nombre: { titulo: 'Nombre', ancho: 200 },
  salario: { titulo: 'Salario', ancho: 100 },
} satisfies Partial<Record<keyof Empleado, { titulo: string; ancho: number }>>;

export const anchoSalario = COLUMNAS.salario.ancho; // existe seguro, sin comprobar undefined

La diferencia entre satisfies y una anotación es sutil y útil. Con const COLUMNAS: Partial<Record<...>>, COLUMNAS.salario podría ser undefined, aunque lo acabes de escribir, porque el tipo anotado borra lo que sabías. Con satisfies, TypeScript comprueba que encaja y se queda con el tipo exacto del valor.

Si añades un estado a EstadoPedido, ETIQUETAS deja de compilar hasta que escribas su texto. Es otra forma de exhaustividad, más sencilla que un switch.


15. Errores frecuentes y cómo leerlos

Un mensaje de TypeScript se lee de abajo arriba. Las primeras líneas dicen qué conjunto no encaja, y las últimas, con más sangría, señalan la causa concreta. Si el mensaje es muy largo, busca la última línea.

Mensaje Qué significa Solución
Type 'string' is not assignable to type 'never' Un useState([]) sin tipo useState<string[]>([])
Property 'x' is missing in type Falta una prop obligatoria Pásala o hazla opcional
'x.current' is possibly 'null' Referencia aún vacía ?. o if
Parameter 'e' implicitly has an 'any' type Manejador separado sin tipo Indica ChangeEvent<...>
Type '(e: SubmitEvent) => void' is not assignable Falta import type de React Importa el tipo de react
Cannot find namespace 'JSX' Tipos de React 19 import type { JSX } from 'react'
Expected 1 arguments, but got 0 en useRef Tipos de React 19 useRef<T>(null)
does not satisfy the constraint 'AnyActionArg' useReducer<Estado, Accion> Quita los parámetros
JSX element 'T' has no corresponding closing tag <T> en una flecha de .tsx Usa una función o <T,>
This syntax is not allowed when 'erasableSyntaxOnly' enum o similar Unión de literales
'x' is of type 'unknown' Falta estrechar typeof, instanceof, guarda o esquema
'x' cannot be used as a JSX component La función no devuelve nada pintable Falta un return

Las vías de escape

TypeScript te da formas de callar al compilador. Cada una es una deuda, porque quitas una comprobación:

Vía Qué hace Cuándo es aceptable
any Desactiva la comprobación y se contagia Casi nunca; usa unknown
as Tipo Afirma un tipo sin comprobarlo Tras comprobar tú mismo, o en memo(...) as typeof X
! Afirma que no es null getElementById('root')! y casos así
// @ts-ignore Oculta el error de la línea siguiente Nunca: usa el siguiente
// @ts-expect-error Igual, pero falla si no hay error Pruebas de tipos y parches temporales, con motivo

any es el peor porque se propaga. Si const datos: any = ..., también son any datos.empleados, datos.empleados[0].salario y todo lo que hagas con ellos, sin ningún aviso. unknown es igual de flexible para recibir, pero te obliga a comprobar antes de usar.

💡 Consejo: oxlint, que usa la plantilla, puede vigilar el uso de any. Añade "typescript/no-explicit-any": "error" a las reglas de .oxlintrc.json cuando quieras prohibirlo en tu proyecto.

Depurar tipos

Cuando no entiendas lo que ocurre:

  1. Pasa el ratón. VS Code muestra el tipo deducido de cualquier variable. Casi siempre basta.
  2. Pulsa F12 sobre un tipo para ir a su declaración. Los .d.ts de node_modules/@types/react/index.d.ts se leen bien, y sus comentarios explican mucho, incluidos los avisos de obsoleto.
  3. Asigna a un tipo concreto. Escribe const prueba: number = valor;, y el error te dirá el tipo real.
  4. Reinicia el servidor de TypeScript si el editor muestra errores que npx tsc -b no da: en la paleta de VS Code, TypeScript: Restart TS Server.
  5. Comprueba npm ls @types/react. Muchos errores extraños vienen de copias repetidas o de versiones que no corresponden a tu react.

16. Ejercicios

Ejercicio 1: Etiqueta de estado de un pedido

Crea un componente EtiquetaEstado que reciba estado: EstadoPedido y pinte su nombre en español con un color distinto. Usa Record, de forma que añadir un estado nuevo a la unión obligue a actualizar el componente.

Ver solución
// src/ejercicios/EtiquetaEstado.tsx
import type { EstadoPedido } from '../tipos.ts';

const ASPECTO: Record<EstadoPedido, { texto: string; color: string }> = {
  PENDIENTE: { texto: 'Pendiente', color: '#ef6c00' },
  ENVIADO: { texto: 'Enviado', color: '#1565c0' },
  ENTREGADO: { texto: 'Entregado', color: '#2e7d32' },
  CANCELADO: { texto: 'Cancelado', color: '#c62828' },
};

export function EtiquetaEstado({ estado }: { estado: EstadoPedido }) {
  const { texto, color } = ASPECTO[estado];
  return <span style={{ color, fontWeight: 600 }}>{texto}</span>;
}

Record<EstadoPedido, ...> exige una entrada por cada valor de la unión. Si añades 'DEVUELTO', el objeto deja de compilar, con un mensaje que nombra la clave que falta. Con un switch tendrías que añadir la comprobación de never, y con un if no tendrías ninguna protección.

Ejercicio 2: Campo numérico

Crea CampoNumero a partir de las props de <input>, pero con value: number | null y onChange: (valor: number | null) => void. Un campo vacío debe entregar null. No debe poder cambiarse su type.

Ver solución
// src/ejercicios/CampoNumero.tsx
import type { ChangeEvent, ComponentProps } from 'react';

type CampoNumeroProps = Omit<ComponentProps<'input'>, 'value' | 'onChange' | 'type'> & {
  etiqueta: string;
  value: number | null;
  onChange: (valor: number | null) => void;
};

export function CampoNumero({ etiqueta, value, onChange, ...resto }: CampoNumeroProps) {
  function cambiar(e: ChangeEvent<HTMLInputElement>) {
    const { valueAsNumber } = e.target;
    onChange(Number.isNaN(valueAsNumber) ? null : valueAsNumber);
  }

  return (
    <label className="campo">
      {etiqueta}
      <input {...resto} type="number" value={value ?? ''} onChange={cambiar} />
    </label>
  );
}

Hay que quitar value y onChange con Omit, porque una intersección con el tipo nativo los combinaría. Quitar type de las props hace que <CampoNumero type="text" /> no compile. valueAsNumber vale NaN si el campo está vacío, y ahí lo convertimos en null. El value ?? '' mantiene el campo controlado: si pasas null a un input, React considera que deja de estar controlado.

Ejercicio 3: Buscador con orden validado

Crea FormularioBusqueda: un formulario con un campo q y un <select> con orden ('nombre', '-salario' o 'fechaAlta'). Al enviarlo, llama a onBuscar con un objeto tipado. No uses as.

Ver solución
// src/ejercicios/FormularioBusqueda.tsx
import type { SubmitEvent } from 'react';

const ORDENES = ['nombre', '-salario', 'fechaAlta'] as const;
type Orden = (typeof ORDENES)[number];

export type Busqueda = { q: string; orden: Orden };

function esOrden(valor: unknown): valor is Orden {
  return ORDENES.some((orden) => orden === valor);
}

export function FormularioBusqueda({ onBuscar }: { onBuscar: (busqueda: Busqueda) => void }) {
  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const datos = new FormData(e.currentTarget);
    const q = datos.get('q');
    const orden = datos.get('orden');
    if (typeof q === 'string' && esOrden(orden)) {
      onBuscar({ q: q.trim(), orden });
    }
  }

  return (
    <form className="fila" onSubmit={enviar}>
      <input name="q" aria-label="Texto" />
      <select name="orden" aria-label="Orden" defaultValue="nombre">
        {ORDENES.map((orden) => (
          <option key={orden} value={orden}>
            {orden}
          </option>
        ))}
      </select>
      <button className="boton boton-primario">Buscar</button>
    </form>
  );
}

La lista ORDENES existe en ejecución y el tipo sale de ella, así que no pueden desincronizarse. ORDENES.includes(valor) no compilaría, porque includes en un as const solo acepta los literales: por eso usamos some. Tras la guarda, orden es Orden, sin ninguna aserción.

Ejercicio 4: Reducer de filtros exhaustivo

Escribe filtrosReducer con el estado { texto: string; deptId: number | null; soloConComision: boolean } y las acciones texto, departamento, alternarComision y limpiar. Olvidar un caso debe ser un error de compilación. Escribe una función aplicarFiltros que reciba una lista de Empleado y el estado.

Ver solución
// src/ejercicios/filtrosReducer.ts
import type { Empleado } from '../tipos.ts';

export type Filtros = {
  texto: string;
  deptId: number | null;
  soloConComision: boolean;
};

export type AccionFiltros =
  | { tipo: 'texto'; valor: string }
  | { tipo: 'departamento'; deptId: number | null }
  | { tipo: 'alternarComision' }
  | { tipo: 'limpiar' };

export const filtrosIniciales: Filtros = { texto: '', deptId: null, soloConComision: false };

export function filtrosReducer(estado: Filtros, accion: AccionFiltros): Filtros {
  switch (accion.tipo) {
    case 'texto':
      return { ...estado, texto: accion.valor };
    case 'departamento':
      return { ...estado, deptId: accion.deptId };
    case 'alternarComision':
      return { ...estado, soloConComision: !estado.soloConComision };
    case 'limpiar':
      return filtrosIniciales;
    default: {
      const nunca: never = accion;
      return nunca;
    }
  }
}

export function aplicarFiltros(empleados: readonly Empleado[], filtros: Filtros): Empleado[] {
  const texto = filtros.texto.toLowerCase();
  return empleados.filter(
    (e) =>
      `${e.nombre} ${e.apellidos}`.toLowerCase().includes(texto) &&
      (filtros.deptId === null || e.deptId === filtros.deptId) &&
      (!filtros.soloConComision || e.comision !== null),
  );
}

Aquí hemos escrito la comprobación directamente, con const nunca: never = accion, en lugar de usar una función. Hace lo mismo: si queda un caso sin tratar, esa asignación no compila. aplicarFiltros es una función pura, separada del componente, y se prueba sin React.

Ejercicio 5: Lista seleccionable genérica

Crea ListaSeleccionable<T>, que reciba elementos, claveDe y etiquetaDe, y una prop seleccion: T | null con onCambiar. Debe funcionar con empleados y con productos, y onCambiar debe recibir el objeto completo.

Ver solución
// src/ejercicios/ListaSeleccionable.tsx
import type { Key, ReactNode } from 'react';

type ListaSeleccionableProps<T> = {
  elementos: readonly T[];
  claveDe: (elemento: T) => Key;
  etiquetaDe: (elemento: T) => ReactNode;
  seleccion: T | null;
  onCambiar: (elemento: T | null) => void;
};

export function ListaSeleccionable<T>({
  elementos,
  claveDe,
  etiquetaDe,
  seleccion,
  onCambiar,
}: ListaSeleccionableProps<T>) {
  const claveElegida = seleccion === null ? null : claveDe(seleccion);

  return (
    <ul>
      {elementos.map((elemento) => {
        const clave = claveDe(elemento);
        const elegido = clave === claveElegida;
        return (
          <li key={clave}>
            <button
              className={`boton ${elegido ? 'boton-primario' : 'boton-neutro'}`}
              aria-pressed={elegido}
              onClick={() => onCambiar(elegido ? null : elemento)}
            >
              {etiquetaDe(elemento)}
            </button>
          </li>
        );
      })}
    </ul>
  );
}
// src/ejercicios/ListaSeleccionableDemo.tsx
import { useState } from 'react';
import { ListaSeleccionable } from './ListaSeleccionable.tsx';
import type { Producto } from '../tipos.ts';

type Fila = Pick<Producto, 'productoId' | 'nombre' | 'precio'>;

const PRODUCTOS: Fila[] = [
  { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899 },
  { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9 },
];

export default function ListaSeleccionableDemo() {
  const [producto, setProducto] = useState<Fila | null>(null);

  return (
    <section className="tarjeta">
      <ListaSeleccionable
        elementos={PRODUCTOS}
        claveDe={(p) => p.productoId}
        etiquetaDe={(p) => p.nombre}
        seleccion={producto}
        onCambiar={setProducto}
      />
      <p>{producto ? `Precio: ${producto.precio} €` : 'Elige un producto'}</p>
    </section>
  );
}

Comparamos por clave y no con elemento === seleccion, porque tras recargar los datos los objetos son otros aunque representen lo mismo. T se deduce de elementos y fluye al resto: en claveDe={(p) => p.productoId}, p ya es un Fila.

Ejercicio 6: Contexto del tema sin null

Crea ProveedorTema y el hook useTema, con un tema 'claro' | 'oscuro' y una función alternar. El hook no debe devolver nunca null. Sepáralo para que un componente que solo necesite alternar no tenga que comprobar nada.

Ver solución
// src/ejercicios/tema.tsx
import { createContext, use, useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';

export type Tema = 'claro' | 'oscuro';

type ValorTema = { tema: Tema; alternar: () => void };

const TemaContexto = createContext<ValorTema | null>(null);

export function ProveedorTema({ children }: { children: ReactNode }) {
  const [tema, setTema] = useState<Tema>('claro');
  const alternar = useCallback(
    () => setTema((actual) => (actual === 'claro' ? 'oscuro' : 'claro')),
    [],
  );
  const valor = useMemo(() => ({ tema, alternar }), [tema, alternar]);

  return <TemaContexto value={valor}>{children}</TemaContexto>;
}

export function useTema(): ValorTema {
  const valor = use(TemaContexto);
  if (valor === null) {
    throw new Error('useTema debe usarse dentro de <ProveedorTema>');
  }
  return valor;
}

Tras el if, el tipo de valor ya no incluye null, y es el hook el que lleva anotado el retorno ValorTema. Quien lo usa escribe const { alternar } = useTema() sin más comprobaciones.

Ejercicio 7: Enlace o botón

Crea AccionMenu, que reciba texto y además href o onPulsar, pero nunca los dos ni ninguno. Añade dos usos incorrectos con @ts-expect-error para demostrarlo.

Ver solución
// src/ejercicios/AccionMenu.tsx
type AccionMenuProps = { texto: string } & (
  | { href: string; onPulsar?: never }
  | { onPulsar: () => void; href?: never }
);

export function AccionMenu({ texto, href, onPulsar }: AccionMenuProps) {
  if (href !== undefined) {
    return <a href={href}>{texto}</a>;
  }
  return <button onClick={onPulsar}>{texto}</button>;
}

export function MenuDePrueba() {
  return (
    <>
      <AccionMenu texto="Ayuda" href="#ayuda" />
      <AccionMenu texto="Salir" onPulsar={() => console.log('salir')} />
      {/* @ts-expect-error No puede tener los dos */}
      <AccionMenu texto="Mal" href="#x" onPulsar={() => {}} />
      {/* @ts-expect-error Debe tener uno */}
      <AccionMenu texto="Mal" />
    </>
  );
}

Con ?: never las dos ramas declaran los dos campos, así que sí se puede desestructurar en la firma. Y como TypeScript relaciona ambos, tras el if sabe que onPulsar es una función.

Ejercicio 8: Productos validados

Escribe ProductoEsquema con Zod para la respuesta de /api/productos, añade un campo calculado hayStock con .transform y una función que devuelva los productos disponibles. Comprueba el tipo de entrada y el de salida.

Ver solución
// src/ejercicios/productos.ts
import { z } from 'zod';
import { pedir } from '../apiSegura.ts';
import { esquemaPagina } from '../esquemas.ts';

export const ProductoEsquema = z
  .object({
    productoId: z.number().int(),
    nombre: z.string(),
    categoria: z.string(),
    precio: z.number().nonnegative(),
    stock: z.number().int(),
    descatalogado: z.boolean(),
  })
  .transform((p) => ({ ...p, hayStock: p.stock > 0 && !p.descatalogado }));

export type ProductoEntrada = z.input<typeof ProductoEsquema>; // sin hayStock
export type ProductoValidado = z.output<typeof ProductoEsquema>; // con hayStock

export async function productosDisponibles(): Promise<ProductoValidado[]> {
  const pagina = await pedir('/productos?tamano=100', esquemaPagina(ProductoEsquema));
  return pagina.contenido.filter((p) => p.hayStock);
}

// src/ejercicios/prueba-productos.ts
import { productosDisponibles } from './productos.ts';

const disponibles = await productosDisponibles();
console.log(disponibles.length, disponibles[0]?.nombre, disponibles[0]?.hayStock);
18 Portátil UltraBook 14 true

Aquí entrada y salida son tipos distintos: hayStock no está en la API, lo añade la transformación. Por eso z.infer equivale a z.output, el tipo que usa tu aplicación. esquemaPagina y pedir conservan esa información, y productosDisponibles compila sin ninguna aserción.

Ejercicio 9 (propuesto): Tabla con orden

Amplía Tabla<T> para que cada columna con un campo tenga cabecera pulsable que ordene las filas de forma ascendente y descendente. El estado debe ser { campo: keyof T; ascendente: boolean } | null. Como T puede tener cualquier tipo de campo, piensa cómo comparar números, textos y null.

Ejercicio 10 (propuesto): useLocalStorage con esquema

Escribe useLocalStorage(clave, esquema, inicial), que guarde un valor en localStorage y que, al leerlo, lo valide con un esquema de Zod. Si el dato guardado no encaja, porque un cambio de versión ha alterado su forma, debe usar inicial. Comprueba que el tipo del retorno se deduce del esquema.

Ejercicio 11 (propuesto): Enlace polimórfico

Toma Accion de la sección 11 y conviértelo en un Boton polimórfico con la prop as que conserve variante y cargando, de forma que <Boton as="a" href="#x" variante="peligro"> compile y <Boton as="button" href="#x"> no lo haga. Compara cuántas líneas de tipos te ha costado cada versión.


Resumen

Concepto API Idea clave
Comprobación tsc -b Vite no mira los tipos: el compilador va aparte y debe ejecutarse siempre
Props type XProps = {...} Tipa el parámetro; FC no aporta nada
Contenido ReactNode Para children, casi siempre; ReactElement solo si lo inspeccionas
Heredar props ComponentProps<'button'>, Omit Reenvía ...resto a la etiqueta real
Eventos ChangeEvent, MouseEvent, SubmitEvent En línea se deducen; separados, importa el tipo de react
Estado useState<T \| null>(null) <T> solo si el valor inicial no lo describe todo
Referencias useRef<T>(null), Ref<T> ?. antes que !; ref es una prop en React 19
Reducers Unión de acciones, never Olvidar un caso es un error de compilación
Context createContext<T \| null>(null), useX() El hook elimina el null y da un error claro
Uniones discriminadas { tipo: 'a' } \| { tipo: 'b' } Los estados imposibles no compilan
Genéricos function Tabla<T>(...) Un componente para muchos tipos, sin perder ninguno
Polimorfismo as, ElementType Útil en bibliotecas; en aplicaciones suele sobrar
Tipos de API Pagina<T>, Omit, Pick Deriva tipos en lugar de duplicarlos
Datos desconocidos unknown, guardas de tipo unknown obliga a comprobar; any desactiva todo
Zod z.object, z.infer, safeParse Un esquema valida en ejecución y da el tipo
Exhaustividad Record<Union, V>, satisfies Añadir un caso obliga a tratarlo en todas partes