品牌类型
使用品牌类型在 TypeScript 中强化类型安全并细化数据。
在本指南中,我们将探讨 TypeScript 中的品牌类型(branded types)概念,并学习如何使用 Brand 模块创建和使用它们。 品牌类型是带有额外类型标记(type tag)的 TypeScript 类型,有助于防止在错误的上下文中意外使用某个值。 它们允许我们基于已有的底层类型创建彼此不同的类型,从而实现类型安全和更好的代码组织。
TypeScript 结构类型系统的问题
TypeScript 的类型系统是结构化类型(structurally typed)的,这意味着只要两个类型的成员兼容,它们就被视为兼容。 这可能导致这样的情况:底层类型相同的值被互换使用,即使它们代表不同的概念或具有不同的含义。
考虑以下类型:
type UserId = number
type ProductId = number
在这里,UserId 和 ProductId 在结构上完全相同,因为它们都基于 number。
TypeScript 会把二者视为可互换的,如果它们在应用中被混用,就可能引发 bug。
示例(意外的类型兼容)
type UserId = number
type ProductId = number
const getUserById = (id: UserId) => {
// Logic to retrieve user
}
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
const id: UserId = 1
getProductById(id) // No type error, but incorrect usage
在上面的例子中,把 UserId 传给 getProductById 不会产生类型错误,尽管这在逻辑上是不正确的。出现这种情况是因为这两个类型被视为可互换。
品牌类型如何解决问题
品牌类型允许你通过添加唯一的类型标记,从相同的底层类型创建出彼此不同的类型,从而在编译期强制正确的用法。
品牌化(branding)是通过添加一个符号标识符来实现的,它在类型层面把一个类型与另一个类型区分开。 这种方法确保类型保持彼此不同,同时不改变它们的运行时特征。
让我们先引入 BrandTypeId 符号:
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
type ProductId = number & {
readonly [BrandTypeId]: {
readonly ProductId: "ProductId" // unique identifier for ProductId
}
}
这种方法把一个唯一标识符作为品牌赋予 number 类型,从而有效地将 ProductId 与其他数值类型区分开。
使用符号可以确保品牌字段不会与 number 类型的任何现有属性冲突。
现在,尝试用 UserId 替代 ProductId 会导致错误:
示例(用品牌类型强制类型安全)
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
type ProductId = number & {
readonly [BrandTypeId]: {
readonly ProductId: "ProductId"
}
}
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
type UserId = number
const id: UserId = 1
// @errors: 2345
getProductById(id)
错误信息清楚地表明,number 不能用来替代 ProductId。
TypeScript 不会允许我们把 number 的实例传给接受 ProductId 的函数,因为它缺少品牌字段。
让我们也为 UserId 添加品牌:
示例(为 UserId 和 ProductId 添加品牌)
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
type ProductId = number & {
readonly [BrandTypeId]: {
readonly ProductId: "ProductId" // unique identifier for ProductId
}
}
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
type UserId = number & {
readonly [BrandTypeId]: {
readonly UserId: "UserId" // unique identifier for UserId
}
}
declare const id: UserId
// @errors: 2345
getProductById(id)
这个错误表明,虽然两个类型都使用了品牌,但品牌字段关联的唯一值("ProductId" 和 "UserId")确保它们保持彼此不同、不可互换。
泛化品牌类型
为了增强品牌类型的通用性和可复用性,可以用一种标准化的方式对它们进行泛化:
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
// Create a generic Brand interface using a unique identifier
interface Brand<in out ID extends string | symbol> {
readonly [BrandTypeId]: {
readonly [id in ID]: ID
}
}
// Define a ProductId type branded with a unique identifier
type ProductId = number & Brand<"ProductId">
// Define a UserId type branded similarly
type UserId = number & Brand<"UserId">
这种设计允许用唯一标识符(字符串或符号)为任意类型添加品牌。
下面展示如何使用 Brand 接口——它由 Brand 模块直接提供,因此无需自己编写实现:
示例(使用 Brand 模块中的 Brand 接口)
import { Brand } from "effect"
// Define a ProductId type branded with a unique identifier
type ProductId = number & Brand.Brand<"ProductId">
// Define a UserId type branded similarly
type UserId = number & Brand.Brand<"UserId">
然而,直接创建这些类型的实例会导致错误,因为类型系统期望的是品牌结构:
示例(直接赋值错误)
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
interface Brand<in out K extends string | symbol> {
readonly [BrandTypeId]: {
readonly [k in K]: K
}
}
type ProductId = number & Brand<"ProductId">
// @errors: 2322
const id: ProductId = 1
你不能直接把 number 赋值给 ProductId。Brand 模块提供了用于正确构造品牌类型值的工具。
构造品牌类型
Brand 模块提供了两个用于创建品牌类型的主要函数:nominal 和 make。
nominal
Brand.nominal 函数用于定义不需要运行时校验的品牌类型。
它只是给底层类型添加一个类型标记,让我们能够区分同一类型但含义不同的值。
当我们只是为了代码清晰和代码组织而想创建彼此不同的类型时,名义品牌类型(nominal branded types)就很有用。
示例(用名义品牌定义不同的标识符)
import { Brand } from "effect"
// Define UserId as a branded number
type UserId = number & Brand.Brand<"UserId">
// Constructor for UserId
const UserId = Brand.nominal<UserId>()
const getUserById = (id: UserId) => {
// Logic to retrieve user
}
// Define ProductId as a branded number
type ProductId = number & Brand.Brand<"ProductId">
// Constructor for ProductId
const ProductId = Brand.nominal<ProductId>()
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
// `Brand.nominal` performs no runtime validation, it just brands the value
ProductId(1) // => 1
尝试赋值一个非 ProductId 的值会导致编译期错误:
示例(品牌标识符带来的类型安全)
import { Brand } from "effect"
type UserId = number & Brand.Brand<"UserId">
const UserId = Brand.nominal<UserId>()
const getUserById = (id: UserId) => {
// Logic to retrieve user
}
type ProductId = number & Brand.Brand<"ProductId">
const ProductId = Brand.nominal<ProductId>()
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
// Correct usage
getProductById(ProductId(1))
// Incorrect, will result in an error
// @errors: 2345
getProductById(1)
// Also incorrect, will result in an error
// @errors: 2345
getProductById(UserId(1))
refined
Brand.make 函数会创建一个会校验其输入的品牌类型构造器。校验函数对有效值返回 true,或者描述该值为何无效。
用无效输入调用该构造器会抛出 BrandError。它的 option、result 和 is 方法提供了不会抛错的替代方案。
示例(创建带校验的品牌类型)
import { Brand } from "effect"
// Define a branded type 'Int' to represent integer values
type Int = number & Brand.Brand<"Int">
// Define the constructor using 'make' to enforce integer values
const Int = Brand.make<Int>(
(n) =>
// Validation to ensure the value is an integer, with an error message if not
Number.isInteger(n) || `Expected ${n} to be an integer`,
)
// A valid integer passes validation and is returned unchanged
Int(3) // => 3
示例(使用 Int 构造器)
import { Brand } from "effect"
type Int = number & Brand.Brand<"Int">
const Int = Brand.make<Int>(
(n) =>
// Check if the value is an integer, with an error message if not
Number.isInteger(n) || `Expected ${n} to be an integer`,
)
// Create a valid Int value
const x: Int = Int(3)
console.log(x) // Output: 3
// Attempt to create an Int with an invalid value
const y: Int = Int(3.14)
// throws BrandError(Expected 3.14 to be an integer)
尝试赋值一个非 Int 的值会导致编译期错误:
示例(错误赋值的编译期错误)
import { Brand } from "effect"
type Int = number & Brand.Brand<"Int">
const Int = Brand.make<Int>(
(n) => Number.isInteger(n) || `Expected ${n} to be an integer`,
)
// Correct usage
const good: Int = Int(3)
// Incorrect, will result in an error
// @errors: 2322
const bad1: Int = 3
// Also incorrect, will result in an error
// @errors: 2322
const bad2: Int = 3.14
组合品牌类型
在某些情况下,你可能需要组合多个品牌类型。为此,Brand 模块提供了 Brand.all API:
示例(组合多个品牌类型)
import { Brand } from "effect"
type Int = number & Brand.Brand<"Int">
const Int = Brand.make<Int>(
(n) => Number.isInteger(n) || `Expected ${n} to be an integer`,
)
type Positive = number & Brand.Brand<"Positive">
const Positive = Brand.make<Positive>(
(n) => n > 0 || `Expected ${n} to be positive`,
)
// Combine the Int and Positive constructors
// into a new branded constructor PositiveInt
const PositiveInt = Brand.all(Int, Positive)
// Extract the branded type from the PositiveInt constructor
type PositiveInt = Brand.Brand.FromConstructor<typeof PositiveInt>
// Usage example
// Valid positive integer
const good: PositiveInt = PositiveInt(10)
// throws BrandError(Expected -5 to be positive)
const bad1: PositiveInt = PositiveInt(-5)
// throws BrandError(Expected 3.14 to be an integer)
const bad2: PositiveInt = PositiveInt(3.14)