JSON Toolkit

Synchronisation bidirectionnelle entre schéma et types TypeScript

L’essentiel : Types et schémas couvrent chacun un segment : l'un à la compilation, l'autre à l'exécution. La question n'est pas lequel choisir, mais comment garder les deux synchronisés automatiquement.

Intermédiaire~11 min de lecturePublié le 2026-10-30Mis à jour le 2026-10-30
Illustration de l'article Synchronisation bidirectionnelle entre schéma et types TypeScript

L'essentiel : choisissez d'abord une source unique de vérité — schéma d'abord quand le backend possède le contrat, types d'abord à la mode zod quand le frontend le possède — puis générez l'autre copie et comparez-la en CI ; n'écrivez à la main que la sémantique que le générateur ne peut pas exprimer, comme les types marqués.

Si vous écrivez les deux définitions à la main, elles divergeront. Le prix de la divergence est « ça compile, puis ça renvoie un 500 à l'exécution ».

1. Pourquoi garder une seule copie ne fonctionne pas

Capacité Types TypeScript JSON Schema
Vérification à la compilation ✓ ✗
Validation à l'exécution ✗ (les types sont effacés) ✓
Usage inter-langages ✗ ✓ (Java/Go/Python peuvent tous le lire)
Complétion IDE et refactoring ✓ Moyen
Génération de documentation Moyen ✓ (l'écosystème OpenAPI)

L'entrée d'une API vient du réseau et est toujours unknown, donc la validation à l'exécution ne peut pas être sautée ; et pendant l'écriture du code métier, vous avez encore besoin de la complétion de types. L'approche sensée est de choisir l'un comme source unique de vérité et de générer l'autre.

2. Voie A : le schéma d'abord

Adaptée à : le travail piloté par le backend, l'usage inter-langages et les contrats publics.

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');
}

Quelques conventions de la sortie méritent d'être notées :

  • required: ["id"] → id: string ; les clés non listées dans required → id?: string ;
  • "type": ["string", "null"] → string | null ;
  • enum → une union de types littéraux, bien plus précise que string ;
  • additionalProperties: false détermine si une signature d'index est générée.

Il existe aussi une option sans étape de build, json-schema-to-ts, qui infère directement au niveau du type :

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 }

L'avantage est l'absence d'étape de génération et des types toujours synchronisés avec le schéma ; l'inconvénient est que le schéma doit être écrit avec as const, et que les scénarios $ref complexes compilent lentement.

3. Voie B : les types d'abord

Adaptée à : le travail piloté par le frontend avec des bibliothèques comme zod / valibot, où « le schéma est le type ».

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',
});

Ici zod joue les deux rôles — « définition de type » et « validation à l'exécution » — et JSON Schema n'est qu'un artefact d'export. C'est le moins d'effort global, mais gardez deux choses en tête :

  • z.infer et le JSON Schema généré ne correspondent pas exactement pour default, transform et refine ;
  • Le schéma généré peut contenir $ref et definitions, donc vérifiez avant publication qu'il correspond à la spécification cible.

4. La direction inverse mérite aussi une vérification

Quelle que soit la voie choisie, ajoutez une vérification en CI : les artefacts générés doivent correspondre aux fichiers du dépôt.

# 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

Sans cette étape, trois mois plus tard personne ne saura si api.d.ts est à jour.

5. Quatre pièges à haute fréquence

5.1 optional et nullable ne sont pas la même chose

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 termes de schéma :

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

Les générateurs traitent les deux cas complètement différemment. Le contrat doit préciser lequel vous voulez dire ; sinon le data.name?.length du frontend plantera sur null.

5.2 Faire évoluer les énumérations

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

Si le backend ajoute 'cancelled' et que les types frontend ne sont pas mis à jour, le code tombe dans la branche default. Les champs de type énumération devraient gérer explicitement les valeurs inconnues :

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

5.3 Dates et montants ne peuvent pas simplement utiliser string

Dans le schéma, format: "date-time" n'est qu'une annotation ; en TypeScript, ce n'est qu'un string. Quand il faut une garantie plus forte, utilisez un « type marqué » :

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 Grands entiers

type: "integer" n'est pas équivalent à number une fois que les valeurs dépassent 2^53. Écrivez-le dans le contrat comme :

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

6. Un processus de déploiement suggéré

  1. Définir le contrat : commencez par écrire la structure de requête/réponse sous forme de schéma (ou de zod) ;
  2. Générer les types : générez le .d.ts en CI et committez-le dans le dépôt pour que les différences soient faciles à relire ;
  3. Générer les validateurs : compilez le même schéma en une fonction de validation Ajv, et appelez-la à chaque point d'entrée serveur ;
  4. Générer la documentation : émettez du Markdown ou OpenAPI à partir du schéma pour que la documentation ne se périme jamais à la main ;
  5. Régression : faites passer les fixtures d'API à la fois par la « vérification de types » et par la « validation de schéma » ; seul le passage des deux compte.

7. Liste de vérification

  • [ ] Il existe exactement une source unique de vérité ; l'autre copie est générée par un script ;
  • [ ] Le script de génération s'exécute en CI, et toute différence d'artefact fait échouer le build ;
  • [ ] optional et nullable sont explicitement distingués dans le contrat ;
  • [ ] Les champs d'énumération ont un repli pour les valeurs inconnues ;
  • [ ] Les grands entiers, les montants et le temps ont des conventions explicites dans le schéma et dans les types ;
  • [ ] Les fichiers de types générés portent un en-tête « ne pas modifier à la main ».

FAQ

Comment décider entre schéma d'abord et types d'abord ?

Choisissez le schéma d'abord quand le backend possède le contrat, qu'il faut un usage inter-langages, ou que le contrat est publié publiquement, et générez les types avec json-schema-to-typescript. Choisissez une bibliothèque à la zod où « le schéma est le type » quand le frontend mène et veut définir types et validation une seule fois, puis exporter JSON Schema. Chaque voie exige une étape CI qui compare les artefacts générés aux fichiers du dépôt.

Pourquoi les définitions de types et de schémas écrites à la main divergent-elles toujours ?

Les types sont effacés à la compilation tandis que les schémas n'agissent qu'à l'exécution, et les deux sont modifiés par des personnes différentes à des moments différents. Sans génération ni comparaison, une copie est mise à jour pendant que l'autre reste immobile, ce qui produit « ça compile, puis ça renvoie un 500 à l'exécution ».

Faut-il committer les fichiers de types générés dans le dépôt ?

Oui. Les committer permet aux relecteurs de voir les changements de contrat, et la CI peut comparer une génération fraîche aux fichiers du dépôt et échouer à la moindre différence. Laissez les artefacts seulement dans un dossier local ou un cache de build et personne ne saura trois mois plus tard s'ils sont à jour.