浅谈 TS 标称类型介绍及社区实现

程序源代码

共 5699字,需浏览 12分钟

 ·

2022-02-22 01:22



前言



有位大神说过"程序是类型的证明",我看不懂,但我大受震撼。为了以后能看懂哪怕一点点,我决定记录下类型相关的所学所悟。

《浅谈 TS 标称类型》系列将以稍偏门的视角来看待 TypeScript 的类型系统,实际用途不大,但自觉有趣。本文是该系列的开篇文章,主要介绍标签类型是什么,以及 TS 社区都有哪些实现手段。




什么是标称类型系统(nominal type system)




先通俗地理解下,举个例子,userId = 123bookId = 34都是数字,但两者用于不同的场景,希望用不同类型 UserIDBookID 来表示,且不能互换。像这样,数据的值本身没什么区别,安上不同名字就是不同类型,这就是标称类型系统(nominal type system)。也就是说,标称类型系统中,两个变量是否类型兼容(可以交换赋值)取决于这两个变量显式声明的类型名字是否相同。

与之相对的是结构类型系统(structural type system),类型兼容只取决于实际结构是否相同,与类型名字无关。比如:定义Point类型包含xy两个数字,rect = { x: 33, y: 3, width: 30, height: 80 }的结构满足Point的定义,就属于Point类型。简单理解,结构类型系统中,结构或者说形状相同的两个值,它们的类型是兼容的,可以交换赋值。

更严格的定义可以看下Type system - Wikipedia的说明。

除了上面的 UserIDBookID 的例子,标称类型还有其他常见的应用场景,比如:区分不同的字符串(正则表达式、html模版、文件路径等),表达不同单位的量纲(不同币种的金额、css各种长度单位)等。这些会在后续文章再展开说明,届时也会列举下标称类型常见的错误用法。




TS 是标称类型系统吗




不是。TS 是结构类型系统(structural type system),基于结构/形状检查类型,而非类型的名字。

One of TypeScript’s core principles is that type checking focuses on the shape that values have. This is sometimes called “duck typing” or “structural typing”.

TypeScript: Documentation - TypeScript for JavaScript Programmers

上面是TS官方文档的说明,里面还举了一些例子,可以先看看加深理解。




TS 可以实现标称类型吗




可以(不然这篇文章写到这里就要结束了😂)。TS 目前不支持显式声明标称类型,也没有计划支持,2014年的提案Support some non-structural (nominal) type matching · Issue #202 到现在还是Open状态。不过社区有不少方案,可以基于现有 TS 的能力一定程度上实现标称类型,整理如下。



TS 实现标称类型的各种手段




为了方便,下面都用 CNY、USD 币种来示例,类型检查用下面两个方法测试。

function buyPekingDuck(money: CNY{} // 只能用 CNY 买北京烤鸭
function buyCocaCola(money: USD{} // 只能用 USD 买可口可乐

为了术语一致,下文统一用下列中文字词(若与习惯的表述不一致,请以英文单词为准)

  • Type Annotation: 类型声明  变量: 类型 ,比如 let yuan: CNY
  • Type Assertion: 类型断言 表达式 as 类型,比如 12 as CNY
  • Type Compatibility: 类型兼容,指一个类型可以赋值给另一个类型
  • Type Infer: 类型推断,指 TS 根据上下文推断变量或值的类型,比如 let a = 12 推断 anumber
  • Primitive Type: 原始类型,指string, numberboolean

1、定义私有属性的类 Class with a private property

class CNY {
  private __brand: void
  constructor(public value: number) {}
}
class USD {
  private __brand: void
  constructor(public value: number) {}
}

// 用例
const yuan = new CNY(12)
const dollar = new USD(5)

// 类型安全
buyPekingDuck(dollar) // Argument of type 'USD' is not assignable to parameter of type 'CNY'.
buyCocaCola(yuan) // Argument of type 'CNY' is not assignable to parameter of type 'USD'.

这个方法利用了 TS 对 private/protected 的特殊处理——判断类型兼容时,如果其中一个包含私有属性,则另一个必须包含来自同一个类声明的相同私有属性。yuandollar 都有私有属性__brand,但来自不同的类声明(分别是CNYUSD),所以它们类型不兼容。

优点:不需要类型声明(type annotation),也不需要类型断言(type assertion),TS 能推导出对应的类型(type infer)。缺点:冗余的类声明,多了一层{ value }的结构,不能支持原始类型,需要额外的序列化处理。推荐度:不推荐。除非本来就是用类实现,而且要严格区分字段相同、语义不同的两个类型,才考虑该方案。

2、包含字面量类型

type CNY = {
  currency: 'CNY',
  value: number,
}
type USD = {
  currency: 'USD',
  value: number,
}

// 用例
const yuan: CNY = { currency: 'CNY', value: 12 }
const dollar: USD = { currency: 'USD', value: 5}

// 类型安全
buyPekingDuck(dollar) // Argument of type 'USD' is not assignable to parameter of type 'CNY'.
buyCocaCola(yuan) // Argument of type 'CNY' is not assignable to parameter of type 'USD'.

加入不同的字面量类型(literal type)来定义 type 或 interface,因为不同字面量是不同类型,所以组合后的类型也不同。

优点:语义清晰,理解直观,条件判断能实现类型收窄(type narrowing)。缺点:多了一层{ value }的结构,不能支持原始类型,需要额外的序列化处理。推荐度:看情况。如果本来有结构,而且用于区分的字面量有对应的语义,可以用该方法。

3、枚举类 intersection

enum CNYBrand { _brand }
type CNY = number & CNYBrand

enum USDBrand { _brand }
type USD = number & USDBrand

// 用例
const yuan = 12 as CNY
const dollar = 5 as USD

// 类型安全
buyPekingDuck(dollar) // Argument of type 'USDBrand' is not assignable to parameter of type 'CNYBrand'.
buyCocaCola(yuan) // Argument of type 'CNYBrand' is not assignable to parameter of type 'USDBrand'.

枚举定义了{ _brand },TS会认为是非空数字枚举,两个枚举不兼容,与数字类型交集后就是不同类型。

注意,字符串不能这么用,string & CNYBrand的结果是never。枚举需要定义为{ _brand: ''},让TS认为是非空字符串枚举,才能跟字符串类型取交集。

优点:无,勉强要说的话,类型断言的 as Xxx 可读性还行。缺点:需要类型断言,有额外的枚举定义,会生成多余的js代码,数字和字符串类型用法不一样,不支持其他原始类型(布尔类型)。推荐度:不推荐。为了标称类型增加额外运行损耗,不值得。

4、unique symbol

type CNY = number & {
  readonly brand: unique symbol
}

type USD = number & {
  readonly brand: unique symbol
}

// 用例
const yuan = 12 as CNY
const dollar = 5 as USD

// 类型安全
buyPekingDuck(dollar) // Argument of type 'USD' is not assignable to parameter of type 'CNY'.
buyCocaCola(yuan) // Argument of type 'CNY' is not assignable to parameter of type 'USD'.

TS 里每个 unique symbol 声明都是完全独立的唯一标识,互相不兼容。作为属性加到类型中需要用readonly修饰。

优点:类型定义部分无差异,不用费心思,无额外的结构,运行时无消耗。缺点:需要类型断言,关键字较多(uniquereadonly),不能用范型。推荐度:推荐。不会生成额外代码,其唯一性确保类型不会重复。

5、brand interface

interface CNY extends Number {
  _CNYBrand: string;
}
interface USD extends Number {
  _USDBrand: string;
}

// 用例
const yuan: CNY = 12 as any
const dollar: USD = 5 as any

// 类型安全
buyPekingDuck(dollar) // Argument of type 'USD' is not assignable to parameter of type 'CNY'.
buyCocaCola(yuan) // Argument of type 'CNY' is not assignable to parameter of type 'USD'.

用 interface 扩展增加互不相同的_xxxBrand变成不同的类型,破坏类型兼容。TS 的源码也使用了该方案。

优点:支持基本类型,没用到黑魔法,无额外的结构,运行时无消耗。缺点:需要类型声明或类型断言,且需要过 any 一道。推荐度:非常推荐。大部分需要标称类型的场景不会直接指定类型,缺点可接受,优先考虑该方案。

6、brand type intersection

type CNY = number & {
  _CNYBrand: string;
}
type USD = number & {
  _USDBrand: string;
}

// 用例
const yuan: CNY = 12 as any
const dollar: USD = 5 as any

// 类型安全
buyPekingDuck(dollar) // Argument of type 'USD' is not assignable to parameter of type 'CNY'.
buyCocaCola(yuan) // Argument of type 'CNY' is not assignable to parameter of type 'USD'.

同上,只不过 interface extend 改成等价的 type intersection,即,用类型交集增加互不相同的_xxxBrand变成不同的类型,破坏类型兼容。

优点:支持基本类型,没用到黑魔法,无额外的结构,运行时无消耗。缺点:需要类型声明或类型断言,且需要过 any 一道。推荐度:非常推荐。同上,大部分需要标称类型的场景不会直接指定类型,缺点可接受,优先考虑该方案。


上面列举了社区常见的标称类型实现方法,其中个人最推荐的是 brand interface 以及等价的 brand type intersection,原理简单易懂,没有黑魔法,适合绝大多数使用场景,也是 TS 官方源码里在用的方法,值得优先考虑。




后记




本文简单介绍了标称类型是什么,以及 TS 中如何实现。除了本文提到的这些方法外,网上还能找到很多标称类型的实现手段,它们各有优劣,适用场景也有差异,而且随着 TS 升级,有些方法已经失效了,不熟悉的话可能会难以抉择,故没有收录到文章中。

本系列后续文章会从实现原理进一步剖析这些方法,了解其背后的机制,并结合实际使用场景来辨析,争取知其然,知其所以然。


浏览 109
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报