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.

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é vrequired→id?: string;"type": ["string", "null"]→string | null;enum→ sjednocení literálních typů, mnohem přesnější nežstring;additionalProperties: falseurč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.infera vygenerované JSON Schema se přesně neshodují prodefault,transformarefine;- Vygenerované schéma může obsahovat
$refadefinitions, 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
- Definujte kontrakt: začněte psaním struktury požadavku/odpovědi jako schématu (nebo zod);
- Generujte typy: generujte
.d.tsv CI a commitujte ho do repozitáře, aby byly rozdíly snadno revidovatelné; - Generujte validátory: zkompilujte stejné schéma do validační funkce Ajv a volejte ji na každém vstupním bodě serveru;
- Generujte dokumentaci: vygenerujte Markdown nebo OpenAPI ze schématu, aby dokumentace ručně nestárla;
- 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;
- [ ]
optionalanullablejsou 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í.