🎯 Meta: eliminar la fuente de bugs más tonta del fullstack: que el backend diga
precio: numbery el frontend espereprice: string. Verás dos estrategias — compartir tipos en un monorepo TypeScript y generar tipos desde OpenAPI para cualquier lenguaje — y cuándo usar cada una.Versiones: pnpm 10 · Turborepo 2 · Zod 4 · openapi-typescript 7 · TS 7.
K.1 · El problema: dos verdades sobre los mismos datos
backend/src/productos/entity.ts frontend/src/types.ts
interface Producto { interface Producto {
id: number; id: number;
nombre: string; nombre: string;
precio: number; precio: string; ← 💥 desincronizado
creadoEn: Date; creadoEn: string; ← (¿y esto? JSON no tiene Date)
} }Cada campo duplicado es un bug en potencia que el compilador no puede ver porque son dos proyectos distintos. Las dos curas:
| Estrategia | Cómo | Cuándo |
|---|---|---|
| Compartir (monorepo) | Un paquete shared que ambos importan | Back y front en TypeScript (NestJS/Elysia + React/Vue) |
| Generar (contrato) | OpenAPI del backend → tipos del frontend | Backend en Go/Python/PHP/Java… o equipos separados |
K.2 · Monorepo con pnpm workspaces
Un monorepo = varios paquetes en un repositorio, con dependencias entre ellos resueltas en local. La herramienta estándar: pnpm workspaces (rápido, estricto con las dependencias).
cantina/
├── pnpm-workspace.yaml
├── package.json ← scripts raíz, devDeps comunes
├── apps/
│ ├── api/ ← NestJS (cap. 06)
│ └── web/ ← React (cap. 17)
└── packages/
└── shared/ ← tipos + esquemas + utilidades comunes# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// packages/shared/package.json
{
"name": "@cantina/shared",
"version": "0.0.0",
"type": "module",
"exports": { ".": "./src/index.ts" }, // exportamos TS fuente: los bundlers modernos lo comen
"dependencies": { "zod": "^4.0.0" }
}
// apps/web/package.json y apps/api/package.json
{ "dependencies": { "@cantina/shared": "workspace:*" } }pnpm install # enlaza todo
pnpm --filter api dev # correr un paquete concreto
pnpm -r test # -r = en todos los paquetesCatálogos: una versión de cada dependencia, un solo lugar
Con varios paquetes es fácil que api pida zod@^3.24 y shared pida zod@^4.0 sin darte cuenta — dos versiones instaladas, comportamientos distintos según quién importe qué. Los catálogos de pnpm centralizan la versión en un solo sitio:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
zod: ^4.0.0
typescript: ^7.0.0
react: # catálogo con nombre, para grupos con reglas distintas
react: ^19.2.0
react-dom: ^19.2.0// packages/shared/package.json
{ "dependencies": { "zod": "catalog:" } }
// apps/web/package.json
{ "dependencies": { "react": "catalog:react", "react-dom": "catalog:react" } }💡 Un
pnpm up zoden el catálogo actualiza la versión para todos los paquetes a la vez, ypnpm publish/pnpm packsustituyencatalog:por el número real (igual que hacen conworkspace:*) — el consumidor final nunca ve ese protocolo. Sin catálogos, "por qué el build dewebusa una versión distinta de Zod queapi" es un bug que se descubre en producción.
K.3 · El paquete shared: Zod como única fuente de verdad
El truco maestro: no compartas solo tipos — comparte esquemas Zod, que dan tipo estático Y validación en runtime con una sola definición:
// packages/shared/src/producto.ts
import { z } from 'zod';
export const ProductoSchema = z.object({
id: z.number().int().positive(),
nombre: z.string().min(2).max(120),
precio: z.number().positive(),
categoria: z.enum(['perifericos', 'audio']),
creadoEn: z.iso.datetime(), // 🧠 en JSON las fechas son strings ISO: modélalo así
});
export const CrearProductoSchema = ProductoSchema.omit({ id: true, creadoEn: true });
// El tipo se DERIVA del esquema — una sola fuente de verdad
export type Producto = z.infer<typeof ProductoSchema>;
export type CrearProducto = z.infer<typeof CrearProductoSchema>;Backend (NestJS): valida la entrada con el MISMO esquema:
import { CrearProductoSchema, type CrearProducto } from '@cantina/shared';
@Post()
crear(@Body(new ZodValidationPipe(CrearProductoSchema)) dto: CrearProducto) {
return this.productos.crear(dto);
}Frontend (React): valida la RESPUESTA (¡el servidor también puede equivocarse!) y el form:
import { ProductoSchema, type Producto } from '@cantina/shared';
export async function listarProductos(): Promise<Producto[]> {
const res = await fetch('/api/productos');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return z.array(ProductoSchema).parse(await res.json()); // runtime + tipos alineados
}💡 Cambias
precioaz.string()en shared → los dos proyectos dejan de compilar hasta adaptarse. Ese error de compilación es exactamente el bug de producción que acabas de evitar.⚠️ Qué NO va en shared: nada con dependencias de servidor (Prisma, bcrypt, fs). Solo tipos, esquemas, constantes y utilidades puras. Si shared importa algo de Node, tu bundle del frontend lo pagará (o directamente romperá).
🧠 ¿Y tRPC? Si backend y frontend son TypeScript en el mismo monorepo, tRPC va un paso más allá: no compartes solo esquemas, compartes las funciones del backend, y el frontend las llama con autocompletado y tipos exactos sin escribir ni un cliente HTTP ni un esquema por separado. El precio: acopla al cliente a llamar funciones RPC en vez de una API REST/OpenAPI reutilizable por terceros o por un móvil en otro lenguaje (ap. U). Para "un equipo, un monorepo, un frontend propio", tRPC ahorra la capa de Zod+fetch de K.3; para una API que también consumen apps móviles, partners o Swagger/OpenAPI (ap. D), el enfoque de este apéndice (esquemas compartidos o generación desde OpenAPI) sigue siendo el más portable.
K.4 · Turborepo: orquestar builds sin repetir trabajo
Con 3+ paquetes, correr build/test/lint en orden y con caché es un problema. Turborepo lo resuelve declarando el grafo:
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ^ = primero las dependencias (shared antes que web)
"outputs": ["dist/**", ".next/**"]
},
"test": { "dependsOn": ["build"] },
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}turbo build # construye en orden topológico, en paralelo donde puede
turbo test # y CACHEA: si un paquete no cambió, ni se ejecuta ("FULL TURBO")En CI (cap. 14) la caché remota convierte pipelines de 10 min en 40 s cuando solo cambió un paquete. Alternativa equivalente: Nx. Para 2-3 paquetes, pnpm a secas te sobra.
K.5 · Estrategia 2: generar tipos desde OpenAPI (backend en cualquier lenguaje)
Si tu backend es FastAPI, Go, Laravel o Spring, no puedes compartir código TS — pero todos publican OpenAPI (cap. D). El contrato ES la fuente de verdad:
# 1. El backend exporta su esquema (FastAPI lo da gratis en /openapi.json)
curl http://localhost:8000/openapi.json > openapi.json
# 2. Generar tipos TypeScript
npx openapi-typescript openapi.json -o src/api/schema.d.ts// 3. Cliente 100% tipado con openapi-fetch (rutas, params y bodies verificados)
import createClient from 'openapi-fetch';
import type { paths } from './api/schema';
const client = createClient<paths>({ baseUrl: '/api' });
const { data, error } = await client.GET('/productos/{id}', {
params: { path: { id: 42 } }, // ← si la ruta pide string, TS te para aquí
});
// data: tipado exacto de la respuesta 200 del backend# 4. En CI: regenerar y fallar si hay drift (el contrato como test)
- run: npx openapi-typescript http://api:8000/openapi.json -o schema.d.ts
- run: git diff --exit-code schema.d.ts # si cambió, alguien rompió el contrato🧠 Esto es contract-first en la práctica: el backend no puede cambiar una respuesta sin que el frontend se entere en CI, aunque los escriban equipos (¡o lenguajes!) distintos. Variantes del mismo truco:
orval(genera hooks de TanStack Query),openapi-generator(clientes para Java/Go/PHP/Swift…).
K.6 · Compose y CI en el monorepo
# apps/api/Dockerfile — el contexto es la RAÍZ del monorepo
FROM node:24-alpine AS base
RUN corepack enable
WORKDIR /repo
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/shared/package.json packages/shared/
COPY apps/api/package.json apps/api/
RUN pnpm install --frozen-lockfile
COPY packages/shared packages/shared
COPY apps/api apps/api
RUN pnpm --filter @cantina/api build
CMD ["node", "apps/api/dist/main.js"]docker build -f apps/api/Dockerfile . # ← contexto raíz, no apps/apiEn GitHub Actions, corre solo lo afectado: turbo test --filter=...[origin/main] ejecuta los tests únicamente de los paquetes que cambiaron respecto a main (y sus dependientes).
K.7 · Buenas prácticas
- Monorepo ≠ monolito: los paquetes se despliegan por separado; solo comparten repo y contratos.
sharedmínimo y puro: esquemas, tipos, constantes. Si dudas, fuera.- Zod (o Valibot) como fuente de verdad cuando todo es TS; OpenAPI cuando no.
- Versiona el lockfile único y usa
workspace:*— nunca dupliques versiones de una lib entre paquetes sin motivo. - El drift del contrato se detecta en CI, no en producción.
- No empieces con monorepo "por si acaso": un proyecto pequeño con carpeta
types/copiada da menos guerra. Migra cuando el drift te haya mordido una vez.
✅ Ejercicio del apéndice
1. Convierte tu tienda (API NestJS + front React) a monorepo pnpm con @tienda/shared.
2. Mueve Producto/Pedido a esquemas Zod en shared; valida entrada en la API y
respuesta en el front con los mismos esquemas.
3. Rompe el contrato a propósito (renombra un campo) y observa dónde y cómo falla.
4. Añade Turborepo: build con dependsOn y comprueba el cache hit en la 2ª ejecución.
5. Con tu API FastAPI del cap. 05: genera schema.d.ts con openapi-typescript, monta
un cliente openapi-fetch y añade el check de drift al CI.💡 Pistas
- Si Vite no traga el TS fuente de shared: añade
@cantina/sharedaoptimizeDeps.excludeo compila shared a dist contsc --watchen dev. - El punto 3 debe fallar en compilación (estrategia compartir) o en el diff de CI (estrategia generar). Si solo falla en runtime, algo está mal montado.
🧠 Autoevaluación
Las interfaces desaparecen al compilar: no protegen en runtime. El esquema Zod da el tipo estático (z.infer) Y la validación de datos reales (entrada de la API, respuesta en el front) desde una única definición.
Generar: exportar OpenAPI desde el backend, openapi-typescript para los tipos del front, y en CI regenerar + git diff --exit-code para que cualquier cambio de contrato rompa el pipeline en vez de romper producción.
Porque JSON no tiene tipo fecha: por el cable siempre viaja un string. Tiparlo Date en el front es mentirle al compilador; se convierte a Date explícitamente en el borde si hace falta.
Que cada paquete declare un rango de versión distinto para la misma dependencia (por ejemplo Zod 3 en uno y Zod 4 en otro): se instalan dos copias con comportamientos distintos, y un esquema que funciona en shared puede fallar de forma sutil al usarse desde api. El catálogo centraliza la versión en pnpm-workspace.yaml para que todos los paquetes usen siempre la misma.
Volver al: README.md · Relacionado: A-typescript.md, D-apis-avanzadas.md, 22-proyecto-final.md