当你使用 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(和类型检查)帮助我们检测值是否在正确的位置
- 数据验证: 你可以使用品牌类型来确保数据是有效的。
你可以将品牌类型视为一种 值对象
的版本,但不使用类——仅使用类型和函数。
享受类型的强大功能!
Source:
https://dzone.com/articles/branded-types-in-typescript