JSON Toolkit

Obousměrná synchronizace schématu a typů TypeScript

Shrnutí: Typy a schémata každé pokrývá jeden segment: jedno při kompilaci, druhé za běhu. Otázka není, které zvolit, ale jak udržet obě kopie automaticky v souladu.

Středně pokročilý~11 min čteníPublikováno 2026-10-30Aktualizováno 2026-10-30
Titulní obrázek článku Obousměrná synchronizace schématu a typů TypeScript

Stručně: nejprve zvolte jediný zdroj pravdy – schema-first, když kontrakt vlastní backend, types-first ve stylu zod, když ho vlastní frontend – poté generujte druhou kopii a diffujte ji v CI; ručně pište jen sémantiku, kterou generátor vyjádřit neumí, například branded typy.

Pokud obě definice píšete ručně, rozejdou se. Cenou driftu je „zkompiluje se a za běhu vrátí 500“.

1. Proč uchovávání jediné kopie nefunguje

Schopnost Typy TypeScript JSON Schema
Kontrola při kompilaci ✓ ✗
Validace za běhu ✗ (typy se vymažou) ✓
Použití napříč jazyky ✗ ✓ (Java/Go/Python ho umějí číst)
Dokončování a refaktoring v IDE ✓ Ujde
Generování dokumentace Ujde ✓ (ekosystém OpenAPI)

Vstup API přichází ze sítě a je vždy unknown, takže validaci za běhu nelze vynechat; a při psaní obchodního kódu stále potřebujete typové dokončování. Rozumný přístup je zvolit jeden jako zdroj pravdy a druhý generovat.

2. Cesta A: schema first

Vhodná pro: práci řízenou backendem, použití napříč jazyky a veřejné kontrakty.

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

/**
 * Generuje definice typů TypeScript z JSON Schema.
 * @param schemaPath Cesta k souboru schématu
 * @param outputPath Cesta k výstupnímu souboru
 * @returns Promise, který se vyřeší po dokončení generování
 */
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');
}

Několik konvencí ve výstupu stojí za pozornost:

  • required: ["id"] → id: string; klíče neuvedené v required → id?: string;
  • "type": ["string", "null"] → string | null;
  • enum → sjednocení literálních typů, mnohem přesnější než string;
  • additionalProperties: false určuje, zda se generuje indexový podpis.

Existuje také zero-build varianta, json-schema-to-ts, která odvozuje přímo na úrovni typů:

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 }

Výhodou je žádný generovací krok a typy vždy v souladu se schématem; nevýhodou je, že schéma musí být napsané jako as const, a složité scénáře $ref se kompilují pomalu.

3. Cesta B: types first

Vhodná pro: práci řízenou frontendem s knihovnami jako zod / valibot, kde „schéma je typ“.

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>;

/** Kontrakt publikovaný pro jiné jazyky */
export const userJsonSchema = zodToJsonSchema(userSchema, {
  target: 'jsonSchema2020-12',
  $refStrategy: 'none',
});

Zde zod hraje obě role – „definice typu“ i „validace za běhu“ – a JSON Schema je pouhým exportním artefaktem. Celkově je to nejmenší úsilí, ale mějte na paměti dvě věci:

  • z.infer a vygenerované JSON Schema se přesně neshodují pro default, transform a refine;
  • Vygenerované schéma může obsahovat $ref a definitions, takže před publikováním zkontrolujte, zda odpovídá cílové specifikaci.

4. Zpětný směr je také třeba kontrolovat

Ať zvolíte kteroukoli cestu, přidejte kontrolu v CI: vygenerované artefakty musí odpovídat souborům v repozitáři.

# Vygenerujte do dočasného adresáře a srovnejte; CI selže při jakémkoli rozdílu
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts

Bez tohoto kroku o tři měsíce nikdo neví, zda je api.d.ts aktuální.

5. Čtyři časté pasti

5.1 optional a nullable nejsou totéž

type A = { name?: string };        // klíč může chybět
type B = { name: string | null };  // klíč musí existovat, ale hodnota může být null

Ve schématu:

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

Generátory oba případy zpracovávají zcela odlišně. Smlouva musí uvést, který z nich myslíte; jinak frontendový data.name?.length spadne na null.

5.2 Vývoj výčtů

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

Pokud backend přidá 'cancelled' a typy frontendu se neaktualizují, kód spadne do větve default. Pole podobná výčtům by měla explicitně ošetřovat neznámé hodnoty:

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

5.3 Data a peníze nemohou prostě použít string

Ve schématu je format: "date-time" jen anotace; v TypeScriptu je to jen string. Když potřebujete silnější záruku, použijte „branded typ“:

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

/**
 * Označí řetězec jako časové razítko ISO.
 * @param value Řetězec, jehož formát už byl ověřen
 * @returns Branded řetězec časového razítka
 */
function asIsoDateTime(value: string): IsoDateTime {
  return value as IsoDateTime;
}

5.4 Velká celá čísla

type: "integer" není ekvivalentní number, jakmile hodnoty přesáhnou 2^53. Ve smlouvě to pište jako:

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

6. Navržený zaváděcí postup

  1. Definujte kontrakt: začněte psaním struktury požadavku/odpovědi jako schématu (nebo zod);
  2. Generujte typy: generujte .d.ts v CI a commitujte ho do repozitáře, aby byly rozdíly snadno revidovatelné;
  3. Generujte validátory: zkompilujte stejné schéma do validační funkce Ajv a volejte ji na každém vstupním bodě serveru;
  4. Generujte dokumentaci: vygenerujte Markdown nebo OpenAPI ze schématu, aby dokumentace ručně nestárla;
  5. Regrese: prožeňte API fixtures jak „typovou kontrolou“, tak „validací schématem“; počítá se jen průchod obojím.

7. Kontrolní seznam

  • [ ] Existuje přesně jeden zdroj pravdy; druhá kopie je generovaná skriptem;
  • [ ] Generovací skript běží v CI a jakýkoli rozdíl artefaktů selže build;
  • [ ] optional a nullable jsou ve smlouvě explicitně rozlišeny;
  • [ ] Výčtová pole mají fallback pro neznámé hodnoty;
  • [ ] Velká celá čísla, peníze a čas mají explicitní konvence ve schématu i v typech;
  • [ ] Vygenerované typové soubory nesou hlavičku „do not edit by hand“.

Časté dotazy

Jak rozhodnout mezi schema-first a types-first?

Schema-first zvolte, když kontrakt vlastní backend, potřebujete použití napříč jazyky nebo kontrakt publikujete veřejně, a generujte typy pomocí json-schema-to-typescript. Knihovnu ve stylu zod „schéma je typ“ zvolte, když vede frontend a chce definovat typy a validaci jednou a poté exportovat JSON Schema. Obě cesty potřebují krok v CI, který srovnává vygenerované artefakty se soubory v repozitáři.

Proč se ručně psané definice typů a schémat vždy rozejdou?

Typy se při kompilaci vymažou, zatímco schémata účinkují jen za běhu, a obě upravují různí lidé v různých časech. Bez generování a srovnávání se jedna kopie aktualizuje a druhá zůstává, což produkuje „zkompiluje se a za běhu vrátí 500“.

Mám commitovat vygenerované typové soubory do repozitáře?

Ano. Commitování umožní revidérům vidět změny kontraktu a CI může srovnat čerstvý výstup generování se soubory v repozitáři a selhat při jakémkoli rozdílu. Pokud artefakty necháte jen v lokální složce nebo build cache, nikdo o tři měsíce později nebude vědět, zda jsou aktuální.