Skip to content

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 dev

Los 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 onAgregar

Variantes 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 --init

En 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 ​

  1. Crea un componente Alerta con props tipo ('exito' | 'error' | 'aviso'), titulo y children.
  2. Escribe el estado de un componente que guarda una lista de productos (inicialmente vacía) y un producto seleccionado (inicialmente null).
  3. Escribe un manejador alCambiarSelect para un <select> que guarde el valor en un estado.
  4. En Express, tipa una ruta GET /productos/:id que devuelva un Producto o { 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-ts de Vite; los componentes son .tsx.
  • Tipa las props con una interface; usa uniones de literales para variantes y ReactNode para children.
  • useState infiere el tipo; usa el genérico con null o arrays vacíos.
  • Eventos de React: ChangeEvent, FormEvent, MouseEvent con el elemento como genérico.
  • Node + TS: tsx watch para desarrollo, tsc para build, @types/node y @types/express.
  • tsconfig para Node: NodeNext, sin DOM, imports relativos con .js.
  • Request<Params, ResBody, ReqBody> tipa Express, pero no valida: usa Zod en el borde.

Siguiente: Buenas prácticas

Academia Web — aprende, copia y construye.