當你用 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