已发布 上游基线 bf46254 原文 ↗ 在 GitHub 编辑

品牌类型

使用品牌类型在 TypeScript 中强化类型安全并细化数据。

在本指南中,我们将探讨 TypeScript 中的品牌类型(branded types)概念,并学习如何使用 Brand 模块创建和使用它们。 品牌类型是带有额外类型标记(type tag)的 TypeScript 类型,有助于防止在错误的上下文中意外使用某个值。 它们允许我们基于已有的底层类型创建彼此不同的类型,从而实现类型安全和更好的代码组织。

TypeScript 结构类型系统的问题

TypeScript 的类型系统是结构化类型(structurally typed)的,这意味着只要两个类型的成员兼容,它们就被视为兼容。 这可能导致这样的情况:底层类型相同的值被互换使用,即使它们代表不同的概念或具有不同的含义。

考虑以下类型:

type UserId = number

type ProductId = number

在这里,UserIdProductId 在结构上完全相同,因为它们都基于 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 模块提供了两个用于创建品牌类型的主要函数:nominalrefined

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
}

尝试赋值一个非 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.refined 函数用于创建包含数据校验的品牌类型。它需要一个精化谓词(refinement predicate),用于根据特定条件检查输入数据是否有效。

当输入数据不符合条件时,该函数使用 Brand.error 生成一个 BrandErrors 数据类型。这会提供关于校验为何失败的详细信息。

示例(创建带校验的品牌类型)

import { Brand } from "effect"

// Define a branded type 'Int' to represent integer values
type Int = number & Brand.Brand<"Int">

// Define the constructor using 'refined' to enforce integer values
const Int = Brand.refined<Int>(
  // Validation to ensure the value is an integer
  (n) => Number.isInteger(n),
  // Provide an error if validation fails
  (n) => Brand.error(`Expected ${n} to be an integer`),
)

示例(使用 Int 构造器)

import { Brand } from "effect"

type Int = number & Brand.Brand<"Int">

const Int = Brand.refined<Int>(
  // Check if the value is an integer
  (n) => Number.isInteger(n),
  // Error message if the value is not an integer
  (n) => Brand.error(`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 [ { message: 'Expected 3.14 to be an integer' } ]

尝试赋值一个非 Int 的值会导致编译期错误:

示例(错误赋值的编译期错误)

import { Brand } from "effect"

type Int = number & Brand.Brand<"Int">

const Int = Brand.refined<Int>(
  (n) => Number.isInteger(n),
  (n) => Brand.error(`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.refined<Int>(
  (n) => Number.isInteger(n),
  (n) => Brand.error(`Expected ${n} to be an integer`),
)

type Positive = number & Brand.Brand<"Positive">

const Positive = Brand.refined<Positive>(
  (n) => n > 0,
  (n) => Brand.error(`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 [ { message: 'Expected -5 to be positive' } ]
const bad1: PositiveInt = PositiveInt(-5)

// throws [ { message: 'Expected 3.14 to be an integer' } ]
const bad2: PositiveInt = PositiveInt(3.14)