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.

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 enrequired→id?: string;"type": ["string", "null"]→string | null;enum→ una unión de tipos literales, mucho más precisa questring;additionalProperties: falsedetermina 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.infery el JSON Schema generado no se corresponden exactamente paradefault,transformyrefine;- El esquema generado puede contener
$refydefinitions, 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
- Define el contrato: empieza escribiendo la estructura de petición/respuesta como esquema (o zod);
- Genera los tipos: genera el
.d.tsen CI y confírmalo en el repositorio para que las diferencias sean fáciles de revisar; - 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;
- Genera la documentación: emite Markdown u OpenAPI desde el esquema para que la documentación nunca se quede obsoleta a mano;
- 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;
- [ ]
optionalynullablese 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.