مزامنة ثنائية الاتجاه بين المخطط وأنواع TypeScript
الخلاصة: الأنواع والمخططات يغطي كل منهما مقطعًا واحدًا: أحدهما وقت الترجمة والآخر وقت التشغيل. السؤال ليس أيّهما تختار، بل كيف تُبقي الاثنين متزامنين تلقائيًا.

الخلاصة: اختر مصدر حقيقة واحدًا أولًا — المخطط أولًا عندما يملك الخادم العقد، أو الأنواع أولًا بنمط zod عندما تملك الواجهة — ثم ولّد النسخة الأخرى ووازن بينهما في CI؛ واكتب يدويًا فقط الدلالات التي لا يستطيع المولّد التعبير عنها، مثل الأنواع الموسومة.
إذا كتبت التعريفين يدويًا فسيتباعدان. وثمن التباعد هو "يُترجم، ثم يعيد 500 في وقت التشغيل."
1. لماذا لا يكفي الاحتفاظ بنسخة واحدة
| القدرة | أنواع TypeScript | JSON Schema |
|---|---|---|
| الفحص وقت الترجمة | ✓ | ✗ |
| التحقق في وقت التشغيل | ✗ (تُمحى الأنواع) | ✓ |
| الاستخدام عبر اللغات | ✗ | ✓ (يمكن لـ Java/Go/Python قراءته كلها) |
| الإكمال وإعادة الهيكلة في IDE | ✓ | مقبول إلى حد ما |
| توليد التوثيق | مقبول إلى حد ما | ✓ (نظام OpenAPI البيئي) |
مدخل API يأتي من الشبكة وهو دائمًا unknown، فلا يمكن تخطي التحقق في وقت التشغيل؛ وفي الوقت نفسه تحتاج أثناء كتابة شيفرة العمل إلى إكمال الأنواع.
والنهج السليم هو اختيار أحدهما مصدر حقيقة وتوليد الآخر.
2. المسار أ: المخطط أولًا
مناسب لـ: الأعمال التي يقودها الخادم، والاستخدام عبر اللغات، والعقود العامة.
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* يولّد تعريفات أنواع TypeScript من JSON Schema.
* @param schemaPath مسار ملف المخطط
* @param outputPath مسار ملف الخرج
* @returns وعد يُحل عند اكتمال التوليد
*/
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→ اتحاد أنواع حرفية، وهو أدق بكثير من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 }
والميزة أنه لا توجد خطوة توليد، وأن الأنواع متزامنة دائمًا مع المخطط؛ والعيب أن المخطط يجب كتابته بـ as const، وأن سيناريوهات $ref المعقدة تُترجم ببطء.
3. المسار ب: الأنواع أولًا
مناسب لـ: الأعمال التي تقودها الواجهة بمكتبات مثل zod / valibot، حيث "المخطط هو النوع".
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>;
/** العقد المنشور للغات الأخرى */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
هنا يؤدي zod الدورين — "تعريف النوع" و"التحقق في وقت التشغيل" — ولا يكون JSON Schema إلا أصلًا مُصدَّرًا. وهذا أقل جهد إجمالًا، لكن ضع في الحسبان أمرين:
z.inferوJSON Schema المولّد لا يتطابقان تمامًا فيdefaultوtransformوrefine؛- قد يحتوي المخطط المولّد على
$refوdefinitions، فتحقق قبل النشر من مطابقته للمواصفة الهدف.
4. الاتجاه العكسي يحتاج فحصًا أيضًا
أيًا كان المسار الذي تختاره، أضف فحصًا في CI: يجب أن تطابق الأصول المولّدة الملفات في المستودع.
# ولّد في مجلد مؤقت وقارن؛ وأفشل CI عند أي اختلاف
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
وبدون هذه الخطوة، لن يعرف أحد بعد ثلاثة أشهر هل api.d.ts محدّث.
5. أربعة فخاخ عالية التكرار
5.1 optional وnullable ليسا الشيء نفسه
type A = { name?: string }; // قد يغيب المفتاح
type B = { name: string | null }; // يجب أن يوجد المفتاح، لكن القيمة قد تكون null
وبمصطلحات المخطط:
{ "properties": { "name": { "type": "string" } } } // اختياري
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // قابل لـ null
وتتعامل المولّدات مع الحالتين بطرق مختلفة تمامًا. ويجب أن يذكر العقد أيّهما تقصد؛ وإلا فسيفشل data.name?.length في الواجهة عند null.
5.2 التعدادات المتطورة
type Status = 'pending' | 'paid' | 'refunded';
فإن أضاف الخادم 'cancelled' ولم تُحدَّث أنواع الواجهة، وقعت الشيفرة في فرع default.
ويجب أن تتعامل الحقول الشبيهة بالتعداد مع القيم غير المعروفة صراحةً:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 التواريخ والمال لا تستطيعان استخدام string ببساطة
في المخطط، format: "date-time" تعليق فقط؛ وفي TypeScript هي مجرد string.
وعندما تحتاج إلى ضمان أقوى، استخدم "نوعًا موسومًا":
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* يوسم سلسلة نصية كطابع زمني ISO.
* @param value سلسلة تحقق تنسيقها مسبقًا
* @returns سلسلة الطابع الزمني الموسومة
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 الأعداد الصحيحة الكبيرة
type: "integer" لا تكافئ number بمجرد أن تتجاوز القيم 2^53. فاكتبها في العقد هكذا:
{ "type": "string", "pattern": "^[0-9]+$", "description": "رقم طلب؛ يتجاوز 2^53، فيجب أن يكون سلسلة" }
6. عملية طرح مقترحة
- عرّف العقد: ابدأ بكتابة بنية الطلب/الاستجابة مخططًا (أو zod)؛
- ولّد الأنواع: ولّد
.d.tsفي CI وأودعه في المستودع حتى يسهل مراجعة الفروق؛ - ولّد المدققات: اترجم المخطط نفسه إلى دالة تحقق Ajv، واستدعها عند كل نقطة دخول على الخادم؛
- ولّد التوثيق: أصدر Markdown أو OpenAPI من المخطط حتى لا يتقادم التوثيق يدويًا؛
- الانحدار: مرّر عينات API عبر "فحص الأنواع" و"التحقق من المخطط" معًا؛ ولا يُحتسب إلا اجتيازهما معًا.
7. قائمة التحقق
- [ ] يوجد مصدر حقيقة واحد بالضبط؛ والنسخة الأخرى يُولّدها سكربت؛
- [ ] يعمل سكربت التوليد في CI، وأي اختلاف في الأصول يفشل البناء؛
- [ ]
optionalوnullableمتميزان صراحةً في العقد؛ - [ ] لحقول التعداد بديل احتياطي للقيم غير المعروفة؛
- [ ] الأعداد الصحيحة الكبيرة والمال والوقت لها عادات صريحة في المخطط والأنواع معًا؛
- [ ] ملفات الأنواع المولّدة تحمل ترويسة "لا تعدّل يدويًا".
الأسئلة الشائعة
كيف أقرر بين المخطط أولًا والأنواع أولًا؟
اختر المخطط أولًا عندما يملك الخادم العقد، أو يحتاج إلى استخدام عبر اللغات، أو ينشر العقد علنًا، وولّد الأنواع بـ json-schema-to-typescript. واختر مكتبة بنمط zod حيث "المخطط هو النوع" عندما تقود الواجهة وتريد تعريف الأنواع والتحقق مرة واحدة، ثم تصدير JSON Schema. وأي مسار يحتاج إلى خطوة CI تقارن الأصول المولّدة بملفات المستودع.
لماذا تتباعد تعريفات الأنواع والمخططات المكتوبة يدويًا دائمًا؟
تُمحى الأنواع وقت الترجمة بينما لا تسري المخططات إلا في وقت التشغيل، والاثنان يعدّلهما أشخاص مختلفون في أوقات مختلفة. وبدون التوليد والمقارنة تُحدَّث نسخة وتبقى الأخرى جامدة، فينتج "يُترجم، ثم يعيد 500 في وقت التشغيل".
هل أودِع ملفات الأنواع المولّدة في المستودع؟
نعم. فإيداعها يتيح للمراجعين رؤية تغييرات العقد، ويستطيع CI مقارنة ناتج التوليد الجديد بملفات المستودع والفشل عند أي اختلاف. وإن تركت الأصول في مجلد محلي أو ذاكرة بناء فقط، فلن يعرف أحد بعد ثلاثة أشهر هل هي محدّثة.