Menu

Branded Types ב-TypeScript: טיפוסים נומינליים עם דוגמאות

branded type הוא פרימיטיבי עם תגית בלתי נראית, כמו string & { readonly __brand: "UserId" }, כך שאי אפשר להעביר UserId במקום שמצפה ל-OrderId. כאן תלמדו איך brands עובדים, פונקציות בנייה שמבצעות ולידציה, עוזר Brand גנרי, brands עם unique symbol ומספרים עם brand.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

TypeScript משווה טיפוסים לפי מבנה, ולכן שני aliases של string ניתנים להחלפה. branded type מוסיף תגית שקיימת רק במערכת הטיפוסים, string & { readonly __brand: "UserId" }, וזה הופך UserId ללא תואם למחרוזת רגילה ולכל brand אחר:

בזמן ריצה userId הוא פשוט המחרוזת "u_42". ה-brand הוא תווית של זמן קומפילציה, והתפקיד היחיד שלו הוא למנוע ערבוב של מזהים, יחידות מידה ומחרוזות שעברו ולידציה.

הבעיה: aliases הם רק שמות

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

זו טיפוסיות מבנית (structural typing): TypeScript בודקת שהמבנה מתאים, ו-string מתאים ל-string. באובייקטים המבנים בדרך כלל שונים; במזהים, כתובות אימייל, מטבעות ויחידות מידה, הם אף פעם לא שונים. brands מתקנים את המקרה הזה.

איך ה-brand עובד

string & { readonly __brand: "UserId" } הוא intersection: ערך חייב להיות מחרוזת וגם להכיל מאפיין __brand מטיפוס "UserId". לאף מחרוזת אמיתית אין את המאפיין הזה, ולכן אף מחרוזת רגילה לא ניתנת להשמה אליו:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

הכיוון החשוב עדיין עובד: UserId הוא מחרוזת, ולכן אפשר להעביר אותו לכל דבר שמקבל מחרוזת, לקרוא עליו ל-.startsWith() או לשים אותו ב-template. ה-brand חוסם רק את הדרך פנימה.

פונקציות בנייה שמבצעות ולידציה

ה-assertion as UserId הוא הדרך היחידה פנימה, ו-assertion לא בודק כלום. שימו אותו בפונקציה אחת שמבצעת ולידציה לקלט, ואז ידוע שכל ערך עם brand בתוכנית עבר את הבדיקה הזו:

sendWelcome אף פעם לא בודקת שוב את הקלט שלה, כי טיפוס הפרמטר שלה אומר שהבדיקה כבר קרתה. זה הרעיון של "parse, don't validate": בודקים בגבול, ואז נושאים את ההוכחה בטיפוס. גם type guard עובד כפונקציית בנייה כשמעדיפים ערך בוליאני על פני חריגה: function isEmail(s: string): s is Email.

עוזר Brand גנרי

כתיבת ה-intersection ביד לכל טיפוס נהיית חזרתית. גנרי קטן עושה את זה פעם אחת:

הוספת brand למספר עובדת בדיוק כמו למחרוזת. שימו לב ש-amount / 100 הוא number רגיל: פעולות חשבון על מספר עם brand נותנות תוצאה בלי brand, כפי שמוסבר בהמשך.

brands עם unique symbol

שם מאפיין כמחרוזת כמו __brand נראה כמו מאפיין אמיתי: userId.__brand עובר בדיקת טיפוסים כ-"UserId" אבל הוא undefined בזמן ריצה, ושתי ספריות עלולות לבחור באותו שם. מפתח מסוג unique symbol נמנע משתי הבעיות:

declare const brand: unique symbol מצהיר על symbol שקיים רק בשביל בודק הטיפוסים; מילת המפתח declare אומרת שלא נוצר עבורו JavaScript. מכיוון שה-symbol לא מיוצא מהמודול שלו, קוד בקבצים אחרים לא יכול אפילו לציין את מאפיין ה-brand, ולכן מחוץ למודול הזה הדרכים היחידות לקבל Meters הן הפונקציות שאתם מייצאים או assertion של as Meters.

ל-brands אין עלות בזמן ריצה

בפלט המקומפל אין שום זכר ל-brand. אלה השורות שנוצרות עבור הדוגמה של Meters, מתחת לכותרת המודול שהקומפיילר מוסיף: ה-declare, שני ה-type aliases וכל ה-as נעלמו, והקריאה המושתקת בשורה האחרונה עדיין רצה.

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

ערך עם brand הוא הפרימיטיבי הרגיל: typeof נותן "string" או "number", JSON.stringify כותב אותו כרגיל, והשוואות עובדות כמו קודם. הצד השני הוא ששום דבר לא נבדק בזמן ריצה, אלא אם פונקציית הבנייה שלכם בודקת. מידע שפוענח מ-JSON, ממסד נתונים או מ-URL מגיע כ-string, והוא הופך ל-UserId רק כשמעבירים אותו דרך הפונקציה הזו.

פעולות חשבון ומתודות מאבדות את ה-brand

פעולות על ערך עם brand מחזירות את טיפוס הבסיס, כי ה-brand לא חלק ממה ש-+ או .slice() מייצרים:

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

בדרך כלל זה מה שרוצים: חיבור של שני סכומים באגורות נותן אגורות, אבל כפל של אגורות באגורות לא, ורק אתם יודעים אילו פעולות שומרות על המשמעות. כתבו עוזרים קטנים כמו addCents(a: Cents, b: Cents): Cents לפעולות שהקוד שלכם צריך.

מתי להשתמש ב-branded types

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

מצבbrands לדוגמה
מזהים מטבלאות שונותUserId, OrderId, ProductId
מחרוזות שעברו ולידציהEmail, Url, NonEmptyString, Slug
יחידות מידה ומטבעותMeters, Feet, Cents, Usd, Eur
טקסט שעבר ניקוי או escapingSafeHtml, SqlIdentifier
מספרים בטווח מסויםPercentage, PositiveInt

ותרו עליהם לערכים שאף פעם לא מתבלבלים ביניהם, ולטיפוסי אובייקט שכבר שונים במבנה. ספריות ולידציה יכולות לייצר branded types מסכמה: ב-Zod, z.string().brand<"UserId">() נותן סכמה שה-parse שלה מחזיר UserId עם brand, וזה חוסך את הכתיבה הידנית של פונקציות הבנייה.

שאלות נפוצות

מה הם branded types ב-TypeScript?

דפוס שהופך שני טיפוסים עם אותו ייצוג בזמן ריצה ללא תואמים. עושים חיתוך (intersection) של טיפוס הבסיס עם תגית שאין לאף ערך רגיל: type UserId = string & { readonly __brand: "UserId" }. מחרוזת רגילה, או OrderId עם תגית אחרת, נדחים אז במקום שמצפה ל-UserId.

האם יש ב-TypeScript טיפוסים נומינליים?

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

האם ל-branded types יש עלות בזמן ריצה?

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

איך יוצרים ערך של branded type?

עם type assertion, רצוי בפונקציה קטנה אחת שבודקת קודם את הקלט: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. כשה-as נמצא רק במקום הזה, כל Email בתוכנית עבר את הבדיקה.

מה ההבדל בין type alias ל-branded type?

type UserId = string הוא רק שם חדש: כל מחרוזת מתקבלת בכל מקום שמצפה ל-UserId. type UserId = string & { readonly __brand: "UserId" } הוא טיפוס חדש שלא תואם: מחרוזת רגילה חייבת לעבור קודם דרך פונקציית בנייה או assertion.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל