En 09-01, al construir la pirámide de pruebas, se colocó en su base el análisis estático y se dijo que era «el nivel más barato»: comprobaciones que se ejecutan sin lanzar la aplicación, sin montar componentes y sin esperar a nada. Allí se cubrió con el linter y se anunció que la otra mitad —los tipos— se vería en esta lección. Y el módulo 10 ha reforzado el argumento sin nombrarlo: en 10-03 has visto contratos por todas partes —qué props pueden cruzar una frontera, qué forma tiene el objeto que devuelve una acción de servidor, qué campos trae una Bicicleta— y todos ellos son hoy implícitos: viven en la cabeza de quien escribió el componente y solo se descubren cuando algo falla en ejecución. Esta lección los hace explícitos y los comprueba mientras escribes. Vas a poner TypeScript en marcha sobre el proyecto Vite de CicloUrbano de forma incremental, modelar el dominio en un único fichero de tipos, y tipar componentes, hooks, eventos, Redux Toolkit, TanStack Query y las respuestas de la API. El objetivo no es aprender TypeScript entero, sino el subconjunto que se usa cada día en React y por qué.

Contenido

  1. El análisis estático como capa más barata
  2. Qué aporta TypeScript y qué cuesta
  3. Puesta en marcha sobre el proyecto Vite
  4. tsconfig.json: las opciones que importan
  5. Estrategia de migración incremental
  6. Fundamentos que se usan a diario
  7. interface frente a type
  8. unknown, any, aserciones y guardas de tipo
  9. Modelar el dominio: src/tipos/dominio.ts
  10. Tipar componentes
  11. Componentes genéricos y por qué ya no se usa React.FC
  12. Tipar hooks
  13. useReducer: donde TypeScript brilla
  14. useContext y hooks propios
  15. Tipar eventos
  16. Redux Toolkit y TanStack Query
  17. Datos externos: validar en el límite con Zod
  18. TypeScript y pruebas: qué detecta cada uno
  19. Cómo leer un mensaje de error largo

  1. El análisis estático como capa más barata

Recupera la pirámide de 09-01 y fíjate en su base:

Nivel Coste de ejecución Qué detecta Cuándo avisa
Análisis estático Milisegundos, en el editor Errores de forma: nombres, tipos, hooks mal usados Mientras escribes
Unitarias Milisegundos Lógica de funciones puras Al ejecutar npm test
Integración Décimas de segundo Comportamiento de componentes Al ejecutar npm test
Extremo a extremo Segundos El sistema completo En la integración continua

La propiedad que hace especial al análisis estático es cuándo avisa. Una prueba te dice que algo está mal después de escribirlo, ejecutarlo y esperar. El tipado te lo dice antes de guardar el fichero, con el cursor todavía en la línea del error. Esa diferencia de segundos es enorme en la práctica, porque el contexto sigue en tu cabeza.

Un ejemplo real de CicloUrbano. Este cambio pasaría todas las pruebas unitarias de validarReserva y aun así rompería la aplicación:

// Alguien renombra el campo en el modelo de datos.
// Antes: { id, modelo, tipo, estado, estacionId, precioHora }
// Ahora: { id, modelo, tipo, estado, estacionId, precioPorHora }

Sin tipos, bicicleta.precioHora devuelve undefined en los doce sitios donde se usa, y la interfaz muestra «NaN €/h». Lo descubres cuando alguien abre esa pantalla. Con tipos, el editor marca los doce sitios en el momento de renombrar el campo, y tsc falla la construcción antes de desplegar.

  1. Qué aporta TypeScript y qué cuesta

Seamos concretos en las dos direcciones.

Lo que aporta:

Ventaja Ejemplo en CicloUrbano
Errores en el editor <TarjetaBicicleta bici={...} /> cuando la prop se llama bicicleta
Contratos explícitos Al abrir TarjetaBicicleta.tsx ves qué props acepta sin leer el cuerpo
Autocompletado real bicicleta. ofrece los seis campos, no una lista vacía
Refactorizaciones seguras Renombrar precioHora actualiza todos los usos y marca los que no encajan
Documentación viva El tipo no se desactualiza como un comentario
Estrechamiento exhaustivo Un switch sobre estado avisa si añades 'reservada' y olvidas un caso

Lo que cuesta:

Coste Realidad
Configuración inicial Una tarde en un proyecto existente
Curva de aprendizaje Real: genéricos, uniones y utilidades llevan semanas
Tipos de terceros Casi todo el ecosistema los trae; alguna biblioteca antigua obliga a escribirlos
Verbosidad Se compensa con la inferencia: la mayor parte no se escribe
Tiempo de compilación Vite no comprueba tipos al servir; tsc --noEmit es un paso aparte

Una precisión importante sobre esa última fila, porque sorprende: Vite no verifica los tipos. Elimina las anotaciones con esbuild y sigue. La comprobación real la hacen tu editor y el comando tsc --noEmit, que hay que ejecutar en la integración continua. Si no lo haces, TypeScript se convierte en decoración.

Y el matiz honesto: TypeScript no sustituye a las pruebas. Garantiza que precioHora es un número, no que el cálculo del precio sea correcto. Son capas distintas y complementarias; el apartado 18 lo detalla.

  1. Puesta en marcha sobre el proyecto Vite

Sobre el CicloUrbano existente, sin tocar nada más:

npm install -D typescript @types/react @types/react-dom

Qué es cada paquete:

  • typescript: el compilador y el servicio de lenguaje que usa tu editor.
  • @types/react y @types/react-dom: las definiciones de tipos de React. React se distribuye sin tipos incluidos, así que van aparte. Deben corresponder a React 19.

Añade el comando de verificación al package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "tipos": "tsc --noEmit",
    "test": "vitest",
    "lint": "eslint ."
  }
}

tsc --noEmit significa «comprueba los tipos pero no generes JavaScript»: de compilar ya se encarga Vite. Encadenarlo antes de vite build convierte un error de tipos en un fallo de construcción, exactamente como el linter en 09-01.

  1. tsconfig.json: las opciones que importan

En la raíz del proyecto:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,

    "allowJs": true,
    "checkJs": false,

    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,

    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"]
}

Las que hay que entender de verdad:

strict: true — activa un grupo de comprobaciones. La más importante es strictNullChecks: sin ella, null y undefined son asignables a cualquier tipo y TypeScript pierde la mitad de su valor.

// Con strict activado:
function buscarBicicleta(id: string): Bicicleta | undefined { /* ... */ }

const bici = buscarBicicleta('bici-002');
console.log(bici.modelo);
//          ~~~~ Error: 'bici' es posiblemente 'undefined'

// Obligado a comprobarlo:
if (bici) console.log(bici.modelo);   // ✅
console.log(bici?.modelo);            // ✅

Ese error es un TypeError: Cannot read properties of undefined que no llegará a producción. Empezar sin strict es la decisión que más se lamenta después.

jsx: "react-jsx" — la transformación moderna. Permite escribir JSX sin importar React en cada fichero. Con "react" (la antigua) el import React from 'react' vuelve a ser obligatorio.

moduleResolution: "bundler" — le dice a TypeScript que resuelva los módulos como lo hace Vite: sin exigir la extensión .js en las importaciones y respetando el campo exports de los paquetes. Es lo correcto en un proyecto con empaquetador.

allowJs: truela clave de la migración incremental. Permite que .js/.jsx y .ts/.tsx convivan y se importen entre sí. Con checkJs: false, los ficheros JavaScript no se comprueban: se migra uno a uno, sin big bang.

noUncheckedIndexedAccess: true — muy recomendable y poco conocida. Hace que acceder por índice devuelva T | undefined, que es la verdad:

const bicicletas: Bicicleta[] = [];
const primera = bicicletas[0];
// Sin la opción: Bicicleta  ← mentira, el array puede estar vacío
// Con la opción: Bicicleta | undefined  ← verdad

console.log(primera.modelo);   // Error con la opción activada
console.log(primera?.modelo);  // ✅

isolatedModules: true — obligatorio con Vite. Obliga a que cada fichero se pueda transpilar por separado, lo que a cambio exige export type / import type para los tipos puros.

skipLibCheck: true — no comprueba los .d.ts de las dependencias. Ahorra mucho tiempo y evita errores en código que no controlas.

Y el fichero de tipos del entorno de Vite, en src/vite-env.d.ts:

/// <reference types="vite/client" />

Sin él, import.meta.env.VITE_URL_API no está tipado.

  1. Estrategia de migración incremental

La regla es una: de las hojas hacia la raíz. Se empieza por el código sin dependencias y se sube.

flowchart TB
    A["1. Tipos del dominio<br/>src/tipos/dominio.ts"] --> B["2. Utilidades puras<br/>validarReserva, clases, disponibilidad"]
    B --> C["3. Hooks propios<br/>useAlternar, useDebounce, useAlmacenLocal"]
    C --> D["4. Componentes hoja<br/>EtiquetaEstado, TarjetaBicicleta"]
    D --> E["5. Componentes compuestos<br/>ListaBicicletas, PanelReserva"]
    E --> F["6. Estado global<br/>almacen, slices, consultas"]
    F --> G["7. Paginas y rutas"]

Por qué ese orden: un componente tipado que importa una utilidad sin tipar recibe any y el tipado no sirve de nada. Si la utilidad ya está tipada, el componente hereda información útil desde el primer momento.

El procedimiento por fichero, mecánico:

  1. Renombrar: .js.ts, .jsx.tsx. Un fichero con JSX debe ser .tsx, sin excepción.
  2. Ejecutar npm run tipos y leer los errores.
  3. Anotar lo mínimo: parámetros de funciones y props de componentes. Los retornos casi siempre se infieren.
  4. Actualizar las importaciones en los ficheros que lo usan (con moduleResolution: "bundler" normalmente no hay que tocar nada).
  5. Ejecutar las pruebas del módulo 9: son la red que hace segura la migración.

Tres consejos de campo:

  • Migra en ramas pequeñas. Un fichero o un grupo pequeño por cambio. Una migración de 80 ficheros de golpe es irrevisable.
  • Prohíbe any desde el primer día, con la regla @typescript-eslint/no-explicit-any. Un any no es un tipo: es un agujero que anula las comprobaciones de todo lo que toca.
  • Si te atascas, usa unknown y estrecha después. Es la salida honesta; any es la deshonesta.

  1. Fundamentos que se usan a diario

El subconjunto de TypeScript que aparece en un proyecto React real es sorprendentemente pequeño.

Primitivos e inferencia. Casi nunca hay que anotar una variable:

const modelo = 'Urbana Clásica';   // string, inferido
const precio = 2.5;                 // number
const disponible = true;            // boolean

// Anotar solo cuando la inferencia no basta:
let estacionSeleccionada: string | null = null;

Uniones de literales, la herramienta más rentable del dominio:

type EstadoBicicleta = 'disponible' | 'alquilada' | 'mantenimiento';

let estado: EstadoBicicleta = 'disponible';
estado = 'averiada';
//        ~~~~~~~~~~ Error: no es asignable a EstadoBicicleta

Compara con estado: string, que acepta 'DISPONIBLE', 'disponible ' con espacio y 'plátano'. La unión de literales convierte un error de datos en un error de compilación, y además hace que el editor autocomplete los tres valores válidos.

Arrays y opcionales:

const modelos: string[] = ['Urbana Clásica', 'Eléctrica Pro'];
const precios: Array<number> = [2.5, 4.0, 5.5];  // sintaxis equivalente

interface Filtros {
  tipo?: TipoBicicleta;    // puede no estar: TipoBicicleta | undefined
  soloDisponibles: boolean;
}

Funciones:

// Parámetros anotados, retorno inferido como number.
function calcularPrecio(precioHora: number, horas: number) {
  return precioHora * horas;
}

// Con valor por defecto y retorno explícito cuando aporta claridad.
function formatearPrecio(precio: number, moneda: string = '€'): string {
  return `${precio.toFixed(2)} ${moneda}`;
}

// Tipo de una función, útil para props.
type AlSeleccionar = (bicicletaId: string) => void;

Genéricos básicos. Un genérico es un tipo que se decide en el punto de uso:

// Sin genérico: se pierde el tipo del elemento.
function primeroMal(lista: unknown[]): unknown { return lista[0]; }

// Con genérico: se conserva.
function primero<T>(lista: T[]): T | undefined {
  return lista[0];
}

const b = primero(bicicletas);   // Bicicleta | undefined
const n = primero([1, 2, 3]);    // number | undefined

T no es magia: es un parámetro del tipo, igual que lista es un parámetro del valor.

  1. interface frente a type

Las dos declaran la forma de un objeto y en el 90 % de los casos son intercambiables.

interface Bicicleta {
  id: string;
  modelo: string;
  precioHora: number;
}

type BicicletaAlt = {
  id: string;
  modelo: string;
  precioHora: number;
};

Diferencias reales:

interface type
Objetos
Uniones ('a' | 'b') No
Tuplas, primitivos, funciones Limitado
Extender extends Intersección &
Fusión de declaraciones No
Mensajes de error Suelen ser más legibles A veces se expanden mucho

La fusión de declaraciones es el punto que decide: dos interface con el mismo nombre se combinan en silencio. Es imprescindible para ampliar tipos de bibliotecas, y peligroso para tipos propios, porque un nombre repetido no da error.

Convención práctica de este curso:

  • interface para la forma de las entidades del dominio, que pueden crecer.
  • type para uniones, alias, props de componentes y todo lo demás.

  1. unknown, any, aserciones y guardas de tipo

any desactiva TypeScript en todo lo que toca:

const datos: any = await respuesta.json();
datos.bicicletas.map((b) => b.precioHora * 2);  // sin comprobar nada
datos.esto.no.existe.tampoco;                    // tampoco da error

unknown es el valor desconocido honesto: no se puede usar sin comprobarlo antes.

const datos: unknown = await respuesta.json();
datos.bicicletas;
//    ~~~~~~~~~~ Error: 'datos' es de tipo 'unknown'

if (typeof datos === 'object' && datos !== null && 'bicicletas' in datos) {
  // aquí ya se puede trabajar con ello
}
any unknown
Se le puede asignar cualquier cosa
Se puede usar sin comprobar (peligroso) No
Propaga la desactivación de comprobaciones No
Cuándo usarlo Casi nunca Datos externos, catch, migraciones

Las aserciones (as) son una promesa, no una comprobación:

const bicicleta = datos as Bicicleta;  // "confía en mí"

En tiempo de ejecución no ocurre nada: si datos no tiene esa forma, el error aparecerá más tarde y más lejos. Usar as con la respuesta de una API es el antipatrón más común, y el apartado 17 lo resuelve.

Las guardas de tipo son la alternativa correcta: funciones que comprueban de verdad y le enseñan a TypeScript el resultado con x is T.

// src/tipos/guardas.ts
import type { Bicicleta, EstadoBicicleta } from './dominio';

const ESTADOS: readonly EstadoBicicleta[] = ['disponible', 'alquilada', 'mantenimiento'];

export function esEstadoBicicleta(valor: unknown): valor is EstadoBicicleta {
  return typeof valor === 'string' && (ESTADOS as readonly string[]).includes(valor);
}

export function esBicicleta(valor: unknown): valor is Bicicleta {
  if (typeof valor !== 'object' || valor === null) return false;
  const b = valor as Record<string, unknown>;
  return (
    typeof b.id === 'string' &&
    typeof b.modelo === 'string' &&
    typeof b.precioHora === 'number' &&
    esEstadoBicicleta(b.estado)
  );
}

Ahora if (esBicicleta(datos)) estrecha el tipo dentro del bloque, y la comprobación existe también en ejecución.

  1. Modelar el dominio: src/tipos/dominio.ts

Este es el fichero que ancla todo lo demás. Un único sitio donde vive la forma del dominio de CicloUrbano.

// src/tipos/dominio.ts

// ---------- Uniones del dominio ----------

export type TipoBicicleta = 'urbana' | 'electrica' | 'carga';
export type EstadoBicicleta = 'disponible' | 'alquilada' | 'mantenimiento';
export type RolUsuario = 'cliente' | 'operario';
export type EstadoReserva = 'activa' | 'finalizada' | 'cancelada';

// Identificadores con marca de tipo: evita pasar un id donde va otro.
export type BicicletaId = string & { readonly __marca: 'BicicletaId' };
export type EstacionId = string & { readonly __marca: 'EstacionId' };

// ---------- Entidades ----------

export interface Bicicleta {
  id: string;
  modelo: string;
  tipo: TipoBicicleta;
  estado: EstadoBicicleta;
  estacionId: string;
  precioHora: number;
}

export interface Estacion {
  id: string;
  nombre: string;
  barrio: string;
  plazas: number;
}

export interface Usuario {
  id: string;
  nombre: string;
  correo: string;
  rol: RolUsuario;
}

export interface Reserva {
  id: string;
  bicicletaId: string;
  usuario: string;
  fechaInicio: string;   // ISO 8601
  horas: number;
  estado: EstadoReserva;
}

// ---------- Tipos derivados ----------

// Lo que se envía al crear una reserva: sin id ni estado, que los pone el servidor.
export type NuevaReserva = Omit<Reserva, 'id' | 'estado'>;

// Errores de validación: una clave por campo del formulario.
export type ErroresReserva = Partial<Record<keyof NuevaReserva | 'general', string>>;

// Una bicicleta con su estación ya resuelta.
export interface BicicletaConEstacion extends Bicicleta {
  estacion: Estacion;
}

Merece la pena detenerse en los tipos derivados, porque son donde TypeScript deja de ser burocracia:

  • Omit<Reserva, 'id' | 'estado'> construye el tipo del formulario a partir del de la entidad. Si mañana Reserva gana un campo codigoDescuento, NuevaReserva lo gana automáticamente. Escribir los dos tipos a mano garantiza que se desincronicen.
  • Partial<Record<keyof NuevaReserva | 'general', string>> dice que ErroresReserva puede tener una clave por cada campo del formulario, más 'general', y que todas son opcionales. Con esto, errores.horaas es un error de compilación: el objeto de errores del módulo 3 pasa a estar verificado.
  • Los identificadores con marca son un patrón avanzado que impide pasar un estacionId donde se espera un bicicletaId. Útil en dominios grandes; opcional aquí.

  1. Tipar componentes

La forma canónica en React 19:

// src/componentes/EtiquetaEstado.tsx
import type { EstadoBicicleta } from '../tipos/dominio';
import estilos from './EtiquetaEstado.module.css';

type Props = {
  estado: EstadoBicicleta;
  compacta?: boolean;
};

const TEXTOS: Record<EstadoBicicleta, string> = {
  disponible: 'Disponible',
  alquilada: 'Alquilada',
  mantenimiento: 'En mantenimiento',
};

function EtiquetaEstado({ estado, compacta = false }: Props) {
  return (
    <span
      className={compacta ? estilos.compacta : estilos.etiqueta}
      data-estado={estado}
    >
      {TEXTOS[estado]}
    </span>
  );
}

export default EtiquetaEstado;

Puntos a destacar:

  • import type para lo que solo son tipos: deja claro que esa importación desaparece al compilar, algo que isolatedModules agradece.
  • Record<EstadoBicicleta, string> obliga a que TEXTOS tenga exactamente las tres claves. Si añades 'reservada' a la unión, este objeto da error hasta que lo completes. Ese efecto en cascada es la mayor ventaja del tipado en un dominio.
  • Los valores por defecto van en la desestructuración, y compacta?: boolean se infiere como boolean dentro del cuerpo.

children se tipa con ReactNode, que es «cualquier cosa renderizable»:

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

type Props = {
  titulo: string;
  children: ReactNode;
  pie?: ReactNode;
};

function Panel({ titulo, children, pie }: Props) {
  return (
    <section>
      <h2>{titulo}</h2>
      <div>{children}</div>
      {pie && <footer>{pie}</footer>}
    </section>
  );
}
Tipo Qué acepta Cuándo usarlo
ReactNode Elementos, cadenas, números, arrays, null Casi siempre
ReactElement Solo un elemento JSX Cuando exiges un único elemento
JSX.Element Similar Como tipo de retorno; rara vez necesario

Extender atributos del DOM con ComponentProps evita reescribir la lista de atributos de un <button>:

// src/componentes/BotonAccion.tsx
import type { ComponentProps } from 'react';
import estilos from './BotonAccion.module.css';

type Props = ComponentProps<'button'> & {
  variante?: 'primaria' | 'secundaria' | 'peligro';
  cargando?: boolean;
};

function BotonAccion({
  variante = 'primaria',
  cargando = false,
  children,
  disabled,
  ...resto
}: Props) {
  return (
    <button
      className={estilos[variante]}
      disabled={disabled || cargando}
      {...resto}
    >
      {cargando ? 'Enviando…' : children}
    </button>
  );
}

export default BotonAccion;

Con esto, <BotonAccion onClick={...} type="submit" aria-label="Reservar" /> está completamente tipado, incluido el onClick con su tipo de evento correcto, sin haber escrito ninguna de esas props.

  1. Componentes genéricos y por qué ya no se usa React.FC

Un componente genérico es el que conserva el tipo de los datos que recibe. Lista es el ejemplo clásico:

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

type Props<T> = {
  elementos: readonly T[];
  claveDe: (elemento: T) => string;
  children: (elemento: T, indice: number) => ReactNode;
  vacio?: ReactNode;
};

function Lista<T>({ elementos, claveDe, children, vacio }: Props<T>) {
  if (elementos.length === 0) {
    return <>{vacio ?? <p>No hay elementos.</p>}</>;
  }

  return (
    <ul>
      {elementos.map((elemento, indice) => (
        <li key={claveDe(elemento)}>{children(elemento, indice)}</li>
      ))}
    </ul>
  );
}

export default Lista;

Uso con dos tipos distintos, ambos verificados:

<Lista
  elementos={bicicletas}
  claveDe={(bici) => bici.id}
  vacio={<Aviso tipo="info">Ninguna bicicleta con ese filtro.</Aviso>}
>
  {(bici) => <TarjetaBicicleta bicicleta={bici} />}
</Lista>

<Lista elementos={estaciones} claveDe={(est) => est.id}>
  {(est) => <TarjetaEstacion estacion={est} />}
</Lista>

TypeScript infiere T = Bicicleta en el primer caso y T = Estacion en el segundo. Dentro de la función hija, bici. autocompleta los seis campos, y bici.nombre da error porque las bicicletas no tienen nombre. Sin genéricos habría que elegir entre any —sin comprobación— o duplicar el componente por cada tipo.

Por qué ya no se usa React.FC

Durante años la convención fue:

// Estilo antiguo: evítalo
const EtiquetaEstado: React.FC<Props> = ({ estado }) => { /* ... */ };

Razones para abandonarlo:

Problema Detalle
Añadía children implícito Hasta React 18 aceptaba children aunque no lo declararas. Ya no, y eso rompió mucho código
No admite genéricos con comodidad Un Lista<T> con React.FC es incómodo
No aporta nada La declaración normal ya tipa props y retorno
Más ruido Un tipo extra por componente sin beneficio

La convención actual, y la de este curso: función normal con las props anotadas.

  1. Tipar hooks

useState infiere el tipo del valor inicial y casi nunca necesita ayuda:

const [horas, setHoras] = useState(1);            // number
const [abierto, setAbierto] = useState(false);    // boolean
const [tipo, setTipo] = useState('todos');        // string

El genérico hace falta en dos situaciones:

// 1. El estado empieza vacío pero luego tendrá otra cosa.
const [seleccionada, setSeleccionada] = useState<Bicicleta | null>(null);
// Sin el genérico sería 'null' y no admitiría una bicicleta.

const [bicicletas, setBicicletas] = useState<Bicicleta[]>([]);
// Sin el genérico sería 'never[]' y no admitiría elementos.

// 2. El valor inicial es más estrecho de lo que quieres.
const [filtro, setFiltro] = useState<TipoBicicleta | 'todos'>('todos');
// Sin el genérico sería 'string' y aceptaría cualquier cosa.

useRef tiene dos usos con tipados distintos:

// A) Referencia a un nodo del DOM: inicial null, la asigna React.
const campoBusqueda = useRef<HTMLInputElement>(null);

useEffect(() => {
  // .current puede ser null: hay que comprobarlo.
  campoBusqueda.current?.focus();
}, []);

<input ref={campoBusqueda} type="search" />

// B) Valor mutable que no provoca renders (05-03).
const contadorRenders = useRef<number>(0);
contadorRenders.current += 1;

// C) Identificador de temporizador.
const temporizador = useRef<ReturnType<typeof setTimeout> | null>(null);

El tipo del elemento hay que acertarlo: HTMLInputElement para <input>, HTMLDivElement para <div>, HTMLButtonElement para <button>. Si no lo sabes, escribe useRef<HTMLElement>(null) y el editor te dirá el correcto en cuanto lo asignes a un elemento concreto.

useMemo y useCallback infieren del cuerpo y rara vez necesitan anotación:

const disponibles = useMemo(
  () => bicicletas.filter((b) => b.estado === 'disponible'),
  [bicicletas]
);  // Bicicleta[]

const manejarSeleccion = useCallback((bicicletaId: string) => {
  setSeleccionada(bicicletas.find((b) => b.id === bicicletaId) ?? null);
}, [bicicletas]);

Fíjate en que el parámetro del useCallback hay que anotarlo: no hay contexto del que inferirlo.

  1. useReducer: donde TypeScript brilla

Este es el caso que convence a los escépticos. Un reductor con las acciones como unión discriminada consigue que TypeScript sepa, dentro de cada case, exactamente qué campos trae esa acción.

// src/reductores/reservaFormulario.ts
import type { NuevaReserva, ErroresReserva } from '../tipos/dominio';

export type EstadoFormulario = {
  datos: NuevaReserva;
  errores: ErroresReserva;
  enviando: boolean;
};

// El campo 'tipo' es el DISCRIMINANTE de la unión.
export type AccionFormulario =
  | { tipo: 'campoCambiado'; campo: keyof NuevaReserva; valor: string }
  | { tipo: 'horasCambiadas'; horas: number }
  | { tipo: 'envioIniciado' }
  | { tipo: 'envioFallado'; errores: ErroresReserva }
  | { tipo: 'envioCompletado' }
  | { tipo: 'reiniciado'; datosIniciales: NuevaReserva };

export function reductorFormulario(
  estado: EstadoFormulario,
  accion: AccionFormulario
): EstadoFormulario {
  switch (accion.tipo) {
    case 'campoCambiado':
      // Aquí TypeScript SABE que existen accion.campo y accion.valor,
      // y que NO existe accion.errores.
      return {
        ...estado,
        datos: { ...estado.datos, [accion.campo]: accion.valor },
        errores: { ...estado.errores, [accion.campo]: undefined },
      };

    case 'horasCambiadas':
      return { ...estado, datos: { ...estado.datos, horas: accion.horas } };

    case 'envioIniciado':
      return { ...estado, enviando: true, errores: {} };

    case 'envioFallado':
      return { ...estado, enviando: false, errores: accion.errores };

    case 'envioCompletado':
      return { ...estado, enviando: false, errores: {} };

    case 'reiniciado':
      return { datos: accion.datosIniciales, errores: {}, enviando: false };

    default: {
      // EXHAUSTIVIDAD: si añades un caso a la unión y olvidas tratarlo,
      // 'accion' deja de ser 'never' y esta línea falla al compilar.
      const noTratada: never = accion;
      throw new Error(`Acción no tratada: ${JSON.stringify(noTratada)}`);
    }
  }
}

El truco del never merece explicación porque es el patrón más útil de esta lección. Dentro del default, TypeScript ha ido descartando todos los miembros de la unión tratados en los case anteriores. Si están todos, lo que queda es never, y la asignación const noTratada: never = accion compila. Si añades { tipo: 'campoTocado'; campo: keyof NuevaReserva } a la unión y no escribes su case, en el default queda ese miembro sin tratar, no es asignable a never y la compilación falla señalando el reductor.

Es decir: el sistema de tipos te obliga a mantener el reductor completo. Ninguna prueba unitaria hace eso sin que alguien la escriba.

Y en el componente:

const [estado, despachar] = useReducer(reductorFormulario, estadoInicial);

despachar({ tipo: 'horasCambiadas', horas: 3 });        // ✅
despachar({ tipo: 'horasCambiadas', horas: '3' });      // ❌ string no es number
despachar({ tipo: 'horasCambiada', horas: 3 });         // ❌ tipo inexistente
despachar({ tipo: 'envioFallado' });                     // ❌ falta 'errores'

Los tres errores se detectan en el editor. En JavaScript, los tres pasaban silenciosamente y producían un estado corrupto.

  1. useContext y hooks propios

El problema clásico del contexto tipado: el valor por defecto. Poner createContext<Sesion | null>(null) obliga a comprobar null en cada consumidor, aunque el proveedor siempre esté presente.

La solución es el hook de acceso que estrecha el tipo y falla ruidosamente si falta el proveedor:

// src/contextos/ContextoSesion.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import type { Usuario } from '../tipos/dominio';

type ValorContextoSesion = {
  usuario: Usuario | null;
  acceder: (correo: string, clave: string) => Promise<void>;
  salir: () => void;
};

// Sin valor por defecto real: undefined marca "no hay proveedor".
const ContextoSesion = createContext<ValorContextoSesion | undefined>(undefined);

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

  async function acceder(correo: string, clave: string) {
    const respuesta = await fetch('/api/acceso', {
      method: 'POST',
      body: JSON.stringify({ correo, clave }),
    });
    setUsuario(await respuesta.json());
  }

  function salir() {
    setUsuario(null);
  }

  return (
    <ContextoSesion.Provider value={{ usuario, acceder, salir }}>
      {children}
    </ContextoSesion.Provider>
  );
}

// El hook de acceso: estrecha el tipo y da un error claro.
export function useSesion(): ValorContextoSesion {
  const valor = useContext(ContextoSesion);
  if (valor === undefined) {
    throw new Error('useSesion debe usarse dentro de <ProveedorSesion>');
  }
  return valor;   // aquí ya NO es undefined
}

Doble beneficio: los consumidores escriben const { usuario } = useSesion() sin comprobar nada, y quien olvide el proveedor recibe un mensaje explícito en vez de un Cannot read properties of undefined. Este patrón ya se recomendaba en 05-04; con tipos, además, se verifica.

Hooks propios que devuelven tuplas necesitan as const:

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

export function useAlternar(inicial = false) {
  const [valor, setValor] = useState(inicial);
  const alternar = useCallback(() => setValor((v) => !v), []);
  const activar = useCallback(() => setValor(true), []);
  const desactivar = useCallback(() => setValor(false), []);

  // Sin 'as const': (boolean | (() => void))[]  ← inservible
  // Con 'as const': readonly [boolean, () => void, () => void, () => void]
  return [valor, alternar, activar, desactivar] as const;
}

Sin as const, TypeScript infiere un array cuyos elementos son la unión de todos los tipos, y al desestructurar const [abierto, alternar] = useAlternar(), abierto sería boolean | (() => void): inutilizable. Con as const infiere una tupla con la posición y el tipo exactos.

Para más de tres valores, devolver un objeto suele ser más legible y no necesita el truco:

export function useAlmacenLocal<T>(clave: string, valorInicial: T) {
  const [valor, setValor] = useState<T>(() => {
    const guardado = window.localStorage.getItem(clave);
    return guardado ? (JSON.parse(guardado) as T) : valorInicial;
  });

  useEffect(() => {
    window.localStorage.setItem(clave, JSON.stringify(valor));
  }, [clave, valor]);

  return [valor, setValor] as const;
}

// Uso: el genérico se infiere del valor inicial.
const [favoritos, setFavoritos] = useAlmacenLocal<string[]>('favoritos', []);

  1. Tipar eventos

Los tipos de eventos de React son genéricos sobre el elemento que los origina.

Evento Tipo Uso típico
onChange en <input> ChangeEvent<HTMLInputElement> Formularios controlados
onChange en <select> ChangeEvent<HTMLSelectElement> SelectorTipo
onSubmit en <form> FormEvent<HTMLFormElement> Envío
onClick en <button> MouseEvent<HTMLButtonElement> Botones
onKeyDown KeyboardEvent<HTMLInputElement> useEventoTeclado
onFocus / onBlur FocusEvent<HTMLInputElement> Validación al salir
import { useState, type ChangeEvent, type FormEvent } from 'react';
import type { TipoBicicleta } from '../tipos/dominio';

function BuscadorBicicletas({ alBuscar }: { alBuscar: (texto: string, tipo: string) => void }) {
  const [texto, setTexto] = useState('');
  const [tipo, setTipo] = useState<TipoBicicleta | 'todos'>('todos');

  function manejarTexto(evento: ChangeEvent<HTMLInputElement>) {
    setTexto(evento.target.value);   // string, garantizado
  }

  function manejarTipo(evento: ChangeEvent<HTMLSelectElement>) {
    setTipo(evento.target.value as TipoBicicleta | 'todos');
  }

  function manejarEnvio(evento: FormEvent<HTMLFormElement>) {
    evento.preventDefault();
    alBuscar(texto, tipo);
  }

  return (
    <form onSubmit={manejarEnvio}>
      <input value={texto} onChange={manejarTexto} />
      <select value={tipo} onChange={manejarTipo}>
        <option value="todos">Todos</option>
        <option value="urbana">Urbana</option>
      </select>
    </form>
  );
}

Cómo encontrar el tipo sin memorizarlo, que es lo que de verdad hace falta. Escribe el manejador en línea y deja que TypeScript infiera; luego pasa el ratón por el parámetro y el editor te dirá el tipo exacto:

<input onChange={(evento) => { /* pasa el ratón por 'evento' */ }} />
// Tooltip: (parameter) evento: React.ChangeEvent<HTMLInputElement>

Ese tipo se copia a la función con nombre. Es el flujo de trabajo real: nadie memoriza estos nombres.

El as de manejarTipo merece un comentario: evento.target.value es siempre string, porque el DOM no sabe de nuestra unión. Aquí la aserción es aceptable porque los <option> los controlamos nosotros; en un caso más estricto usaríamos la guarda esTipoBicicleta del apartado 8.

  1. Redux Toolkit y TanStack Query

Redux Toolkit está diseñado para TypeScript y solo pide dos tipos derivados y dos hooks tipados:

// src/almacen/index.ts
import { configureStore } from '@reduxjs/toolkit';
import sliceSesion from './sliceSesion';
import sliceCatalogo from './sliceCatalogo';
import sliceReservas from './sliceReservas';

export const almacen = configureStore({
  reducer: {
    sesion: sliceSesion,
    catalogo: sliceCatalogo,
    reservas: sliceReservas,
  },
});

// Se DERIVAN del almacén: nunca se escriben a mano.
export type RootState = ReturnType<typeof almacen.getState>;
export type AppDispatch = typeof almacen.dispatch;
// src/almacen/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

A partir de aquí, todo el estado global está tipado sin más esfuerzo:

import { useAppSelector, useAppDispatch } from '../almacen/hooks';
import { filtroCambiado } from '../almacen/sliceCatalogo';

function SelectorTipo() {
  // 'estado' está tipado: autocompleta sesion, catalogo y reservas.
  const filtro = useAppSelector((estado) => estado.catalogo.filtroTipo);
  const despachar = useAppDispatch();

  return (
    <select value={filtro} onChange={(e) => despachar(filtroCambiado(e.target.value))}>
      {/* ... */}
    </select>
  );
}

Y el slice se tipa anotando el estado inicial:

// src/almacen/sliceCatalogo.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { TipoBicicleta } from '../tipos/dominio';

type EstadoCatalogo = {
  filtroTipo: TipoBicicleta | 'todos';
  busqueda: string;
};

const estadoInicial: EstadoCatalogo = { filtroTipo: 'todos', busqueda: '' };

const sliceCatalogo = createSlice({
  name: 'catalogo',
  initialState: estadoInicial,
  reducers: {
    filtroCambiado(estado, accion: PayloadAction<TipoBicicleta | 'todos'>) {
      estado.filtroTipo = accion.payload;
    },
    busquedaCambiada(estado, accion: PayloadAction<string>) {
      estado.busqueda = accion.payload;
    },
  },
});

export const { filtroCambiado, busquedaCambiada } = sliceCatalogo.actions;
export default sliceCatalogo.reducer;

PayloadAction<T> es lo único que hay que anotar: los creadores de acciones y sus tipos se generan solos.

TanStack Query infiere el tipo de data a partir del retorno de queryFn:

// src/consultas/bicicletas.ts
import { useQuery } from '@tanstack/react-query';
import type { Bicicleta } from '../tipos/dominio';

async function obtenerBicicletas(): Promise<Bicicleta[]> {
  const respuesta = await fetch('http://localhost:3001/bicicletas');
  if (!respuesta.ok) throw new Error('No se ha podido cargar el catálogo.');
  return respuesta.json();   // ← aquí está la mentira: ver apartado 17
}

export function useBicicletas() {
  return useQuery({
    queryKey: ['bicicletas'],
    queryFn: obtenerBicicletas,
  });
}

En el componente, data es Bicicleta[] | undefinedundefined mientras carga— y TypeScript obliga a tratar ese caso. Es justamente la comprobación que en JavaScript se olvidaba y producía Cannot read properties of undefined (reading 'map').

  1. Datos externos: validar en el límite con Zod

Y ahora el punto más importante de la lección, el que separa a quien usa TypeScript de quien lo entiende.

La respuesta de una API no está tipada. respuesta.json() devuelve any. Escribir Promise<Bicicleta[]> en la firma no comprueba nada: es una promesa tuya al compilador, no una verificación.

Si json-server devuelve precioHora como cadena, o el campo se renombra en el servidor, TypeScript no dirá nada y el fallo aparecerá lejos, en el componente que hace precioHora.toFixed(2).

La solución es validar en el límite: comprobar la forma de los datos una vez, en el punto donde entran a la aplicación, y a partir de ahí confiar en los tipos.

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

export const esquemaTipoBicicleta = z.enum(['urbana', 'electrica', 'carga']);
export const esquemaEstadoBicicleta = z.enum([
  'disponible', 'alquilada', 'mantenimiento',
]);

export const esquemaBicicleta = z.object({
  id: z.string(),
  modelo: z.string().min(1),
  tipo: esquemaTipoBicicleta,
  estado: esquemaEstadoBicicleta,
  estacionId: z.string(),
  precioHora: z.number().positive(),
});

export const esquemaListaBicicletas = z.array(esquemaBicicleta);

// EL TIPO SE DERIVA DEL ESQUEMA: una única fuente de verdad.
export type Bicicleta = z.infer<typeof esquemaBicicleta>;
export type TipoBicicleta = z.infer<typeof esquemaTipoBicicleta>;
export type EstadoBicicleta = z.infer<typeof esquemaEstadoBicicleta>;

z.infer es la pieza clave: el tipo se deriva del esquema, no se escribe aparte. Es imposible que se desincronicen.

Y la consulta pasa a validar de verdad:

// src/consultas/bicicletas.ts
import { useQuery } from '@tanstack/react-query';
import { esquemaListaBicicletas } from '../tipos/esquemas';

async function obtenerBicicletas() {
  const respuesta = await fetch('http://localhost:3001/bicicletas');
  if (!respuesta.ok) throw new Error('No se ha podido cargar el catálogo.');

  const datos: unknown = await respuesta.json();

  // Valida en EJECUCIÓN y devuelve un valor tipado. Si no encaja, lanza.
  return esquemaListaBicicletas.parse(datos);
}

Comparación de los dos enfoques:

as Bicicleta[] o Promise<Bicicleta[]> esquema.parse(datos)
Comprobación en compilación
Comprobación en ejecución No
Si la API cambia Fallo silencioso, lejos del origen Error inmediato con el campo exacto
Coste Cero Unos microsegundos por respuesta

Cuando la validación falla, el mensaje señala el problema con precisión:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": [1, "precioHora"],
    "message": "Expected number, received string"
  }
]

«El elemento 1 tiene precioHora como cadena en lugar de número». Eso es diagnóstico, no adivinación.

Dónde conviene aplicar esta validación en CicloUrbano:

Límite ¿Validar? Motivo
Respuestas de la API El servidor puede cambiar sin avisar
localStorage (useAlmacenLocal) Puede tener datos de una versión anterior
Parámetros de la URL (?tipo=) Los escribe el usuario
import.meta.env Sí, al arrancar Falta una variable → fallo inmediato y claro
Props entre componentes propios No Ya las comprueba TypeScript
Estado interno No Nunca sale del programa

Zod también sirve, con el mismo esquema, para validar formularios: validarReserva del módulo 3 puede reescribirse como esquema y compartir las reglas entre el cliente y el servidor —incluidas las acciones de servidor de 10-03—.

  1. TypeScript y pruebas: qué detecta cada uno

Vuelta a la pirámide, ahora con las dos capas comparadas.

Situación ¿Lo detecta TypeScript? ¿Lo detectan las pruebas?
Prop mal escrita (bici en vez de bicicleta) ✅ Al escribir ⚠️ Solo si hay una prueba de ese componente
Campo renombrado en el dominio ✅ En los 12 usos ⚠️ Solo donde haya cobertura
Un case nuevo sin tratar en el reductor ✅ Con el truco del never ❌ Salvo prueba específica
Olvidar comprobar undefined ✅ Con strict ⚠️ Solo si la prueba usa ese caso
El cálculo del precio está mal ✅ Prueba unitaria
El botón no se deshabilita en mantenimiento ✅ Testing Library
La reserva no llega a la API ✅ Prueba e2e
El texto del error es incomprensible ✅ Prueba de integración
La API devuelve otra forma de datos ❌ (sí con Zod) ✅ Si hay contrato en MSW
Un bucle infinito en useEffect ✅ La prueba se cuelga

La conclusión se lee en las dos columnas: TypeScript comprueba la forma; las pruebas comprueban el comportamiento. Sustituir una por otra no funciona en ninguna dirección.

Lo que sí ocurre es que TypeScript elimina una categoría entera de pruebas triviales. Ya no hace falta comprobar «que el componente no reviente si bicicletas es undefined»: con strict, eso no compila. Ese tiempo se invierte en probar el comportamiento, que es lo que importa.

Y las pruebas también se tipan. Con .test.tsx, Testing Library y MSW ganan comprobación de tipos:

// src/pruebas/TarjetaBicicleta.test.tsx
import { render, screen } from '@testing-library/react';
import TarjetaBicicleta from '../componentes/TarjetaBicicleta';
import type { Bicicleta } from '../tipos/dominio';

const BICI: Bicicleta = {
  id: 'bici-002',
  modelo: 'Eléctrica Pro',
  tipo: 'electrica',
  estado: 'alquilada',
  estacionId: 'est-01',
  precioHora: 4.0,
};

test('muestra el modelo y el estado de la bicicleta', () => {
  render(<TarjetaBicicleta bicicleta={BICI} />);
  expect(screen.getByText('Eléctrica Pro')).toBeInTheDocument();
  expect(screen.getByText('Alquilada')).toBeInTheDocument();
});

Ventaja concreta: si Bicicleta gana un campo obligatorio, el objeto de prueba deja de compilar. Los datos ficticios desactualizados —una plaga clásica en las suites grandes— dejan de ser posibles.

  1. Cómo leer un mensaje de error largo

Los errores de TypeScript asustan por su longitud. La técnica para leerlos es siempre la misma.

Type '{ bicicleta: { id: string; modelo: string; tipo: string; estado: string;
estacionId: string; precioHora: number; }; }' is not assignable to type
'IntrinsicAttributes & Props'.
  Types of property 'bicicleta' are not assignable.
    Type '{ id: string; modelo: string; tipo: string; estado: string; ... }' is
    not assignable to type 'Bicicleta'.
      Types of property 'tipo' are not assignable.
        Type 'string' is not assignable to type 'TipoBicicleta'.

Léelo de abajo arriba. La última línea es la causa; las de arriba son el camino hasta ella.

  • Última línea: 'string' no es asignable a 'TipoBicicleta'. Ahí está el problema real.
  • Penúltima: ocurre en la propiedad tipo.
  • Anteriores: dentro del objeto pasado a la prop bicicleta.

Diagnóstico: en algún sitio se creó una bicicleta cuyo tipo es string en vez de la unión. Causa habitual: un objeto literal sin anotar, o datos venidos de la API sin validar.

// Origen del problema
const bici = { id: 'bici-002', tipo: 'electrica', /* ... */ };
// 'tipo' se infiere como 'string'

// Solución A: anotar el objeto
const bici: Bicicleta = { id: 'bici-002', tipo: 'electrica', /* ... */ };

// Solución B: as const en el valor
const bici = { id: 'bici-002', tipo: 'electrica' as const, /* ... */ };

Errores frecuentes de quien empieza, con su traducción:

Mensaje Qué significa realmente
Object is possibly 'undefined' Falta comprobar antes de usar: ?. o un if
Property 'x' does not exist on type 'y' Nombre mal escrito, o el tipo es más estrecho de lo que creías
Type 'string' is not assignable to type '"a" | "b"' Se usó string donde va una unión de literales
Argument of type 'X' is not assignable to parameter of type 'Y' Argumento con la forma equivocada; compara campo a campo
Cannot find module './X' or its type declarations Falta la extensión, o el fichero aún no está migrado
Type 'never[]' is not assignable... useState([]) sin genérico
JSX element type does not have any construct signatures Se usó un valor no componente como componente

Errores Comunes y Consejos

  • Empezar sin strict. Sin strictNullChecks, TypeScript no detecta la mayor parte de los fallos reales. Actívalo desde el principio; añadirlo a un proyecto grande después es doloroso.
  • Usar any para salir del paso. Anula las comprobaciones en cascada. Si no sabes el tipo, unknown y estrecha.
  • Confiar en as con datos de la API. No comprueba nada en ejecución. Valida en el límite con Zod y deriva el tipo del esquema.
  • Creer que Vite verifica los tipos. No lo hace. tsc --noEmit en el build y en la integración continua, o el tipado es decorativo.
  • Escribir Bicicleta y esquemaBicicleta por separado. Se desincronizan. z.infer deriva uno del otro.
  • Escribir RootState a mano. Se deriva con ReturnType<typeof almacen.getState>. Escrito a mano, se desactualiza al añadir un slice.
  • Olvidar as const en un hook que devuelve tupla. El tipo se convierte en un array de uniones y la desestructuración deja de funcionar.
  • Usar React.FC. Convención obsoleta: no aporta nada y entorpece los genéricos.
  • Renombrar a .ts un fichero con JSX. Debe ser .tsx, o el compilador interpreta <Componente> como una aserción de tipo y produce errores incomprensibles.
  • Migrar de la raíz hacia las hojas. Al revés: primero los tipos del dominio, luego utilidades y hooks, después componentes. Si no, todo lo importado llega como any.
  • Consejo: pasa el ratón por encima. El editor te dice el tipo inferido de cualquier expresión. Es más rápido y más fiable que buscar en la documentación.
  • Consejo: activa noUncheckedIndexedAccess. Es incómoda las dos primeras semanas y evita una familia entera de errores en producción.

Ejercicios

Ejercicio 1. Tipa este componente de CicloUrbano, que hoy está en JavaScript. Define el tipo de props, usa los tipos del dominio y corrige los dos problemas de tipos que aparecerán al hacerlo.

// src/componentes/PanelReserva.jsx
function PanelReserva({ bicicleta, horas, alConfirmar, descuento }) {
  const total = bicicleta.precioHora * horas * (1 - descuento);
  const puedeReservar = bicicleta.estado === 'disponible';

  return (
    <aside>
      <p>Total estimado: {total.toFixed(2)} €</p>
      <button onClick={() => alConfirmar(bicicleta.id, horas)} disabled={!puedeReservar}>
        Reservar {horas} h
      </button>
    </aside>
  );
}

Ejercicio 2. Escribe el reductor tipado de PanelReservas, que gestiona la lista de reservas del usuario. Debe soportar: cargar la lista, marcar una reserva como cancelada, filtrar por estado y registrar un error. Define EstadoReservas y la unión discriminada AccionReservas, incluye el caso never de exhaustividad, y demuestra con un ejemplo qué ocurre al añadir una acción nueva sin tratarla.

Ejercicio 3. La API de CicloUrbano ha empezado a devolver plazas como cadena en algunas estaciones y barrio a veces falta. Escribe el esquema de Zod de Estacion que: valide los campos, convierta plazas a número aunque llegue como cadena, dé a barrio el valor 'Sin asignar' cuando no venga, y derive el tipo Estacion. Escribe también la función de consulta que lo usa y explica qué pasa exactamente cuando llega un dato inválido.

Soluciones

Solución 1.

// src/componentes/PanelReserva.tsx
import type { Bicicleta } from '../tipos/dominio';

type Props = {
  bicicleta: Bicicleta;
  horas: number;
  alConfirmar: (bicicletaId: string, horas: number) => void;
  descuento?: number;   // opcional: no siempre hay descuento
};

function PanelReserva({ bicicleta, horas, alConfirmar, descuento = 0 }: Props) {
  const total = bicicleta.precioHora * horas * (1 - descuento);
  const puedeReservar = bicicleta.estado === 'disponible';

  return (
    <aside>
      <p>Total estimado: {total.toFixed(2)} €</p>
      <button
        type="button"
        onClick={() => alConfirmar(bicicleta.id, horas)}
        disabled={!puedeReservar}
      >
        Reservar {horas} h
      </button>
    </aside>
  );
}

export default PanelReserva;

Los dos problemas que aparecen al tipar:

  1. descuento puede no pasarse. En JavaScript, omitirla hacía 1 - undefined = NaN y el total se mostraba como «NaN €», un fallo silencioso. Al tipar, o se declara obligatoria o se le da un valor por defecto. Aquí lo correcto es descuento = 0.
  2. alConfirmar necesita una firma explícita. Sin ella sería Function, y cualquier llamada con argumentos equivocados compilaría. Con (bicicletaId: string, horas: number) => void, invertir el orden de los argumentos es un error de compilación.

Como extra, bicicleta.estado === 'disponible' queda verificado contra la unión: escribir 'Disponible' daría error, porque no es un valor posible de EstadoBicicleta.

Solución 2.

// src/reductores/reservas.ts
import type { Reserva, EstadoReserva } from '../tipos/dominio';

export type EstadoReservas = {
  reservas: Reserva[];
  filtro: EstadoReserva | 'todas';
  cargando: boolean;
  error: string | null;
};

export type AccionReservas =
  | { tipo: 'cargaIniciada' }
  | { tipo: 'cargaCompletada'; reservas: Reserva[] }
  | { tipo: 'cargaFallada'; mensaje: string }
  | { tipo: 'reservaCancelada'; reservaId: string }
  | { tipo: 'filtroCambiado'; filtro: EstadoReserva | 'todas' };

export const estadoInicial: EstadoReservas = {
  reservas: [],
  filtro: 'todas',
  cargando: false,
  error: null,
};

export function reductorReservas(
  estado: EstadoReservas,
  accion: AccionReservas
): EstadoReservas {
  switch (accion.tipo) {
    case 'cargaIniciada':
      return { ...estado, cargando: true, error: null };

    case 'cargaCompletada':
      return { ...estado, cargando: false, reservas: accion.reservas };

    case 'cargaFallada':
      return { ...estado, cargando: false, error: accion.mensaje };

    case 'reservaCancelada':
      return {
        ...estado,
        reservas: estado.reservas.map((reserva) =>
          reserva.id === accion.reservaId
            ? { ...reserva, estado: 'cancelada' }
            : reserva
        ),
      };

    case 'filtroCambiado':
      return { ...estado, filtro: accion.filtro };

    default: {
      const noTratada: never = accion;
      throw new Error(`Acción no tratada: ${JSON.stringify(noTratada)}`);
    }
  }
}

Qué ocurre al añadir una acción sin tratarla. Si amplías la unión:

export type AccionReservas =
  | /* ... los cinco de antes ... */
  | { tipo: 'reservaAmpliada'; reservaId: string; horasExtra: number };

y no escribes su case, en el default el tipo de accion ya no se ha reducido a never, sino a { tipo: 'reservaAmpliada'; ... }, y la compilación falla:

Type '{ tipo: "reservaAmpliada"; reservaId: string; horasExtra: number; }'
is not assignable to type 'never'.

El error apunta a la línea exacta del reductor. Es una comprobación de completitud gratuita que ninguna prueba proporciona sin escribirla explícitamente. Fíjate además en { ...reserva, estado: 'cancelada' }: si escribieras 'cancelado', TypeScript lo rechazaría por no pertenecer a EstadoReserva.

Solución 3.

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

export const esquemaEstacion = z.object({
  id: z.string(),
  nombre: z.string().min(1),

  // barrio puede faltar: se le da un valor por defecto.
  barrio: z.string().default('Sin asignar'),

  // plazas puede llegar como número o como cadena numérica.
  plazas: z.union([
    z.number().int().nonnegative(),
    z.string().regex(/^\d+$/).transform(Number),
  ]),
});

export const esquemaListaEstaciones = z.array(esquemaEstacion);

// El tipo derivado ya tiene plazas: number y barrio: string (no opcional).
export type Estacion = z.infer<typeof esquemaEstacion>;
// src/consultas/estaciones.ts
import { useQuery } from '@tanstack/react-query';
import { esquemaListaEstaciones } from '../tipos/esquemas';

async function obtenerEstaciones() {
  const respuesta = await fetch('http://localhost:3001/estaciones');
  if (!respuesta.ok) throw new Error('No se han podido cargar las estaciones.');

  const datos: unknown = await respuesta.json();
  return esquemaListaEstaciones.parse(datos);
}

export function useEstaciones() {
  return useQuery({ queryKey: ['estaciones'], queryFn: obtenerEstaciones });
}

Qué pasa cuando llega un dato inválido —por ejemplo, plazas: "veinte"—:

  1. parse lanza un ZodError con la ruta exacta: [1, "plazas"], es decir, la segunda estación.
  2. Al lanzarse dentro de queryFn, TanStack Query lo trata como un error de consulta: isError pasa a true y data sigue siendo undefined.
  3. El componente muestra su interfaz de error —o el LimiteDeError si se usa useSuspenseQuery, según 10-03— en lugar de pintar NaN.
  4. El error queda registrado con el campo culpable, así que el diagnóstico es inmediato.

Dos matices sobre el diseño de este esquema. default('Sin asignar') hace que el tipo derivado tenga barrio: string no opcional, así que los componentes no necesitan comprobar undefined: la incertidumbre se ha resuelto en el límite, que es exactamente el objetivo. Y si prefirieras tolerar errores parciales en lugar de fallar la consulta entera, safeParse devuelve { success, data, error } sin lanzar, lo que permite descartar las estaciones inválidas y mostrar el resto.

Conclusión

Esta lección ha completado la base de la pirámide que 09-01 dejó a medias. El linter cubría el estilo y los errores de forma del código; TypeScript cubre los contratos, y lo hace en el momento más barato posible: mientras escribes, con el cursor en la línea del error.

De la puesta en marcha, lo esencial es que TypeScript entra en un proyecto existente sin reescribirlo: typescript más @types/react y @types/react-dom, un tsconfig.json con strict: true —innegociable—, jsx: "react-jsx", moduleResolution: "bundler", allowJs: true para la convivencia y noUncheckedIndexedAccess para que el acceso por índice diga la verdad. Y una advertencia que decide si todo esto sirve para algo: Vite no comprueba los tipos, así que tsc --noEmit tiene que estar en el build y en la integración continua. La migración va de las hojas a la raíz —dominio, utilidades, hooks, componentes hoja, compuestos, estado global, páginas—, en ramas pequeñas, con las pruebas del módulo 9 haciendo de red.

Del lenguaje, el subconjunto que se usa a diario es corto: primitivos con inferencia, uniones de literales —la herramienta más rentable del dominio—, opcionales, arrays, genéricos básicos, interface para entidades y type para todo lo demás, y unknown en lugar de any con guardas de tipo x is T para estrechar de verdad. Queda fijado src/tipos/dominio.ts con Bicicleta, Estacion, Usuario, Reserva, TipoBicicleta, EstadoBicicleta, RolUsuario y EstadoReserva, más los derivados NuevaReserva con Omit y ErroresReserva con Partial<Record<...>>, que hacen que ampliar una entidad propague el cambio a todo lo que depende de ella.

En React, las piezas quedan así: props con type y valores por defecto en la desestructuración; children con ReactNode; atributos del DOM heredados con ComponentProps<'button'>; componentes genéricos como Lista<T> que conservan el tipo del elemento; y nada de React.FC. En hooks, useState infiere salvo cuando empieza en null o en []; useRef<HTMLInputElement>(null) para el DOM y useRef<number>(0) para valores mutables; useContext sin valor por defecto más un hook de acceso que estrecha el tipo y falla ruidosamente si falta el proveedor; y as const en los hooks propios que devuelven tuplas. El caso donde TypeScript brilla de verdad es useReducer con las acciones como unión discriminada: dentro de cada case se conocen los campos exactos, y el truco del never en el default convierte «he olvidado tratar una acción» en un error de compilación. Los eventos no se memorizan: se escriben en línea, se lee el tipo que muestra el editor y se copia.

En las bibliotecas, Redux Toolkit solo pide derivar RootState y AppDispatch del almacén y crear useAppSelector/useAppDispatch tipados; TanStack Query infiere data del queryFn y obliga a tratar el undefined de la carga. Y el punto que más cambia la forma de trabajar: la respuesta de una API no está tipada, así que se valida en el límite con Zod y el tipo se deriva del esquema con z.infer, en una única fuente de verdad. Todo lo que entra desde fuera —API, localStorage, parámetros de la URL, variables de entorno— pasa por ese filtro; lo que circula entre componentes propios ya lo comprueba TypeScript.

Y el equilibrio final, que es la respuesta a la pregunta implícita de 09-01: TypeScript comprueba la forma; las pruebas comprueban el comportamiento. Los tipos detectan la prop mal escrita, el campo renombrado en sus doce usos y el case que falta; no detectan que el cálculo del precio esté mal, que el botón no se deshabilite en mantenimiento o que el mensaje de error sea incomprensible. Lo que sí hacen es eliminar una categoría entera de pruebas triviales, liberando ese tiempo para probar lo que importa.

Con esto, CicloUrbano tiene cerradas las cuatro capas de calidad y las dos arquitecturas de renderizado. Queda un salto de terreno más, y es el último del módulo: llevar todo esto fuera del navegador. Los componentes, las props, el estado, los hooks, el contexto, los slices de Redux, las consultas de TanStack Query y los tipos que acabas de escribir se pueden reutilizar en una aplicación móvil nativa, donde no hay DOM, ni CSS, ni etiquetas HTML. La próxima lección es React Native: Creación de Aplicaciones Móviles.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados