Tema
TS con React y Node
TypeScript brilla especialmente en los dos entornos donde más trabajarás: interfaces con React y APIs con Node/Express. En esta lección verás una introducción práctica a ambos: cómo tipar props, estado y eventos en React, y cómo montar un servidor Express tipado con tsx y un tsconfig para Node. Profundizarás en cada tecnología en sus cursos: React y Node.
Concepto
En ambos casos la idea es la misma que ya conoces: tipar los bordes. En React, los bordes son las props de cada componente (lo que entra) y los eventos. En Node, son las peticiones (req.body, req.params) y las respuestas de tu API. Lo de en medio, TS lo infiere casi todo.
Parte 1: React + TypeScript
Crear el proyecto
bash
npm create vite@latest mi-app-react -- --template react-ts
cd mi-app-react
npm install
npm run devLos componentes usan la extensión .tsx (TypeScript + JSX). Vite ya trae @types/react y un tsconfig listo.
Tipar props
Las props se describen con una interface (o type) y se desestructuran en los parámetros:
tsx
// src/components/TarjetaProducto.tsx
interface TarjetaProductoProps {
nombre: string;
precio: number;
imagen?: string; // opcional
enOferta?: boolean;
onAgregar: (nombre: string) => void; // función que recibe del padre
}
export function TarjetaProducto({
nombre,
precio,
imagen = '/img/sin-foto.png', // valor por defecto
enOferta = false,
onAgregar
}: TarjetaProductoProps) {
return (
<article className="tarjeta">
<img src={imagen} alt={nombre} />
<h3>{nombre}</h3>
<p>
${precio.toFixed(2)} {enOferta && <span>¡Oferta!</span>}
</p>
<button onClick={() => onAgregar(nombre)}>Agregar</button>
</article>
);
}tsx
// Al usarlo, TS revisa cada prop
<TarjetaProducto nombre="Café" precio={320} onAgregar={(n) => console.log(n)} />
<TarjetaProducto nombre="Café" precio="320" />
// ❌ precio debe ser number y falta onAgregarVariantes con uniones de literales
tsx
interface BotonProps {
variante?: 'primario' | 'secundario' | 'peligro';
tamano?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
export function Boton({ variante = 'primario', tamano = 'md', children, onClick }: BotonProps) {
return (
<button className={`btn btn-${variante} btn-${tamano}`} onClick={onClick}>
{children}
</button>
);
}children
React.ReactNode acepta todo lo que React puede renderizar: texto, números, elementos, arrays, null. Es el tipo correcto para children:
tsx
import type { ReactNode } from 'react';
interface TarjetaProps {
titulo: string;
children: ReactNode;
}
export function Tarjeta({ titulo, children }: TarjetaProps) {
return (
<section className="tarjeta">
<h2>{titulo}</h2>
{children}
</section>
);
}
// Uso
<Tarjeta titulo="Resumen">
<p>Tienes 3 pedidos pendientes.</p>
</Tarjeta>Extender props de elementos HTML
Si tu componente envuelve un elemento nativo, hereda todas sus props:
tsx
import type { ComponentProps } from 'react';
// Acepta todo lo que acepta un <input> (type, placeholder, onChange...) + etiqueta
interface CampoProps extends ComponentProps<'input'> {
etiqueta: string;
}
export function Campo({ etiqueta, id, ...resto }: CampoProps) {
return (
<label htmlFor={id}>
{etiqueta}
<input id={id} {...resto} />
</label>
);
}useState
useState infiere el tipo del valor inicial. Solo necesitas el genérico cuando el valor inicial no lo dice todo (por ejemplo, null o un array vacío):
tsx
import { useState } from 'react';
interface Usuario {
id: number;
nombre: string;
}
function Perfil() {
const [contador, setContador] = useState(0); // number (inferido)
const [busqueda, setBusqueda] = useState(''); // string
const [usuario, setUsuario] = useState<Usuario | null>(null); // explícito
const [tareas, setTareas] = useState<string[]>([]); // sin genérico sería never[]
// Estado con unión discriminada (ver lección de Narrowing)
const [estado, setEstado] = useState<
{ tipo: 'cargando' } | { tipo: 'listo'; datos: Usuario[] } | { tipo: 'error'; mensaje: string }
>({ tipo: 'cargando' });
return (
<div>
<p>{usuario ? `Hola, ${usuario.nombre}` : 'Inicia sesión'}</p>
<button onClick={() => setContador((c) => c + 1)}>Clicks: {contador}</button>
<button onClick={() => setTareas((t) => [...t, `Tarea ${t.length + 1}`])}>
Agregar ({tareas.length})
</button>
{estado.tipo === 'error' && <p>{estado.mensaje}</p>}
</div>
);
}Error común
useState([]) sin genérico infiere never[] y no te deja agregar nada: Argument of type 'string' is not assignable to parameter of type 'never'. Indica el tipo: useState<string[]>([]).
Eventos en React
React tiene sus propios tipos de eventos. Lo más fácil: escribe el manejador en línea y TS lo infiere; si lo defines aparte, usa estos tipos:
tsx
import { useState, type ChangeEvent, type FormEvent } from 'react';
function FormularioContacto() {
const [datos, setDatos] = useState({ nombre: '', mensaje: '' });
// Sirve para input y textarea
function manejarCambio(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = e.target;
setDatos((prev) => ({ ...prev, [name]: value }));
}
function manejarEnvio(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
console.log('Enviando', datos);
}
return (
<form onSubmit={manejarEnvio}>
<input name="nombre" value={datos.nombre} onChange={manejarCambio} />
<textarea name="mensaje" value={datos.mensaje} onChange={manejarCambio} />
<button type="submit">Enviar</button>
</form>
);
}Tipos de eventos frecuentes: ChangeEvent<T>, FormEvent<T>, MouseEvent<T>, KeyboardEvent<T>, donde T es el elemento (HTMLInputElement, HTMLButtonElement...).
Buena práctica
Si no sabes qué tipo tiene un evento, escribe el manejador en línea (onChange={(e) => ...}) y pasa el cursor sobre e: VS Code te mostrará el tipo exacto para copiarlo.
Parte 2: Node + TypeScript
Preparar el proyecto
bash
mkdir api-tareas && cd api-tareas
npm init -y
npm i express
npm i -D typescript tsx @types/node @types/express
npx tsc --initEn package.json usa módulos ES y define los scripts:
json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/server.js"
}
}tsconfig para Node
json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}Diferencias con el de frontend: sin DOM en lib (en Node no hay document), module en NodeNext y types: ["node"] para tener process, fs, Buffer, etc.
Error común
Con moduleResolution: "NodeNext", los imports relativos llevan extensión .js aunque el archivo sea .ts: import { tareas } from './datos.js'. Es porque TS no reescribe las rutas y en dist el archivo sí será .js.
Express tipado
ts
// src/tipos.ts
export interface Tarea {
id: number;
titulo: string;
completada: boolean;
}
export type CrearTareaDto = Pick<Tarea, 'titulo'>;
export type ActualizarTareaDto = Partial<Omit<Tarea, 'id'>>;ts
// src/server.ts
import express, { type Request, type Response, type NextFunction } from 'express';
import type { Tarea, CrearTareaDto, ActualizarTareaDto } from './tipos.js';
const app = express();
app.use(express.json());
const tareas: Tarea[] = [{ id: 1, titulo: 'Configurar TypeScript', completada: true }];
let siguienteId = 2;
// GET /tareas → Response<Tarea[]> tipa lo que puedes enviar
app.get('/tareas', (_req: Request, res: Response<Tarea[]>) => {
res.json(tareas);
});
// Request<Params, ResBody, ReqBody>
app.post(
'/tareas',
(req: Request<{}, Tarea | { error: string }, CrearTareaDto>, res) => {
const titulo = req.body.titulo?.trim();
if (!titulo) {
return res.status(400).json({ error: 'El título es obligatorio' });
}
const nueva: Tarea = { id: siguienteId++, titulo, completada: false };
tareas.push(nueva);
res.status(201).json(nueva);
}
);
// Params tipados: req.params.id es string
app.patch(
'/tareas/:id',
(req: Request<{ id: string }, unknown, ActualizarTareaDto>, res: Response) => {
const tarea = tareas.find((t) => t.id === Number(req.params.id));
if (!tarea) return res.status(404).json({ error: 'No encontrada' });
Object.assign(tarea, req.body);
res.json(tarea);
}
);
// Middleware de errores: 4 parámetros
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ error: 'Error interno' });
});
const PORT = Number(process.env.PORT ?? 3000);
app.listen(PORT, () => console.log(`API en http://localhost:${PORT}`));El body no está validado
Escribir Request<{}, unknown, CrearTareaDto> no valida nada: solo le dice a TS qué esperar. Un cliente puede mandar cualquier cosa. En producción valida req.body con Zod (ver Narrowing) antes de confiar en él. Por eso arriba usamos req.body.titulo?.trim() con cuidado.
Extender el Request de Express
Un caso clásico: un middleware de autenticación agrega usuario a req. Gracias a la fusión de interfaces (ver Interfaces y types) puedes extender el tipo de Express:
ts
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
usuario?: { id: number; rol: 'admin' | 'cliente' };
}
}
}
export {};ts
// Ahora req.usuario existe en todos los handlers
app.get('/perfil', (req, res) => {
if (!req.usuario) return res.status(401).json({ error: 'No autenticado' });
res.json({ id: req.usuario.id });
});Repasa Express sin tipos en Express.
Ejercicios
- Crea un componente
Alertacon propstipo('exito' | 'error' | 'aviso'),tituloychildren. - Escribe el estado de un componente que guarda una lista de productos (inicialmente vacía) y un producto seleccionado (inicialmente
null). - Escribe un manejador
alCambiarSelectpara un<select>que guarde el valor en un estado. - En Express, tipa una ruta
GET /productos/:idque devuelva unProductoo{ error: string }con 404.
Ver solución
tsx
// 1
import type { ReactNode, ChangeEvent } from 'react';
import { useState } from 'react';
interface AlertaProps {
tipo: 'exito' | 'error' | 'aviso';
titulo: string;
children: ReactNode;
}
export function Alerta({ tipo, titulo, children }: AlertaProps) {
return (
<div className={`alerta alerta-${tipo}`} role="alert">
<strong>{titulo}</strong>
<div>{children}</div>
</div>
);
}
// 2
interface Producto { id: number; nombre: string; precio: number }
function Catalogo() {
const [productos, setProductos] = useState<Producto[]>([]);
const [seleccionado, setSeleccionado] = useState<Producto | null>(null);
// 3
const [orden, setOrden] = useState('precio');
function alCambiarSelect(e: ChangeEvent<HTMLSelectElement>) {
setOrden(e.target.value);
}
return (
<select value={orden} onChange={alCambiarSelect}>
<option value="precio">Precio</option>
<option value="nombre">Nombre</option>
</select>
);
}ts
// 4
import express, { type Request, type Response } from 'express';
interface Producto { id: number; nombre: string; precio: number }
const productos: Producto[] = [{ id: 1, nombre: 'Café', precio: 320 }];
const app = express();
app.get(
'/productos/:id',
(req: Request<{ id: string }>, res: Response<Producto | { error: string }>) => {
const producto = productos.find((p) => p.id === Number(req.params.id));
if (!producto) return res.status(404).json({ error: 'Producto no encontrado' });
res.json(producto);
}
);Resumen
- React + TS: crea el proyecto con la plantilla
react-tsde Vite; los componentes son.tsx. - Tipa las props con una interface; usa uniones de literales para variantes y
ReactNodeparachildren. useStateinfiere el tipo; usa el genérico connullo arrays vacíos.- Eventos de React:
ChangeEvent,FormEvent,MouseEventcon el elemento como genérico. - Node + TS:
tsx watchpara desarrollo,tscpara build,@types/nodey@types/express. tsconfigpara Node:NodeNext, sinDOM, imports relativos con.js.Request<Params, ResBody, ReqBody>tipa Express, pero no valida: usa Zod en el borde.
Siguiente: Buenas prácticas