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 |
| טקסט שעבר ניקוי או escaping | SafeHtml, 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.