JSON ל־Zod

יצירת סכימות Zod מ־JSON, לכל אחת טיפוס z.infer משלה: איברי מערך ממוזגים, מפתחות אופציונליים ו־nullable מסומנים, ושום דבר אינו מנוחש מהדגימה.

קלט
סכימת Zod
import * as z from "zod"

export const Customer = z.object({
  name: z.string(),
  email: z.string(),
  phone: z.null(),
})
export type Customer = z.infer<typeof Customer>

export const LineItem = z.object({
  sku: z.string(),
  quantity: z.number(),
  price: z.number(),
  note: z.string().nullable(),
  backordered: z.boolean().optional(),
})
export type LineItem = z.infer<typeof LineItem>

export const Root = z.object({
  id: z.string(),
  customer: Customer,
  lineItems: z.array(LineItem),
  tags: z.array(z.unknown()),
})
export type Root = z.infer<typeof Root>

תחביר Zod 4 שתקף גם ב־Zod 3. כל מפתח ש־z.object לא מפרט מושמט, כך ששדה שלא היה בדגימה שלכם יוסר בשקט מהנתונים שהסכימה מנתחת.

  • בכל אחד מהמיקומים שלהלן, הדגימה שלכם הכילה רק null, ולכן הסכימה לא מקבלת שם שום ערך אחר.

    מיקומים: ‎1

    • Customer.phone
  • בכל אחד מהמיקומים שלהלן, כל מספר היה שלם. z.number()‎ מקבל גם 7.5, ואילו ‎.int()‎ היה מחייב מספרים שלמים, לכן הוסיפו אותו בעצמכם רק היכן שאתם יודעים שהערך חייב להיות שלם.

    מיקומים: ‎1

    • LineItem.quantity
  • בכל אחד מהמיקומים שלהלן לא ניתן היה להסיק דבר, ולכן הסכימה לא בודקת את מה שנמצא שם: z.unknown()‎ מקבל כל ערך, ו־z.record(z.string(), z.unknown())‎ כל אובייקט.

    מיקומים: ‎1

    • Root.tags[]

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

טיפוס של TypeScript נבדק כשהקוד שלכם עובר הידור, וכבר אינו קיים כשהקוד רץ. סכימת Zod היא החלק שנשאר: היא רצה בתוך התוכנית שלכם ובודקת כל מטען נתונים ברגע שהוא מגיע — תגובה של API, גוף של webhook, הודעה שנשלפה מתור — לפני שהקוד שלכם סומך עליו. הדביקו דגימה של ה־JSON הזה, והעמוד הזה יכתוב בשבילכם את הסכימות, מוכנות להדבקה לתוך מודול: כל אובייקט שיש לו מפתחות הופך ל־"z.object" בעל שם, איברי כל מערך ממוזגים לאחד, ואחרי כל סכימה בא טיפוס ה־TypeScript שהיא מפיקה.

זו התשובה שהעמוד JSON ל־TypeScript נותן לאותו JSON, כתובה כמאמת ולא כטיפוסים. שני העמודים קוראים הסקה אחת, ולכן אילו מפתחות הם אופציונליים, אילו ערכים הופכים לאיחוד, היכן null נשמר בנפרד ממפתח חסר ואיך נקרא כל אובייקט מקונן — כל אלה מוכרעים פעם אחת ורק נכתבים פעמיים. כללי המיזוג האלה הם הנושא של המדריך שבעמוד JSON ל־TypeScript, והם לא מסופרים כאן שוב. המדריך הזה עוסק במה שהסכימה עושה עם נתונים אמיתיים ברגע שהיא רצה, ובמה שהיא משאירה לכם להחליט.

מה עובר, ומה נדחה

סכימה מהעמוד הזה מקבלת את הדגימה שממנה נכתבה, ב־Zod 3 וב־Zod 4 כאחד, חוץ מהמקרה האחד שיש לו ממצא משלו: מספר אינסופי ב־Zod 4. מעבר לדגימה הזאת היא מקבלת כל מה שנשאר בגבולות מה שהסכימה אומרת, ועבור הנתונים שבאמת יגיעו אליכם זה נראה כך:

  • עד מגבלת העומק, מפתח שנכתב בלי "‎.optional()‎" הוא חובה. מטען שמשמיט אותו נדחה, וכך גם מטען שמציב בו סוג ערך שהסכימה לא נוקבת בו — מחרוזת במקום שבו כתוב "z.number()‎", אובייקט במקום שבו כתוב "z.string()‎".
  • מפתח שנכתב עם "‎.optional()‎" רשאי להיעדר, ומפתח שנכתב עם "‎.nullable()‎" רשאי להחזיק null. אף אחד מהם לא מעביר שום סוג ערך אחר, כך שמפתח אופציונלי שכן מופיע עדיין חייב להחזיק את מה שהסכימה נוקבת בו.
  • "z.union" מקבל כל אחת מהחלופות שלו ושום דבר אחר. "z.array" מקבל כל מספר של איברים, גם אפס, כל עוד כל אחד מהם מתאים לסכימה שנכתבה לאיברים שלו.
  • היכן שהדגימה לא הראתה דבר — איברי מערך ריק, אובייקט בלי מפתחות, ערך שמקונן מעבר למגבלת העומק — הסכימה לא בודקת את מה שנמצא שם: היא מקבלת כל ערך, או כל אובייקט במקום שבו לאובייקט שבדגימה לא היו מפתחות, וממצא אומר היכן.

מפתח שהסכימה לא מפרטת מועבר ואז מושמט מהתוצאה; זו ההשמטה, ויש לה פרק משלה. מפתח אחד עומד מחוץ לכל זה. מפתח בשם "__proto__" נכתב כמפתח מחושב, בסוגריים מרובעים, מפני שאילו נכתב כרגיל הוא היה קובע את ה־prototype של האובייקט שבו הוא יושב במקום להגדיר מפתח — ובכל זאת אף אחת משתי הגרסאות של Zod לא מחזירה את המפתח הזה במה שהניתוח מחזיר, ו־Zod 4 אף לא בודק את הערך שלו כלל.

למה הסכימה אף פעם לא מהדקת את עצמה

לכל דגימה יש דברים משותפים שהיא לא יכולה להוכיח: כל מזהה מספר שלם, כל אימייל בצורה של כתובת אימייל, תפקיד שתמיד היה admin. זו סדירות, וסדירות אינה בדיקה. מחולל מתפתה לרשום אותה בכל זאת — "‎.int()‎" על המזהים, "z.email()‎" על הכתובות, literal על התפקיד — והמחולל הזה אף פעם לא עושה זאת, מפני שדגימה מראה מה הנתונים שלכם יכולים להכיל, ואף פעם לא מה הם חייבים להכיל. טיפוס הדוק יותר מהנתונים שלכם עולה לכם בשגיאת הידור על המכונה שלכם. סכימה הדוקה יותר מהנתונים שלכם עולה לכם בבקשה שנדחית בפרודקשן: התפקיד הראשון שאינו admin, המזהה הראשון שערכו 7.5, הכתובת הראשונה שהתבנית לא צפתה מראש.

הבדיקות גם היו זזות מתחתיכם. "z.uuid()‎" של Zod 4 דוחה מחרוזות בצורה של UUID ש־"‎.uuid()‎" של Zod 3 קיבל, מפני שהוא בודק את סיביות ה־variant, ו־"‎.int()‎" של Zod 4 דוחה מספר שלם מחוץ לטווח הבטוח ש־Zod 3 העביר — כך שבדיקה שנוחשה מהדגימה של היום הייתה בדיקה אחרת בכל Zod שתתקינו. לכן שום דבר שהדגימה רק מרמזת עליו לא נכתב לתוך הסכימה. היכן שזה משנה כשהנתונים מגיעים, העמוד אומר לכם זאת בממצא במקום זה, והעריכה נשארת בידיכם.

מה שהסכימה לא תאמר בעצמה

מתחת לסכימה, העמוד מפרט את מה שהבחין בו ולא כתב לתוכה: ממצא לכל סוג שלהלן שחל על הדגימה, פעם אחת ועם כל מיקום שבו הוא תקף. מיקום נכתב כפי שהפלט קורא לדברים — "Customer.phone" למפתח, "Root.tags[]‎" לאיברים של מערך, והמפתח במירכאות ובסוגריים מרובעים היכן שאינו שם תקין ב־JavaScript ועבור "__proto__" — כדי שאפשר יהיה למצוא אותו בסכימה במבט אחד. הדוגמה שנטענת עם העמוד מציגה כל סוג מלבד זה של Infinity.

  • רק null. הדגימה אף פעם לא החזיקה במיקום הזה שום דבר מלבד null, ולכן הסכימה אומרת "z.null()‎" ודוחה את הערך האמיתי הראשון. החליטו מה השדה יחזיק כשהוא יתמלא וכתבו זאת בעצמכם — "z.string().nullable()‎", למשל — או הדביקו דגימה שבה יש לו ערך.
  • Infinity. מספר שחורג ממה שמספר ב־JavaScript יכול להכיל, כמו 1e999, נהיה Infinity או ‎-Infinity כשה־JSON מפוענח. "z.number()‎" של Zod 4 דוחה מספר אינסופי וזה של Zod 3 מקבל אותו, כך שב־Zod 4 זה המקרה האחד שבו סכימה דוחה את הדגימה עצמה שממנה נוצרה. השאלה שזה מעלה היא על הנתונים ולא על הסכימה: הספרות אובדות עוד לפני שמאמת כלשהו רואה אותן, ולכן אם הערך הזה צריך בכלל להיות מספר — על זה צריך לענות מי שכתב אותו.
  • מספרים שלמים. כל מספר במיקום הזה היה שלם, ו־"z.number()‎" מקבל גם 7.5. היכן שערך חייב להיות שלם — מזהה, ספירה, כמות — הוסיפו "‎.int()‎" בעצמכם; היכן שזה מחיר שבמקרה יצא עגול, השאירו אותו כמו שהוא. הממצא לא ניתן לאף מיקום שמחזיק מספר שלם מחוץ לטווח הבטוח, מ־"Number.MIN_SAFE_INTEGER" ועד "Number.MAX_SAFE_INTEGER", מפני ש־"‎.int()‎" של Zod 4 דוחה אותם, ועצה שהייתה גורמת לסכימה לדחות את הדגימה שלה עצמה היא הסוג האחד שהעמוד לא ייתן.
  • לא ניתן להסיק דבר. איברי מערך ריק, אובייקט בלי מפתחות וערך שמקונן יותר מ־100 רמות לעומק לא נותנים להסקה שום דבר להישען עליו, ולכן הסכימה לא בודקת את מה שנמצא שם: "z.unknown()‎" מקבל כל ערך שהוא, ו־"z.record(z.string(), z.unknown())‎" כל אובייקט. הדביקו דגימה שבה למערך הזה יש איברים ולאובייקט הזה יש מפתחות, או כתבו את החלק הזה של הסכימה ביד.

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

מפתחות שהסכימה לא מפרטת מושמטים

"z.object" מעביר אובייקט שנושא מפתחות שהוא לא מפרט, ומשאיר אותם מחוץ למה שהוא מחזיר. זו ברירת המחדל של Zod בשתי הגרסאות, והדבר השקט ביותר שסכימה עושה: שדה שבמקרה לא היה בדגימה שלכם נעלם מהנתונים שהקוד שלכם מקבל, בלי שום שגיאה שתאמר זאת. זו הסיבה שהמשפט שמתחת לכל סכימה בעמוד הזה מזכיר את זה.

העמוד לא בוחר בשבילכם בין קפדני למתירני, מפני שכל אחד מהם טוען יותר ממה שדגימה יכולה להראות. אובייקט קפדני דוחה כל מפתח שהוא לא מפרט — אין מפתחות מלבד אלה — ושום דגימה לא יכולה להוכיח זאת לגבי המטען הבא. אובייקט מתירני שומר מפתחות עודפים, והטיפוס המוסק שלו מקבל עבורם חתימת אינדקס (index signature), כך שהוא היה מפסיק להיות התשובה של עמוד ה־TypeScript. ההשמטה היא ההתנהגות האחת שמקבלת את מה שהטיפוס של TypeScript מקבל ועדיין מסיקה את אותו טיפוס — ערך עם מאפיינים עודפים מקיים גם ממשק. כדי לבחור אחרת, שנו את הסכימה ביד:

  • כדי לדחות מפתחות שהסכימה לא מפרטת, כתבו "z.strictObject" במקום שבו הפלט כותב "z.object" ב־Zod 4, או שרשרו "‎.strict()‎" על ה־"z.object" ב־Zod 3.
  • כדי לשמור אותם, כתבו "z.looseObject" ב־Zod 4, או שרשרו "‎.passthrough()‎" ב־Zod 3. שתי המתודות האלה של Zod 3 עדיין רצות ב־Zod 4, שמסמן אותן כ־legacy.

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

שם אחד לכל סכימה ולטיפוס שלה

אחרי כל סכימה בא הטיפוס שלה — "export type Customer = z.infer<typeof Customer>‎" מיד אחרי "export const Customer" — וכך zod.dev כותב את הדוגמאות שלו עצמו: שם אחד לערך שבודק את הנתונים ולטיפוס שהוא מפיק, מפני ש־TypeScript מחזיקה ערכים וטיפוסים במרחבי שמות נפרדים. הטיפוס הזה הוא אותו טיפוס שהעמוד JSON ל־TypeScript מדפיס עבור אותו JSON, שם מול שם ומפתח מול מפתח, עם אותם מפתחות אופציונליים, אותם איחודים ו־null באותם מקומות — פרט לחריגים שלהלן.

המאגר בודק את ההבטחה הזאת במקום לסמוך עליה: אוסף של דגימות עובר דרך שני העמודים ואז דרך המהדר של TypeScript עם כל אחת מגרסאות Zod, והמהדר נשאל, עבור כל שם, אם שני הטיפוסים זהים ואם כל אחד מהם ניתן להשמה לשני. היכן שהוא עונה אחרת, המקום נמצא עמוק בתוך המסמך. מעבר למגבלת העומק, מפתח שמחזיק "z.unknown()‎" מוסק ב־Zod 3 כאופציונלי, בעוד שעמוד ה־TypeScript עושה אותו חובה. וב־Zod 4 המהדר מוותר על הטיפוס של מערך שמקונן עשרות רמות לעומק, ומדווח על שגיאה TS2589 בשורה של הטיפוס עצמו; הסכימה שמעל השורה הזאת עדיין רצה ועדיין מקבלת את הדגימה, ורק הטיפוס המוסק אובד.

שתי ההשוואות קוראות מפתח אופציונלי כפי ש־TypeScript קוראת אותו כברירת מחדל. תחת האפשרות "exactOptionalPropertyTypes", שכבויה אלא אם פרויקט מפעיל אותה, ה־"z.infer" של מפתח אופציונלי מקבל גם undefined מפורש, בשתי הגרסאות של Zod, בעוד שהטיפוס של עמוד ה־TypeScript לא מקבל אותו.

נכתב עבור Zod 4, ועדיין תקף ב־Zod 3

הפלט נצמד למה שיש בשתי הגרסאות הראשיות — "z.object", "z.array", "z.union" על פני שתי חלופות או יותר, "z.string()", "z.number()", "z.boolean()", "z.null()", "z.unknown()", "z.record(z.string(), z.unknown())", ".optional()", ".nullable()‎" ו־"z.infer" — תחת שורת ה־import שהדוגמאות של zod.dev עצמו פותחות בה. שום דבר בו לא נוסף ב־Zod 4 ושום דבר בו אינו מסומן שם כמיושן, כך שפרויקט שעוד לא עבר מ־Zod 3 יכול להדביק אותו כמו שהוא.

עם זאת, אותו טקסט לא מתנהג באופן זהה בשתיהן, וכל הבדל נאמר במדריך הזה במקום שבו הוא משנה. "z.number()‎" של Zod 4 דוחה מספר אינסופי בעוד שזה של Zod 3 מקבל אותו, וזה ממצא ה־Infinity. מפתח שהערך שלו הוא "z.unknown()‎" הוא אופציונלי בטיפוס המוסק של Zod 3 וחובה בזה של Zod 4 — וחובה גם כשהנתונים מנותחים, החל מ־Zod 4.4 — ומפתח כזה הפלט הזה כותב רק מעבר למגבלת העומק. Zod 3 בודק מפתח בשם "__proto__" ו־Zod 4 לא. והמהדר מוותר על הטיפוס של Zod 4 עבור מערך שמקונן עשרות רמות לעומק, בעוד שאת הטיפוס של Zod 3 הוא מצליח לחשב.

הפלט נכתב עבור Zod הרגיל, עם מתודות: "z.string().nullable().optional()‎". ב־Zod Mini אותה סכימה נכתבת בפונקציות במקום זאת, "z.optional(z.nullable(z.string()))‎", ולכן Zod Mini לא יכול להריץ את הפלט כמו שהוא.

למה השורש בא אחרון

עמוד ה־TypeScript מדפיס את השורש ראשון ואת האובייקטים שבהם הוא משתמש אחריו, מפני שמותר להשתמש בטיפוס לפני השורה שמצהירה עליו. בסכימה זה אסור: היא ערך, וקריאה של "const" מעל ההצהרה שלו זורקת ReferenceError בזמן שהמודול עדיין נטען. לכן כל סכימה כאן באה אחרי כל סכימה שהיא משתמשת בה, והשורש בא אחרון — Customer ו־LineItem ראשונים בדוגמה, ואחריהם ה־Root שמחזיק אותם — וזו הסיבה שהשורש שפותח את עמוד ה־TypeScript סוגר את העמוד הזה.

סדר כזה תמיד קיים. ההסקה היא עץ, וכל אובייקט שהיא מחלצת נמצא בשימוש ממקום אחד בדיוק, כך ששום סכימה לא צריכה להפנות לעצמה או לסכימה שמודפסת אחריה, והפלט לעולם לא צריך "z.lazy".

שאלות נפוצות

למה מזהה יוצא "z.number()‎" ולא "z.number().int()‎"?
מפני שדגימה יכולה להראות שכל מזהה עד עכשיו היה שלם, אבל לא שהמזהה הבא יהיה. העמוד אומר זאת במקום זה: ממצא המספרים השלמים מפרט כל מיקום שבו כל מספר היה שלם, והיכן שאתם יודעים שערך חייב להישאר שלם, הוספת "‎.int()‎" היא עריכה של מילה אחת. הממצא לא מוצג היכן שמספר הוא מספר שלם מחוץ לטווח הבטוח, מפני ש־"‎.int()‎" של Zod 4 היה דוחה את הדגימה ההיא עצמה.
למה כתובת אימייל יוצאת סתם "z.string()‎"?
מפני שמחרוזת שנראית כמו אימייל בדגימה שלכם לא אומרת דבר על המחרוזת הבאה, ובדיקת תבנית נכונה חייבת ללכת לפי הדפוסים של Zod עצמו, שמשתנים בין גרסאות — "z.uuid()‎" של Zod 4 כבר דוחה מחרוזות ש־"‎.uuid()‎" של Zod 3 העביר. תאריכים, כתובות URL ומזהי UUID נשארים מחרוזות מאותה סיבה, ושדה שתמיד החזיק רק ערכים ספורים אף פעם לא הופך ל־enum או ל־literal. אם אתם מכירים את הכלל, כתבו אותו לתוכה; הסכימה היא קוד רגיל שהוא שלכם.
למה הדגימה שלי עצמה נכשלת ב־Zod 4?
היא מחזיקה מספר שחורג ממה שמספר ב־JavaScript יכול להכיל — 1e999, למשל — שנהיה Infinity או ‎-Infinity כשה־JSON פוענח, ו־"z.number()‎" של Zod 4 דוחה מספר אינסופי בעוד שזה של Zod 3 מקבל אותו. ממצא ה־Infinity נוקב בכל מיקום שנוגע בדבר. חוץ מהמקרה הזה, סכימה תמיד מקבלת את הדגימה שממנה באה, מפני שכל ערך בדגימה נכנס לתוכה, והמאגר בודק זאת בשתי הגרסאות על פני אוסף של דגימות.
למה שדה ששלחתי נעלם מהתוצאה של הניתוח?
מפני שהסכימה לא מפרטת אותו. "z.object" מקבל אובייקט עם מפתחות עודפים ומחזיר אותו בלעדיהם, בשתי הגרסאות, ומפתח שלא היה בדגימה שלכם הוא מפתח שהסכימה אף פעם לא למדה. הוסיפו אותו לסכימה, או הפכו את האובייקט הזה בלבד למתירני — "z.looseObject" ב־Zod 4 או "‎.passthrough()‎" ב־Zod 3 — אם מפתחות לא מוכרים צריכים לעבור בלי שינוי.
הזזתי סכימה אחת אל מתחת לאחרת וקיבלתי ReferenceError. למה?
מפני שסכימה היא ערך, ו־JavaScript לא מאפשרת לקרוא ערך מעל השורה שמגדירה אותו. העמוד מדפיס כל סכימה אחרי כל סכימה שהיא משתמשת בה, עם השורש אחרון, בדיוק מהסיבה הזאת; השאירו סכימה מעל כל מה שמפנה אליה, והשגיאה נעלמת.
למה הסכימה נקראת Customer ולא CustomerSchema?
זו המוסכמה של zod.dev עצמו: לסכימה ולטיפוס שהיא מסיקה יש שם אחד משותף, ו־TypeScript מאפשרת זאת מפני שערכים וטיפוסים חיים במרחבי שמות נפרדים. השם עצמו הוא השם שהעמוד JSON ל־TypeScript נותן לאותו אובייקט, כך שזו אותה מילה בשני העמודים, לסכימה ולטיפוס שלה כאחד.
לאיזו גרסה של Zod הפלט נכתב?
ל־Zod 4, בלי שום דבר שאין ב־Zod 3, ולכן הוא רץ בלי שינוי בשתיהן. שתי הגרסאות נבדלות בכמה מקומות — מספר אינסופי, מפתח מעבר למגבלת העומק, מפתח בשם "__proto__" והטיפוס של מערך שמקונן לעומק רב מאוד — וכל אחד מהם מוסבר למעלה. זה Zod רגיל עם מתודות משורשרות; Zod Mini מאיית את אותה סכימה בפונקציות ואינו יכול להריץ אותו כמו שהוא.
אפשר להדביק נתונים אמיתיים, כולל פרטי גישה?
כן. הסכימה מחושבת בדפדפן שלכם: מה שאתם מדביקים נקרא על המכונה שלכם עצמכם, ואינו נשלח לשרת, נשמר או נרשם. גם הסכימה עצמה לא מכילה אף אחד מהערכים שלכם, רק את המפתחות שלכם ואת סוג הערך שתחת כל אחד מהם, כך שטוקן בדגימה יוצא "z.string()‎" ותו לא.

כלים קשורים

  • JSON ל־TypeScript

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

  • JSON ל־Go

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

  • בודק JSONPath

    בדיקת שאילתות JSONPath לפי RFC 9535 מול JSON.

  • מחולל טבלאות Markdown

    בניית טבלאות Markdown מתוך CSV, TSV או JSON ויישור עמודות.