همگامسازی دوطرفه بین Schema و نوعهای TypeScript
جمعبندی: نوعها و schemaها هر یک یک بازه را پوشش میدهند: یکی زمان کامپایل، یکی زمان اجرا. پرسش این نیست کدام انتخاب شود، بلکه چگونه این دو خودکار همگام بمانند.

جمعبندی: اول یک منبع حقیقت انتخاب کنید — schema-first وقتی بکاند قرارداد را دارد، types-first به سبک zod وقتی فرانتاند — سپس نسخه دیگر را تولید و در CI diff کنید؛ فقط معناشناسیای را دستی بنویسید که تولیدکننده نمیتواند بیان کند، مانند نوعهای branded.
اگر هر دو تعریف را دستی بنویسید، منحرف میشوند. قیمت انحراف «کامپایل میشود، بعد در زمان اجرا ۵۰۰ برمیگرداند» است.
۱. چرا نگه داشتن فقط یک نسخه کار نمیکند
| توانایی | نوعهای TypeScript | JSON Schema |
|---|---|---|
| بررسی زمان کامپایل | ✓ | ✗ |
| اعتبارسنجی زمان اجرا | ✗ (نوعها پاک میشوند) | ✓ |
| استفاده بینزبانی | ✗ | ✓ (Java/Go/Python همه میتوانند بخوانند) |
| تکمیل و بازسازی IDE | ✓ | متوسط |
| تولید مستندات | متوسط | ✓ (اکوسیستم OpenAPI) |
ورودی یک API از شبکه میآید و همیشه unknown است، پس اعتبارسنجی زمان اجرا قابل رد کردن نیست؛ و هنگام نوشتن کد کسبوکار همچنان به تکمیل نوع نیاز دارید.
رویکرد معقول انتخاب یکی بهعنوان منبع حقیقت و تولید دیگری است.
۲. مسیر الف: schema اول
مناسب برای: کار بکاند-محور، استفاده بینزبانی و قراردادهای عمومی.
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');
}
چند قرارداد در خروجی شایسته توجهاند:
required: ["id"]→id: string؛ کلیدهای فهرستنشده درrequired→id?: string؛"type": ["string", "null"]→string | null؛enum→ unionی از نوعهای literal، بسیار دقیقتر ازstring؛additionalProperties: falseتعیین میکند آیا امضای ایندکس تولید شود.
یک گزینه بدون ساخت هم وجود دارد، json-schema-to-ts، که مستقیم در سطح نوع استنتاج میکند:
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 }
مزیتش نبود گام تولید و نوعهایی است که همیشه با schema همگاماند؛ عیبش این است که schema باید as const نوشته شود و سناریوهای $ref پیچیده کند کامپایل میشوند.
۳. مسیر ب: نوع اول
مناسب برای: کار فرانتاند-محور با کتابخانههایی مانند zod / valibot، جایی که «schema همان نوع است».
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',
});
اینجا zod هر دو نقش «تعریف نوع» و «اعتبارسنجی زمان اجرا» را بازی میکند و JSON Schema صرفاً محصول صادراتی است. کمترین تلاش کلی است، اما دو چیز را در ذهن نگه دارید:
z.inferو JSON Schema تولیدشده برایdefault،transformوrefineدقیقاً نگاشت نمیشوند؛- schema تولیدشده ممکن است
$refوdefinitionsداشته باشد، پس پیش از انتشار بررسی کنید آیا با مشخصات هدف میخواند.
۴. جهت معکوس هم باید بررسی شود
هر مسیری را انتخاب کنید، یک بررسی CI اضافه کنید: محصولات تولیدشده باید با فایلهای مخزن بخوانند.
# 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
بدون این گام، سه ماه بعد هیچکس نمیداند api.d.ts بهروز است یا نه.
۵. چهار تله پرتکرار
۵.۱ optional و nullable یک چیز نیستند
type A = { name?: string }; // the key may be absent
type B = { name: string | null }; // the key must exist, but the value may be null
در قالب schema:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
تولیدکنندهها دو حالت را کاملاً متفاوت مدیریت میکنند. قرارداد باید بگوید کدام منظور است؛ وگرنه data.name?.length فرانتاند روی null کرش میکند.
۵.۲ enumهای در حال تکامل
type Status = 'pending' | 'paid' | 'refunded';
اگر بکاند 'cancelled' اضافه کند و نوعهای فرانتاند بهروز نشوند، کد به شاخه default میافتد.
فیلدهای enum-مانند باید مقادیر ناشناخته را صریح مدیریت کنند:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
۵.۳ تاریخ و پول نمیتوانند صرفاً string باشند
در schema، format: "date-time" فقط حاشیهنویسی است؛ در TypeScript فقط string است.
وقتی به تضمین قویتر نیاز دارید، از «نوع branded» استفاده کنید:
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;
}
۵.۴ عددهای صحیح بزرگ
type: "integer" پس از عبور مقادیر از 2^53 معادل number نیست. در قرارداد اینگونه بنویسید:
{ "type": "string", "pattern": "^[0-9]+$", "description": "شماره سفارش؛ از 2^53 میگذرد، باید رشته باشد" }
۶. فرایند انتشار پیشنهادی
- تعریف قرارداد: با نوشتن ساختار درخواست/پاسخ بهصورت schema (یا zod) شروع کنید؛
- تولید نوع:
.d.tsرا در CI تولید و در مخزن کامیت کنید تا تفاوتها آسان قابل بازبینی باشند؛ - تولید اعتبارسنج: همان schema را به تابع اعتبارسنجی Ajv کامپایل کنید و در هر نقطه ورود سرور صدا بزنید؛
- تولید مستندات: Markdown یا OpenAPI از schema تولید کنید تا مستندات هرگز دستی کهنه نشوند؛
- رگرسیون: fixtureهای API را از هر دو «بررسی نوع» و «اعتبارسنجی schema» عبور دهید؛ فقط پاس شدن هر دو حساب میشود.
۷. چکلیست
- [ ] دقیقاً یک منبع حقیقت وجود دارد؛ نسخه دیگر با اسکریپت تولید میشود؛
- [ ] اسکریپت تولید در CI اجرا میشود و هر تفاوت محصول ساخت را شکست میدهد؛
- [ ]
optionalوnullableدر قرارداد صریحاً متمایز شدهاند؛ - [ ] فیلدهای enum برای مقادیر ناشناخته جایگزین دارند؛
- [ ] عددهای صحیح بزرگ، پول و زمان قرارداد صریح در هم schema و هم نوعها دارند؛
- [ ] فایلهای نوع تولیدشده سرآیند «دستی ویرایش نکنید» دارند.
پرسشهای متداول
چگونه بین schema-first و types-first تصمیم بگیرم؟
وقتی بکاند قرارداد را دارد، استفاده بینزبانی لازم است یا قرارداد عمومی منتشر میشود schema-first را انتخاب کنید و نوعها را با json-schema-to-typescript تولید کنید. وقتی فرانتاند رهبر است و میخواهد نوع و اعتبارسنجی یک بار تعریف شوند، کتابخانهای به سبک zod که «schema همان نوع است» انتخاب کنید و سپس JSON Schema صادر کنید. هر مسیر به گام CI نیاز دارد که محصولات تولیدشده را با فایلهای مخزن مقایسه کند.
چرا تعریفهای دستنویس نوع و schema همیشه منحرف میشوند؟
نوعها زمان کامپایل پاک میشوند در حالی که schemaها فقط زمان اجرا اثر دارند، و این دو را افراد مختلف در زمانهای مختلف ویرایش میکنند. بدون تولید و مقایسه، یک نسخه بهروز میشود و دیگری میماند، که «کامپایل میشود، بعد در زمان اجرا ۵۰۰ برمیگرداند» تولید میکند.
آیا فایلهای نوع تولیدشده را در مخزن کامیت کنم؟
بله. کامیت کردنشان به بازبینها تغییر قرارداد را نشان میدهد و CI میتواند خروجی تولید تازه را با فایلهای مخزن مقایسه و روی هر تفاوت شکست بخورد. اگر محصولات را فقط در پوشه محلی یا کش ساخت بگذارید، سه ماه بعد هیچکس نمیداند بهروزند یا نه.