JSON Toolkit

Sincronización bidireccional entre esquema y tipos de TypeScript

En resumen: Los tipos y los esquemas cubren cada uno un segmento: uno en tiempo de compilación, otro en tiempo de ejecución. La pregunta no es cuál elegir, sino cómo mantener ambos sincronizados de forma automática.

Intermedio~11 min de lecturaPublicado el 2026-10-30Actualizado el 2026-10-30
Portada de Sincronización bidireccional entre esquema y tipos de TypeScript

En resumen: elige primero una única fuente de verdad — schema-first cuando el backend es dueño del contrato, types-first al estilo zod cuando lo es el frontend — y luego genera la otra copia y compara diferencias en CI; escribe a mano solo la semántica que el generador no puede expresar, como los tipos con marca.

Si escribes ambas definiciones a mano, se desviarán. El precio de la desviación es "compila, y luego devuelve un 500 en tiempo de ejecución."

1. Por qué no funciona quedarse con una sola copia

Capacidad Tipos de TypeScript JSON Schema
Comprobación en tiempo de compilación ✓ ✗
Validación en tiempo de ejecución ✗ (los tipos se borran) ✓
Uso entre lenguajes ✗ ✓ (Java/Go/Python pueden leerlo)
Autocompletado y refactorización en el IDE ✓ Regular
Generación de documentación Regular ✓ (el ecosistema OpenAPI)

La entrada a una API viene de la red y siempre es unknown, así que la validación en tiempo de ejecución no se puede omitir; y mientras escribes código de negocio sigues necesitando el autocompletado de tipos. El enfoque sensato es elegir uno como fuente de verdad y generar el otro.

2. Vía A: primero el esquema

Adecuada para: trabajo impulsado por el backend, uso entre lenguajes y contratos públicos.

pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';

/**
 * Generate TypeScript type definitions from a JSON Schema.
 * @param schemaPath Path to the schema file
 * @param outputPath Path to the output file
 * @returns A Promise that resolves when generation is finished
 */
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
  const typeText = await compileFromFile(schemaPath, {
    bannerComment: '/* Auto-generated from the schema. Do not edit by hand. */',
    additionalProperties: false,
    style: { singleQuote: true },
  });
  writeFileSync(outputPath, typeText, 'utf8');
}

Merece la pena fijarse en algunas convenciones de la salida:

  • required: ["id"] → id: string; las claves no listadas en required → id?: string;
  • "type": ["string", "null"] → string | null;
  • enum → una unión de tipos literales, mucho más precisa que string;
  • additionalProperties: false determina si se genera una signatura de índice.

También existe una opción sin compilación, json-schema-to-ts, que infiere directamente a nivel de tipo:

import type { FromSchema } from 'json-schema-to-ts';

const userSchema = {
  type: 'object',
  properties: { id: { type: 'string' }, age: { type: 'integer' } },
  required: ['id'],
  additionalProperties: false,
} as const;

type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }

La ventaja es que no hay paso de generación y los tipos están siempre sincronizados con el esquema; la desventaja es que el esquema debe escribirse con as const, y los escenarios complejos con $ref compilan despacio.

3. Vía B: primero los tipos

Adecuada para: trabajo impulsado por el frontend con bibliotecas como zod / valibot, donde "el esquema es el tipo".

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

export const userSchema = z.object({
  id: z.string().min(1),
  name: z.string().min(1).max(64),
  age: z.number().int().min(0).max(150).optional(),
  tags: z.array(z.string()).default([]),
});

export type User = z.infer<typeof userSchema>;

/** The contract published to other languages */
export const userJsonSchema = zodToJsonSchema(userSchema, {
  target: 'jsonSchema2020-12',
  $refStrategy: 'none',
});

Aquí zod desempeña ambos papeles — "definición de tipo" y "validación en tiempo de ejecución" — y JSON Schema es meramente un artefacto de exportación. Este es el enfoque con menor esfuerzo global, pero ten presentes dos cosas:

  • z.infer y el JSON Schema generado no se corresponden exactamente para default, transform y refine;
  • El esquema generado puede contener $ref y definitions, así que comprueba antes de publicar si coincide con la especificación de destino.

4. La dirección inversa también necesita comprobación

Elijas la vía que elijas, añade una comprobación en CI: los artefactos generados deben coincidir con los archivos del repositorio.

# Generate into a temp directory and compare; fail CI on any difference
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts

Sin este paso, tres meses después nadie sabe si api.d.ts está al día.

5. Cuatro trampas de alta frecuencia

5.1 optional y nullable no son lo mismo

type A = { name?: string };        // the key may be absent
type B = { name: string | null };  // the key must exist, but the value may be null

En términos de esquema:

{ "properties": { "name": { "type": "string" } } }                        // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable

Los generadores manejan ambos casos de forma completamente distinta. El contrato debe indicar cuál de los dos quieres decir; de lo contrario el data.name?.length del frontend se romperá con null.

5.2 Enumeraciones que evolucionan

type Status = 'pending' | 'paid' | 'refunded';

Si el backend añade 'cancelled' y los tipos del frontend no se actualizan, el código cae en la rama default. Los campos tipo enumeración deberían manejar explícitamente los valores desconocidos:

function normalizeStatus(raw: string): Status | 'unknown' {
  return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}

5.3 Fechas y dinero no pueden usar simplemente string

En el esquema, format: "date-time" es solo una anotación; en TypeScript es simplemente string. Cuando necesites una garantía más fuerte, usa un "tipo con marca":

type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };

/**
 * Mark a string as an ISO timestamp.
 * @param value A string whose format has already been verified
 * @returns The branded timestamp string
 */
function asIsoDateTime(value: string): IsoDateTime {
  return value as IsoDateTime;
}

5.4 Enteros grandes

type: "integer" no equivale a number cuando los valores superan 2^53. Escríbelo en el contrato así:

{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }

6. Un proceso de despliegue sugerido

  1. Define el contrato: empieza escribiendo la estructura de petición/respuesta como esquema (o zod);
  2. Genera los tipos: genera el .d.ts en CI y confírmalo en el repositorio para que las diferencias sean fáciles de revisar;
  3. Genera los validadores: compila el mismo esquema en una función de validación de Ajv, y llámala en cada punto de entrada del servidor;
  4. Genera la documentación: emite Markdown u OpenAPI desde el esquema para que la documentación nunca se quede obsoleta a mano;
  5. Regresión: pasa los fixtures de API por "comprobación de tipos" y "validación de esquema"; solo cuenta si pasan ambos.

7. Lista de comprobación

  • [ ] Existe exactamente una fuente de verdad; la otra copia la genera un script;
  • [ ] El script de generación se ejecuta en CI, y cualquier diferencia en un artefacto rompe la compilación;
  • [ ] optional y nullable se distinguen explícitamente en el contrato;
  • [ ] Los campos de enumeración tienen un valor de reserva para valores desconocidos;
  • [ ] Los enteros grandes, el dinero y las fechas tienen convenciones explícitas tanto en el esquema como en los tipos;
  • [ ] Los archivos de tipos generados llevan una cabecera de "no editar a mano".

Preguntas frecuentes

¿Cómo decido entre schema-first y types-first?

Elige schema-first cuando el backend es dueño del contrato, necesita uso entre lenguajes o publica el contrato públicamente, y genera los tipos con json-schema-to-typescript. Elige una biblioteca de estilo zod donde "el esquema es el tipo" cuando el frontend lidera y quiere definir tipos y validación una sola vez, y luego exporta JSON Schema. Cualquiera de las dos vías necesita un paso de CI que compare los artefactos generados con los archivos del repositorio.

¿Por qué las definiciones de tipos y esquemas escritas a mano siempre se desvían?

Los tipos se borran en tiempo de compilación mientras que los esquemas solo surten efecto en tiempo de ejecución, y ambos los editan personas distintas en momentos distintos. Sin generación y comparación, una copia se actualiza mientras la otra se queda quieta, lo que produce "compila, y luego devuelve un 500 en tiempo de ejecución".

¿Debería confirmar los archivos de tipos generados en el repositorio?

Sí. Confirmarlos permite a los revisores ver los cambios de contrato, y CI puede comparar la salida fresca de la generación con los archivos del repositorio y fallar ante cualquier diferencia. Si dejas los artefactos solo en una carpeta local o en la caché de compilación, nadie sabrá tres meses después si están al día.