中文

探索 TypeScript 品牌类型,这是一种在结构化类型系统中实现名义化类型的强大技术。学习如何增强类型安全性和代码清晰度。

TypeScript 品牌类型:在结构化类型系统中实现名义化类型

TypeScript 的结构化类型系统虽然灵活,但有时可能导致意外行为。品牌类型提供了一种强制实施名义化类型的方法,从而增强了类型安全性和代码清晰度。本文将详细探讨品牌类型,并提供其实际应用的示例和最佳实践。

理解结构化类型与名义化类型

在深入探讨品牌类型之前,我们先来阐明结构化类型和名义化类型之间的区别。

结构化类型(鸭子类型)

在结构化类型系统中,如果两种类型具有相同的结构(即,相同的属性和相同的类型),则它们被认为是兼容的。TypeScript 使用的就是结构化类型。请看以下示例:


interface Point {
  x: number;
  y: number;
}

interface Vector {
  x: number;
  y: number;
}

const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // 在 TypeScript 中有效

console.log(vector.x); // 输出: 10

尽管 PointVector 被声明为不同的类型,但 TypeScript 允许将一个 Point 对象赋值给一个 Vector 变量,因为它们共享相同的结构。这在某些情况下很方便,但如果需要区分逻辑上不同但恰好具有相同形态的类型,也可能导致错误。例如,设想一下经纬度坐标,它可能偶然与屏幕像素坐标的结构相匹配。

名义化类型

在名义化类型系统中,只有当类型具有相同的名称时,它们才被认为是兼容的。即使两种类型具有相同的结构,只要名称不同,它们就会被视为不同的类型。像 Java 和 C# 这样的语言就使用名义化类型。

为什么需要品牌类型

当需要确保一个值属于特定类型而不管其结构如何时,TypeScript 的结构化类型系统就可能出现问题。例如,考虑表示货币。你可能有 USD 和 EUR 两种不同的类型,但它们都可以表示为数字。如果没有一种机制来区分它们,你可能会意外地对错误的货币执行操作。

品牌类型通过创建结构上相似但被类型系统视为不同的独特类型来解决这个问题。这增强了类型安全性,并防止了否则可能溜掉的错误。

在 TypeScript 中实现品牌类型

品牌类型是通过使用交叉类型和一个唯一的符号(Symbol)或字符串字面量来实现的。其思想是向一个类型添加一个“品牌”,以将其与具有相同结构的其他类型区分开来。

使用 Symbols (推荐)

通常首选使用 Symbols 进行品牌化,因为 Symbols 能保证唯一性。


const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };

const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// 取消下一行的注释将导致类型错误
// const invalidOperation = addUSD(usd1, eur1);

在这个例子中,USDEUR 是基于 number 类型的品牌类型。unique symbol 确保了这些类型的独特性。createUSDcreateEUR 函数用于创建这些类型的值,而 addUSD 函数只接受 USD 类型的值。尝试将一个 EUR 值与一个 USD 值相加将导致类型错误。

使用字符串字面量

你也可以使用字符串字面量进行品牌化,但这种方法不如使用 Symbols 稳健,因为字符串字面量不能保证唯一性。


type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// 取消下一行的注释将导致类型错误
// const invalidOperation = addUSD(usd1, eur1);

这个例子与前一个例子实现了相同的结果,但使用的是字符串字面量而不是 Symbols。虽然更简单,但重要的是要确保用于品牌化的字符串字面量在你的代码库中是唯一的。

实际示例与用例

品牌类型可以应用于各种需要强制执行超越结构兼容性的类型安全的场景。

ID

考虑一个拥有不同类型 ID 的系统,例如 UserIDProductIDOrderID。所有这些 ID 可能都表示为数字或字符串,但你希望防止意外混用不同类型的 ID。


const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };

const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };

function getUser(id: UserID): { name: string } {
  // ... 获取用户数据
  return { name: "Alice" };
}

function getProduct(id: ProductID): { name: string, price: number } {
  // ... 获取产品数据
  return { name: "Example Product", price: 25 };
}

function createUserID(id: string): UserID {
  return id as UserID;
}

function createProductID(id: string): ProductID {
  return id as ProductID;
}

const userID = createUserID('user123');
const productID = createProductID('product456');

const user = getUser(userID);
const product = getProduct(productID);

console.log("User:", user);
console.log("Product:", product);

// 取消下一行的注释将导致类型错误
// const invalidCall = getUser(productID);

这个例子演示了品牌类型如何防止将一个 ProductID 传递给一个期望 UserID 的函数,从而增强了类型安全性。

领域特定值

品牌类型对于表示具有约束条件的领域特定值也很有用。例如,你可能有一个表示百分比的类型,该值应始终在 0 到 100 之间。


const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };

function createPercentage(value: number): Percentage {
  if (value < 0 || value > 100) {
    throw new Error('百分比必须在 0 到 100 之间');
  }
  return value as Percentage;
}

function applyDiscount(price: number, discount: Percentage): number {
  return price * (1 - discount / 100);
}

try {
  const discount = createPercentage(20);
  const discountedPrice = applyDiscount(100, discount);
  console.log("Discounted Price:", discountedPrice);

  // 取消下一行的注释将在运行时导致错误
  // const invalidPercentage = createPercentage(120);
} catch (error) {
  console.error(error);
}

这个例子展示了如何在运行时对品牌类型的值强制执行约束。虽然类型系统不能保证一个 Percentage 值总是在 0 到 100 之间,但 createPercentage 函数可以在运行时强制执行此约束。你也可以使用像 io-ts 这样的库来对品牌类型进行运行时验证。

日期和时间表示

由于各种格式和时区,处理日期和时间可能很棘手。品牌类型可以帮助区分不同的日期和时间表示法。


const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };

const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };

function createUTCDate(dateString: string): UTCDate {
  // 验证日期字符串是否为 UTC 格式(例如,带 Z 的 ISO 8601)
  if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
    throw new Error('无效的 UTC 日期格式');
  }
  return dateString as UTCDate;
}

function createLocalDate(dateString: string): LocalDate {
  // 验证日期字符串是否为本地日期格式(例如,YYYY-MM-DD)
  if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
    throw new Error('无效的本地日期格式');
  }
  return dateString as LocalDate;
}

function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
  // 执行时区转换
  const date = new Date(utcDate);
  const localDateString = date.toLocaleDateString();
  return createLocalDate(localDateString);
}

try {
  const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
  const localDate = convertUTCDateToLocalDate(utcDate);
  console.log("UTC Date:", utcDate);
  console.log("Local Date:", localDate);
} catch (error) {
  console.error(error);
}

这个例子区分了 UTC 日期和本地日期,确保你在应用程序的不同部分使用正确的日期和时间表示。运行时验证确保只有格式正确的日期字符串才能被赋予这些类型。

使用品牌类型的最佳实践

为了在 TypeScript 中有效地使用品牌类型,请考虑以下最佳实践:

品牌类型的优点

品牌类型的缺点

品牌类型的替代方案

虽然品牌类型是在 TypeScript 中实现名义化类型的一种强大技术,但你也可以考虑一些替代方法。

不透明类型 (Opaque Types)

不透明类型与品牌类型相似,但提供了一种更明确的方式来隐藏底层类型。TypeScript 没有对不透明类型的内置支持,但你可以使用模块和私有 Symbols 来模拟它们。

类 (Classes)

使用类可以为定义不同类型提供一种更面向对象的方法。虽然类在 TypeScript 中是结构化类型的,但它们提供了更清晰的关注点分离,并可以通过方法来强制执行约束。

使用像 io-tszod 这样的库

这些库提供复杂的运行时类型验证,可以与品牌类型结合使用,以确保编译时和运行时的双重安全。

结论

TypeScript 品牌类型是在结构化类型系统中增强类型安全性和代码清晰度的宝贵工具。通过向类型添加“品牌”,你可以强制实施名义化类型,并防止意外混用结构相似但逻辑上不同的类型。虽然品牌类型会带来一些复杂性和开销,但提高类型安全性和代码可维护性的好处通常超过了其缺点。在需要确保一个值属于特定类型而不管其结构如何的场景中,请考虑使用品牌类型。

通过理解结构化类型和名义化类型背后的原理,并应用本文中概述的最佳实践,你可以有效地利用品牌类型来编写更健壮、更易于维护的 TypeScript 代码。从表示货币和 ID 到强制执行领域特定的约束,品牌类型为增强项目中的类型安全性提供了一种灵活而强大的机制。

在使用 TypeScript 的过程中,探索各种可用于类型验证和强制执行的技术和库。考虑将品牌类型与像 io-tszod 这样的运行时验证库结合使用,以实现全面的类型安全方法。