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
tsconfigde la plantilla y por qué Vite no comprueba tipos - Tipar props,
children, valores por defecto y funciones de retorno, y elegir bien entreReactNode,ReactElementyJSX.Element - Reutilizar los tipos de HTML y de otros componentes con
ComponentProps,OmityComponentRef - Tipar eventos con
ChangeEvent,MouseEvent,KeyboardEventySubmitEvent, y evitar la trampa de los tipos globales del DOM - Tipar estado, referencias, reducers y Context sin recurrir a
anyni 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
asy 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/react19.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.
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
tsconfiglleva"strict": true. Todo lo que se explica en este módulo cuenta con ese modo: sin él,nullencaja 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.
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
JSXcomo global:JSX.Elementda el errorCannot find namespace 'JSX'. Si usas una versión anterior: elJSXglobal sigue existiendo, peroReact.JSX.Elementfunciona 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,
ReactElementsin parámetros tieneprops: unknown, cuando antes eraany. Si usas una versión anterior: el código compila sin indicar el tipo, pero declararReactElement<{ 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>
);
}

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/react19.2.10 existenSubmitEventySubmitEventHandler, yFormEventestá marcado como obsoleto. Si usas una versión anterior: esos tipos no existen, así que escribeFormEvent<HTMLFormElement>enonSubmit, 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
submitde React exponesubmittercon el botón que lo envió. Si usas una versión anterior:e.submittercompila con@types/react19.2.10 o superior, pero en ejecución valeundefined; leee.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.
useRefnecesita un argumento yRefObjectdeja de ser de solo lectura, aunque se cree connull. Si usas una versión anterior:useRef<T>()sin argumento compila, yuseRef<T>(null)devuelve un objeto con.currentde solo lectura; para escribir en él declarauseRef<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
refcomo una prop normal y se tipa conRef<T>. No existe en React 18.3 ni anteriores, donderefno llega a las props. Si usas una versión anterior: envuelve el componente conforwardRef, como se muestra a continuación; en esas versiones usaComponentPropsWithoutRefpara 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
refpuede 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 recibenull.
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>
);
}

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
useReducercambian: ahora sonuseReducer<S, A>, dondeAes una tupla de argumentos, y apareceActionDispatch. Si usas una versión anterior: se escribeuseReducer<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>, yuse(Contexto)lee el contexto. No existen en React 18.3 ni anteriores. Si usas una versión anterior: escribe<SesionContexto.Provider value={valor}>yuseContext(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 };
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
ifdefensivo 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>
</>
);
}

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:
C extends ElementTypeadmite cualquier etiqueta o componente. Con= 'span', si no hayasse asumespan.- TypeScript deduce
Cdel valor deas: en<Texto as="a">,Ces'a'. ComponentPropsWithRef<C>da las props de esa etiqueta, yOmitquita las que chocan con las nuestras.- Dentro del componente anotamos
EtiquetacomoElementTypea secas, porque TypeScript no puede razonar sobre JSX con unCsin 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.unknownes el opuesto seguro deany: admite cualquier valor, pero no te deja usarlo hasta que compruebes qué es.esErrorApi. Una guarda de tipo para un objeto. Conin, TypeScript aprende que la propiedad existe y permite comprobar su tipo.catch (e: unknown). En una cláusulacatchel error esunknown, porque en JavaScript se puede lanzar cualquier cosa. Antes de usarlo hay que estrecharlo coninstanceof.
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.
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 entipos.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()yz.iso.date(). PedidoEsquemano enumera todos los campos del pedido. Por defecto,z.objectacepta el objeto y elimina del resultado las claves que no declara, así que tu aplicación solo ve lo que dice el esquema.z.enumda 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>
);
}

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.jsoncuando quieras prohibirlo en tu proyecto.
Depurar tipos
Cuando no entiendas lo que ocurre:
- Pasa el ratón. VS Code muestra el tipo deducido de cualquier variable. Casi siempre basta.
- Pulsa F12 sobre un tipo para ir a su declaración. Los
.d.tsdenode_modules/@types/react/index.d.tsse leen bien, y sus comentarios explican mucho, incluidos los avisos de obsoleto. - Asigna a un tipo concreto. Escribe
const prueba: number = valor;, y el error te dirá el tipo real. - Reinicia el servidor de TypeScript si el editor muestra errores que
npx tsc -bno da: en la paleta de VS Code, TypeScript: Restart TS Server. - Comprueba
npm ls @types/react. Muchos errores extraños vienen de copias repetidas o de versiones que no corresponden a tureact.
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 |