כאשר אתם ממדלים ישויות עם TypeScript, זה מאוד נפוץ לקבל ממשק כמו זה:
interface User {
id: number
username: string
}
interface Order {
id: number
userId: number
title: string
year: number
month: number
day: number
amount: { currency: 'EUR' | 'USD', value: number }
}
הבעיה
סוגי התכונות אין להם משמעות סמנטית. במונחים של סוגים, User.id
, Order.id
, Order.year
וכו' הם אותו הדבר: מספר, וכמספר הם ניתנים להחלפה, אך סמנטית, הם לא.
בהמשך לדוגמה הקודמת, אנו יכולים לקבל סט של פונקציות שמבצעות פעולות על הישויות. לדוגמה:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
הפונקציות הללו יקבלו כל מספר בכל ארגומנט לא משנה מה המשמעות הסמנטית של המספר. לדוגמה:
const id = getUserId()
deleteOrder(id)
ברור שזה טעות גדולה, וזה עשוי להיראות קל להימנע מקריאת הקוד, אבל הקוד לא תמיד פשוט כמו בדוגמה.
אותו הדבר קורה עם getOrdersFiltered
: אנו יכולים להחליף את הערכים של יום וחודש, ולא נקבל שום אזהרה או שגיאה. השגיאות יתרחשו אם היום גדול מ-12, אבל ברור שהתוצאה לא תהיה כמו שציפינו.
הפתרון
כללי הקליסטניקה של אובייקטים מספקים פתרון לכך: לעטוף את כל הפרימיטיבים ומחרוזות (הקשר של הפרימיטיב הוא אנטי-תבנית). הכלל הוא לעטוף את הפרימיטיבים באובייקט שמייצג משמעות סמנטית (DDD מתאר את זה כValueObjects
).
אבל עם TypeScript, אנחנו לא צריכים להשתמש במחלקות או אובייקטים לכך: אנחנו יכולים להשתמש במערכת הסוגים כדי להבטיח שמספר המייצג משהו שונה משנה לא יכול לשמש במקום שנה.
סוגים ממותגים
תבנית זו עושה שימוש בהרחבה של סוגים כדי להוסיף מאפיין שמבטיח את המשמעות הסמנטית:
type Year = number & { __brand: 'year' }
השורה הפשוטה הזו יוצרת סוג חדש שיכול לפעול כמספר — אבל לא מספר, זהו שנה.
const year = 2012 as Year
function age(year: Year): number { //... }
age(2012) // ❌ IDE will show an error as 2012 is not a Year
age(year) // ✅
כלליזציה של הפתרון
כדי למנוע כתיבת סוג עבור כל סוג מותג, אנו יכולים ליצור סוג עזר כמו:
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
שמשתמש בסמל ייחודי כשם מאפיין המותג כדי להימנע מעימותים עם המאפיינים שלך ומקבל את הסוג המקורי ואת המותג כפרמטרים גנריים.
עם זאת, אנו יכולים לבצע ריפרפ של המודלים והפונקציות שלנו כך:
type UserId = Branded<number, 'UserId'>
type OrderId = Branded<number, 'OrderId'>
type Year = Branded<number, 'Year'>
type Month = Branded<number, 'Month'>
type Day = Branded<number, 'Day'>
type Amount = Branded<{ currency: 'EUR' | 'USD', value: number}, 'Amount'>
interface User {
id: UserId
username: string
}
interface Order {
id: OrderId
userId: UserId
title: string
year: Year
month: Month
day: Day
amount: Amount
}
function getOrdersFiltered(userId: UserId, year: Year, month: Month, day: Day, amount: Amount) { // ...}
function deleteOrder(id: OrderId) { // ... }
עכשיו, בדוגמה הזו, ה-IDE יראה שגיאה מכיוון שid
הוא UserId
וdeleteOrder
מצפה לOrderId
.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
החזרות
כפיצוי קטן, תצטרך להשתמש בX
כBrand
. לדוגמה, const year = 2012 as Year
כאשר אתה יוצר ערך חדש מתוך פרימיטיב, אבל זה שווה ערך לnew Year(2012)
אם אתה משתמש באובייקטי ערך. תוכל לספק פונקציה שעובדת כסוג של "קונסטרקטור":
function year(year: number): Year {
return year as Year
}
אימות עם סוגים ממותגים
סוגים ממותגים שימושיים גם כדי להבטיח שהנתונים תקינים מכיוון שניתן שיהיו סוגים ספציפיים לנתונים מאומתים, ואתה יכול לסמוך על שהמשתמש אומת רק על ידי שימוש בסוגים:
type User = { id: UserId, email: Email}
type ValidUser = Readonly<Brand<User, 'ValidUser'>>
function validateUser(user: User): ValidUser {
// Checks if user is in the database
if (!/* logic to check the user is in database */) {
throw new InvalidUser()
}
return user as ValidUser
}
// We can not pass just a User, needs to be a ValidUser
function doSomethingWithAValidUser(user: ValidUser) {
}
Readonly
אינו חובה, אבל כדי להיות בטוח שקוד שלך לא ישנה את הנתונים לאחר אימותם, מאוד מומלץ.
סיכום
סוגים ממותגים הם פתרון פשוט שכולל את הדברים הבאים:
- משפר את קריאות הקוד: מבהיר יותר איזה ערך צריך להשתמש בכל ארגומנט
- אמינות: עוזרת להימנע מטעויות בקוד שיכולות להיות קשות לזיהוי; עכשיו ה-IDE (והבדיקות סוגים) עוזרים לנו לגלות אם הערך נמצא במקום הנכון
- אימות נתונים: ניתן להשתמש בסוגים ממותגים כדי להבטיח שהנתונים תקינים.
אפשר לחשוב על סוגים ממותגים כסוג של גרסה של ValueObjects
אבל בלי להשתמש במחלקות — רק סוגים ופונקציות.
תהנו מכוח הטייפים!
Source:
https://dzone.com/articles/branded-types-in-typescript