Skip to content

🎯 Meta: eliminar la fuente de bugs más tonta del fullstack: que el backend diga precio: number y el frontend espere price: 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:

EstrategiaCómoCuándo
Compartir (monorepo)Un paquete shared que ambos importanBack y front en TypeScript (NestJS/Elysia + React/Vue)
Generar (contrato)OpenAPI del backend → tipos del frontendBackend 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
yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
jsonc
// 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:*" } }
bash
pnpm install                 # enlaza todo
pnpm --filter api dev        # correr un paquete concreto
pnpm -r test                 # -r = en todos los paquetes

Catá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:

yaml
# 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
jsonc
// packages/shared/package.json
{ "dependencies": { "zod": "catalog:" } }

// apps/web/package.json
{ "dependencies": { "react": "catalog:react", "react-dom": "catalog:react" } }

💡 Un pnpm up zod en el catálogo actualiza la versión para todos los paquetes a la vez, y pnpm publish/pnpm pack sustituyen catalog: por el número real (igual que hacen con workspace:*) — el consumidor final nunca ve ese protocolo. Sin catálogos, "por qué el build de web usa una versión distinta de Zod que api" 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:

typescript
// 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:

typescript
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:

typescript
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 precio a z.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:

jsonc
// 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 }
  }
}
bash
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:

bash
# 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
typescript
// 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
yaml
# 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

dockerfile
# 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"]
bash
docker build -f apps/api/Dockerfile .    # ← contexto raíz, no apps/api

En 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

  1. Monorepo ≠ monolito: los paquetes se despliegan por separado; solo comparten repo y contratos.
  2. shared mínimo y puro: esquemas, tipos, constantes. Si dudas, fuera.
  3. Zod (o Valibot) como fuente de verdad cuando todo es TS; OpenAPI cuando no.
  4. Versiona el lockfile único y usa workspace:* — nunca dupliques versiones de una lib entre paquetes sin motivo.
  5. El drift del contrato se detecta en CI, no en producción.
  6. 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/shared a optimizeDeps.exclude o compila shared a dist con tsc --watch en 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