JSON Toolkit

סנכרון דו-כיווני בין Schema לטיפוסי TypeScript

שורה תחתונה: טיפוסים ו-schemas מכסים כל אחד מקטע אחר: אחד בזמן קומפילציה, אחד בזמן ריצה. השאלה אינה מה לבחור, אלא איך לשמור את השניים מסונכרנים אוטומטית.

בינוניכ-11 דקות קריאהפורסם 2026-10-30עודכן 2026-10-30
עטיפה עבור סנכרון דו-כיווני בין Schema לטיפוסי TypeScript

שורה תחתונה: בחרו קודם מקור אמת אחד — schema-first כשהבקנד מחזיק בחוזה, types-first בסגנון zod כשהפרונט מחזיק בו — ואז ייצרו את העותק השני ועשו לו diff ב-CI; כתבו ביד רק סמנטיקה שהמחולל לא יכול לבטא, כמו branded types.

אם תכתבו את שתי ההגדרות ביד, הן ייסחפו. מחיר הסחיפה הוא "זה מתקמפל, ואז מחזיר 500 בזמן ריצה".

1. למה להחזיק רק עותק אחד לא עובד

יכולת טיפוסי TypeScript JSON Schema
בדיקה בזמן קומפילציה ✓ ✗
אימות בזמן ריצה ✗ (טיפוסים נמחקים) ✓
שימוש בין שפות ✗ ✓ (Java/Go/Python יכולים כולם לקרוא אותו)
השלמה ו-refactoring ב-IDE ✓ כך-כך
יצירת תיעוד כך-כך ✓ (האקוסיסטם של OpenAPI)

הקלט של API מגיע מהרשת ותמיד unknown, ולכן אימות בזמן ריצה לא יכול להידלג; ובזמן כתיבת קוד עסקי עדיין צריך השלמת טיפוסים. הגישה ההגיונית היא לבחור אחד כמקור אמת ולייצר את השני.

2. מסלול A: 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 → איחוד של טיפוסי ליטרל, מדויק הרבה יותר מ-string;
  • additionalProperties: false קובע אם נוצר index signature.

יש גם אפשרות zero-build, ‏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 מורכבים מתקמפלים לאט.

3. מסלול B: טיפוסים קודם

מתאים ל: עבודת פרונט עם ספריות כמו 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, ולכן בדקו לפני פרסום אם הוא מתאים למפרט היעד.

4. גם הכיוון ההפוך צריך בדיקה

לא משנה איזה מסלול בוחרים, הוסיפו בדיקת 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 עדכני.

5. ארבע מלכודות בתדירות גבוהה

5.1 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.

5.2 enums שמתפתחים

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

אם הבקנד מוסיף 'cancelled' והטיפוסים בפרונט לא מתעדכנים, הקוד נופל לענף ה-default. שדות דמויי enum צריכים לטפל בערכים לא מוכרים במפורש:

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

5.3 תאריכים וכסף לא יכולים פשוט להשתמש ב-string

ב-schema, ‏format: "date-time" הוא רק הערה; ב-TypeScript הוא רק string. כשצריך הבטחה חזקה יותר, השתמשו ב"branded type":

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

5.4 מספרים שלמים גדולים

type: "integer" אינו שקול ל-number ברגע שהערכים עולים על 2^53. כתבו בחוזה:

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

6. תהליך הטמעה מוצע

  1. הגדירו את החוזה: התחילו בכתיבת מבנה הבקשה/תגובה כ-schema (או zod);
  2. ייצרו טיפוסים: ייצרו .d.ts ב-CI והעלו אותו למאגר כדי שקל יהיה לסקור הבדלים;
  3. ייצרו מאמתים: קמפלו את אותו schema לפונקציית אימות Ajv, וקראו לה בכל נקודת כניסה בשרת;
  4. ייצרו תיעוד: הפיקו Markdown או OpenAPI מה-schema כדי שהתיעוד לעולם לא יתיישן ביד;
  5. רגרסיה: הריצו את נתוני ה-API הקבועים (fixtures) גם ב"בדיקת טיפוסים" וגם ב"אימות schema"; רק מעבר של שניהם נחשב.

7. רשימת בדיקה

  • [ ] יש בדיוק מקור אמת אחד; העותק השני נוצר על ידי סקריפט;
  • [ ] סקריפט היצירה רץ ב-CI, וכל הבדל בארטיפקט מכשיל את הבנייה;
  • [ ] optional ו-nullable מובחנים במפורש בחוזה;
  • [ ] לשדות enum יש גיבוי לערכים לא מוכרים;
  • [ ] למספרים שלמים גדולים, לכסף ולזמן יש מוסכמות מפורשות גם ב-schema וגם בטיפוסים;
  • [ ] קבצי טיפוסים שנוצרים נושאים כותרת "do not edit by hand".

שאלות נפוצות

איך מחליטים בין schema-first ל-types-first?

בחרו schema-first כשהבקנד מחזיק בחוזה, צריך שימוש בין שפות או מפרסם את החוזה פומבית, וייצרו טיפוסים עם json-schema-to-typescript. בחרו ספרייה בסגנון zod שבה "ה-schema הוא הטיפוס" כשהפרונט מוביל ורוצה להגדיר טיפוסים ואימות פעם אחת, ואז לייצא JSON Schema. כל מסלול צריך שלב CI שמשווה את הארטיפקטים שנוצרו לקבצים במאגר.

למה הגדרות טיפוסים ו-schema שנכתבו ביד תמיד נסחפות?

טיפוסים נמחקים בזמן קומפילציה בעוד ש-schemas נכנסים לתוקף רק בזמן ריצה, והשניים נערכים על ידי אנשים שונים בזמנים שונים. בלי יצירה והשוואה, עותק אחד מתעדכן והשני נשאר במקום, מה שמפיק "זה מתקמפל, ואז מחזיר 500 בזמן ריצה".

כדאי להעלות את קבצי הטיפוסים שנוצרים למאגר?

כן. העלאה שלהם מאפשרת לסוקרים לראות שינויי חוזה, ו-CI יכול להשוות פלט יצירה חדש לקבצים במאגר ולהיכשל בכל הבדל. אם משאירים את הארטיפקטים רק בתיקייה מקומית או במטמון בנייה, אף אחד לא ידע כעבור שלושה חודשים אם הם עדכניים.