Schema और TypeScript Types के बीच दो-तरफ़ा Sync
मुख्य बात: Types और schemas में से हर एक एक हिस्सा कवर करता है: एक compile time पर, एक runtime पर। सवाल यह नहीं कि किसे चुनें, बल्कि यह कि दोनों को स्वतः sync में कैसे रखें।

सारांश: पहले एक सत्य-स्रोत चुनें — schema-first जब contract backend का हो, zod-शैली types-first जब frontend का हो — फिर दूसरी प्रति जनरेट करें और CI में उसका diff लें; केवल वही semantics हाथ से लिखें जो generator व्यक्त नहीं कर सकता, जैसे branded types।
अगर आप दोनों definitions हाथ से लिखेंगे, तो वे drift कर जाएँगी। Drift की कीमत है "compile हो जाता है, फिर runtime पर 500 लौटाता है।"
1. केवल एक प्रति रखना क्यों काम नहीं करता
| क्षमता | TypeScript types | JSON Schema |
|---|---|---|
| Compile-time जाँच | ✓ | ✗ |
| Runtime validation | ✗ (types मिट जाते हैं) | ✓ |
| Cross-language उपयोग | ✗ | ✓ (Java/Go/Python सभी पढ़ सकते हैं) |
| IDE completion और refactoring | ✓ | ठीक-ठाक |
| Documentation generation | ठीक-ठाक | ✓ (OpenAPI ecosystem) |
API का इनपुट network से आता है और हमेशा unknown होता है, इसलिए runtime validation छोड़ी नहीं जा सकती; और business code लिखते समय type completion फिर भी चाहिए।
समझदार तरीका है एक को सत्य-स्रोत चुनना और दूसरे को जनरेट करना।
2. रास्ता A: schema पहले
उपयुक्त: backend-चालित काम, cross-language उपयोग और सार्वजनिक contracts।
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* JSON Schema से TypeScript type definitions जनरेट करें।
* @param schemaPath schema फ़ाइल का पथ
* @param outputPath आउटपुट फ़ाइल का पथ
* @returns एक Promise जो generation पूरा होने पर resolve होता है
*/
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');
}
आउटपुट में कुछ परंपराएँ ध्यान देने योग्य हैं:
required: ["id"]→id: string;requiredमें न दी गई keys →id?: string;"type": ["string", "null"]→string | null;enum→ literal types का union,stringसे कहीं अधिक सटीक;additionalProperties: falseतय करता है कि index signature बनेगा या नहीं।
एक zero-build विकल्प भी है, json-schema-to-ts, जो सीधे 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 }
लाभ है generation step न होना और types हमेशा schema के साथ sync रहना; कमी है schema का as const में लिखा जाना, और जटिल $ref परिदृश्य धीमे compile होते हैं।
3. रास्ता B: types पहले
उपयुक्त: zod / valibot जैसी libraries के साथ frontend-चालित काम, जहाँ "schema ही 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>;
/** अन्य भाषाओं को प्रकाशित contract */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
यहाँ zod दोनों भूमिकाएँ निभाता है — "type definition" और "runtime validation" — और JSON Schema बस export artifact है। यह समग्र रूप से सबसे कम परिश्रम है, पर दो बातें ध्यान में रखें:
z.inferऔर जनरेट किया JSON Schemadefault,transformऔरrefineके लिए ठीक-ठीक मेल नहीं खाते;- जनरेट किया schema
$refऔरdefinitionsरख सकता है, इसलिए publish करने से पहले जाँचें कि यह लक्ष्य विनिर्देश से मेल खाता है।
4. उल्टी दिशा भी जाँची जानी चाहिए
चाहे जो भी रास्ता चुनें, एक CI जाँच जोड़ें: जनरेट किए artifacts repository की फ़ाइलों से मेल खाने चाहिए।
# अस्थायी निर्देशिका में जनरेट करें और तुलना करें; कोई भी अंतर हो तो CI विफल करें
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
इस step के बिना, तीन महीने बाद किसी को पता नहीं होगा कि api.d.ts अद्यतन है या नहीं।
5. चार उच्च-आवृत्ति जाल
5.1 optional और nullable एक चीज़ नहीं हैं
type A = { name?: string }; // key अनुपस्थित हो सकती है
type B = { name: string | null }; // key मौजूद होनी चाहिए, पर मान null हो सकता है
Schema के संदर्भ में:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
Generators दोनों स्थितियों को पूरी तरह अलग संभालते हैं। Contract को बताना चाहिए कि आपका अर्थ कौन-सा है; वरना front end का data.name?.length null पर crash करेगा।
5.2 बदलते enums
type Status = 'pending' | 'paid' | 'refunded';
अगर backend 'cancelled' जोड़ दे और frontend types अद्यतन न हों, तो code default branch में गिर जाता है।
Enum-जैसे फ़ील्ड अज्ञात मानों को स्पष्ट रूप से संभालने चाहिए:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 तारीखें और money केवल string नहीं ले सकते
Schema में format: "date-time" केवल annotation है; TypeScript में यह बस string है।
मज़बूत गारंटी चाहिए तो "branded type" इस्तेमाल करें:
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* किसी स्ट्रिंग को ISO timestamp के रूप में चिह्नित करें।
* @param value वह स्ट्रिंग जिसका प्रारूप पहले ही सत्यापित हो चुका है
* @returns Branded timestamp स्ट्रिंग
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 बड़े integers
type: "integer" मानों के 2^53 से अधिक होने पर number के बराबर नहीं है। Contract में इसे ऐसे लिखें:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. सुझाया गया rollout प्रक्रिया
- Contract तय करें: request/response संरचना को schema (या zod) के रूप में लिखकर शुरू करें;
- Types जनरेट करें: CI में
.d.tsजनरेट करें और repository में commit करें ताकि अंतर आसानी से review हों; - Validators जनरेट करें: उसी schema को Ajv validation function में compile करें, और हर server entry point पर उसे कॉल करें;
- Documentation जनरेट करें: schema से Markdown या OpenAPI निकालें ताकि docs हाथ से कभी पुराने न पड़ें;
- Regression: API fixtures को "type checking" और "schema validation" दोनों से गुज़ारें; दोनों पास होने पर ही गिनती हो।
7. Checklist
- [ ] सत्य-स्रोत ठीक एक है; दूसरी प्रति किसी script से जनरेट होती है;
- [ ] Generation script CI में चलता है, और कोई भी artifact अंतर build विफल करता है;
- [ ]
optionalऔरnullablecontract में स्पष्ट रूप से अलग हैं; - [ ] Enum फ़ील्ड में अज्ञात मानों के लिए fallback है;
- [ ] बड़े integers, money और समय के लिए schema और types दोनों में स्पष्ट परंपराएँ हैं;
- [ ] जनरेट की type फ़ाइलों पर "do not edit by hand" हेडर है।
FAQ
schema-first और types-first के बीच कैसे तय करूँ?
schema-first चुनें जब contract backend का हो, cross-language उपयोग चाहिए, या contract सार्वजनिक रूप से प्रकाशित होता हो, और types json-schema-to-typescript से जनरेट करें। zod-शैली "schema ही type है" library चुनें जब frontend अग्रणी हो और types तथा validation एक बार define करके JSON Schema export करना हो। किसी भी रास्ते के लिए CI step चाहिए जो जनरेट किए artifacts की repository फ़ाइलों से तुलना करे।
हाथ से लिखी type और schema definitions हमेशा drift क्यों होती हैं?
Types compile time पर मिट जाते हैं जबकि schemas runtime पर ही प्रभावी होते हैं, और दोनों अलग-अलग लोग अलग-अलग समय पर संपादित करते हैं। Generation और comparison के बिना एक प्रति अद्यतन होती है जबकि दूसरी जस की तस रहती है, जिससे बनता है "compile हो जाता है, फिर runtime पर 500 लौटाता है"।
क्या जनरेट की type फ़ाइलें repository में commit करनी चाहिए?
हाँ। Commit करने से reviewers contract बदलाव देख पाते हैं, और CI ताज़ा generation आउटपुट की repository फ़ाइलों से तुलना करके किसी भी अंतर पर विफल हो सकता है। Artifacts को केवल local फ़ोल्डर या build cache में छोड़ दें तो तीन महीने बाद किसी को पता नहीं होगा कि वे अद्यतन हैं या नहीं।