TS 类型体操笔记 - 697 Tag

174 阅读8分钟

引言

类型体操 是关于 TS 类型编程的一系列挑战(编程题目)。通过这些挑战,我们可以加深对 TS 类型系统的理解,举一反三,在实际工作中解决类似问题。

本文是我对 697 Tag 的解题笔记。

本文的读者是正在做 TS 类型体操,对上述题目感兴趣,希望获得更多解读的同学。读者需要具备一定的 TS 基础知识,这部分推荐阅读另一篇优秀博文 Typescript 类型编程,从入门到念头通达😇

题目和答案

先直接贴出题目与答案,呈现问题全貌。

题目

Despite the structural typing system in TypeScript, it is sometimes convenient to mark some types with tags, and so that these tags do not interfere with the ability to assign values of these types to each other.

For example, using tags, you can check that some value passes through the calls of the required functions, and in the correct order:

const doA = <T extends string>(x: T) => {
  const result = x

  return result as Tag<typeof result, 'A'>
}

const doB = <T extends string>(x: T) => {
  const result = x

  return result as Tag<typeof result, 'B'>
};

const a = doA('foo')
const b = doB(a)

type Check0 = IsTrue<HasTags<typeof b, ['A', 'B']>>

Write a function Tag<B, T extends string> that takes a type B other than null and undefined and returns a type labeled with the string literal type T.

The labeled types must be mutually assignable with the corresponding original types:

declare let x: string
declare let y: Tag<string, 'A'>

x = y = x

When tagging a type already marked with a tag, a new tag must be added to the end of the list of all tags of the type:

type T0 = Tag<{ foo: string }, 'A'>
type T1 = Tag<T0, 'B'>

type Check1 = IsTrue<HasExactTags<T1, ['A', 'B']>>

Add some functions to check for type tags.

GetTags<B> retrieves a list of all tags of a type B:

type T2 = Tag<number, 'C'>

type Check2 = IsTrue<Equal<GetTags<T2>, ['C']>>

HasTag<B, T extends string> checks if type B is tagged with tag T (and returns true or false):

type T3 = Tag<0 | 1, 'D'>

type Check3 = IsTrue<HasTag<T3, 'D'>>

HasTags<B, T extends readonly string[]> checks if type B is tagged in succession with tags from tuple T:

type T4 = Tag<Tag<Tag<{}, 'A'>, 'B'>, 'C'>

type Check4 = IsTrue<HasTags<T4, ['B', 'C']>>

HasExactTags<B, T extends readonly string[]> checks if the list of all tags of type B is exactly equal to the T tuple:

type T5 = Tag<Tag<unknown, 'A'>, 'B'>

type Check5 = IsTrue<HasExactTags<T5, ['A', 'B']>>

Finally, add type UnTag<B>, which removes all tags from type B:

type T6 = Tag<{ bar: number }, 'A'>
type T7 = UnTag<T6>

type Check6 = IsFalse<HasTag<T7, 'A'>>

答案

Issue 22316

/**
 * 版本一
 * 清晰、精干,但是有未通过的测试用例(GetTags 处理 union 的部分)
 * 这是我比较推崇的一版,比较适合学习
 */

// ---- Start: Structures ----

declare const UniqueSymbol: unique symbol

type UniqueSymbolType = typeof UniqueSymbol

type TagsWrapper<B, TGS> = UniqueSymbolType | (UniqueSymbolType & [B, TGS])

type TagsBag<B, TGS> = {
  [UniqueSymbol]?: TagsWrapper<B, TGS>
}

// ---- End: Structures ----

// ---- Start: Get Tags ----

type GetTags<B> = 
  Equal<B, never> extends true ? [] :
  B extends TagsBag<unknown, infer Tags extends string[]>
  ? Equal<Tags, string[]> extends true
    ? []
    : Tags
  : []

// ---- End: Get Tags ----

// ---- Start: Tag and UnTag ----

type PassEmptyValue<B, D> = 
  Equal<B, null> extends true ? null :
  Equal<B, undefined> extends true ? undefined :
  D

type Tag<B, TG> = PassEmptyValue<B, UnTag<B> & TagsBag<UnTag<B>, [...GetTags<B>, TG]>>

type UnTag<B> = PassEmptyValue<B, Omit<B, UniqueSymbolType>>

// ---- End: Tag and UnTag ----

// ---- Start: Other Methods ----

type Includes<A extends readonly unknown[], B extends readonly unknown[]> =
  A extends [...B, ...unknown[]]
  ? true
  : A extends [unknown, ...infer Rest]
    ? Includes<Rest, B>
    : false

type HasTag<B, T extends string> = Includes<GetTags<B>, [T]>
type HasTags<B, T extends readonly string[]> = Includes<GetTags<B>, T>
type HasExactTags<B, T extends readonly string[]> = Equal<GetTags<B>, T>

// ---- End: Other Methods ----
/**
 * 版本二
 * 这一版是在版本一的基础上做了修改,将 GetTags 的返回值由交集变为并集,纯粹是为了满足测试用例的需要,增加了一些额外的逻辑
 * 学完版本一之后,如果非得要通关,可以使用这一版
 */

// ---- Start: Structures ----

declare const UniqueSymbol: unique symbol

type UniqueSymbolType = typeof UniqueSymbol

type TagsWrapper<B, TGS> = UniqueSymbolType | (UniqueSymbolType & [B, TGS])

type TagsBag<B, TGS> = {
  [UniqueSymbol]?: TagsWrapper<B, TGS>
}

// ---- End: Structures ----

// ---- Start: Get Tags ----

type Union2Intersection<U> =
  (U extends unknown ? (arg: U) => void : never) extends
  (arg: infer I) => void ? I : never

type _GetTags<B> = 
  Equal<B, never> extends true ? [] :
  B extends TagsBag<unknown, infer Tags extends string[]>
  ? Equal<Tags, string[]> extends true
    ? []
    : Tags
  : []

type GetTags<B> = 
  Union2Intersection<(_GetTags<B>)> extends infer Result
  ? Equal<Result, never> extends true
    ? []
    : Result extends string[]
      ? Result
      : never
  : never

// ---- End: Get Tags ----

// ---- Start: Wrap and Unwrap ----

type PassEmptyValue<B, D> = 
  Equal<B, null> extends true ? null :
  Equal<B, undefined> extends true ? undefined :
  D

type Tag<B, TG> = PassEmptyValue<B, UnTag<B> & TagsBag<UnTag<B>, [...GetTags<B>, TG]>>

type UnTag<B> = PassEmptyValue<B, Omit<B, UniqueSymbolType>>

// ---- End: Tag and UnTag ----

// ---- Start: Other Methods ----

type Includes<A extends readonly unknown[], B extends readonly unknown[]> =
  A extends [...B, ...unknown[]]
  ? true
  : A extends [unknown, ...infer Rest]
    ? Includes<Rest, B>
    : false

type HasTag<B, T extends string> = Includes<GetTags<B>, [T]>
type HasTags<B, T extends readonly string[]> = Includes<GetTags<B>, T>
type HasExactTags<B, T extends readonly string[]> = Equal<GetTags<B>, T>

// ---- End: Other Methods ----

解题思路

问题分析

这个题目看起来比较长。宏观分析后可将其分为两个部分:

  • 核心能力:Tag, UnTag, GetTags,确保返回的类型与原始类型 可相互赋值
  • 扩展能力:基于 GetTags 就能实现的一堆 HasXXX 之类的检查函数(不是重点)

其中,扩展能力的部分不是重点,基于 GetTags 就能实现,也没什么特点,有思路的话工作量也不是很大,但一开始没思路可能会花一些时间,关键是容易分散我们宝贵的注意力,所以我们先不关注它。

先建立一个简单的心智模型,给类型打 tag,就好像给它附上一条尾巴(链表),要点如下

  • Tag 一次只增加一个标签,但 UnTag 需要一次性去掉所有标签,即返回最初被封装的类型
  • GetTags 需要一次性取出所有标签
  • 被打上标签之后的类型,与他的前继和后继类型都需要 可相互赋值

关键点:如何给类型附加信息?

要给类型附加信息,思路是 装箱拆箱,即设计一个新的类型,它有一些槽位,将原始类型装到一个槽位上,而附加信息装到其他槽位上。在 ts 中要实现这样的结构,利用 对象、元组、函数 等能力都很容易实现,但真正的难点在于,装箱之后的类型要和原始类型 可相互赋值,这个限制性就非常强了。

关键点:如何维持 可互相赋值 的性质?

原始类型和装箱之后的类型要 可互相赋值,它们一定需要存在某种共性。在 ts 世界中,目前我已知的方案有

  • 基于对象类型的可选属性
  • 基于并交结构

方案一:基于对象的可选属性

要点:

  • (基础)在 ts 中,如果有两个对象类型 甲 和 乙,如果乙只是比甲多出一个可选属性,那么甲乙是可相互赋值的
  • 考虑 可相互赋值 这种性质的传递性,继续给甲或乙添加对方没有的可选属性,新类型仍然和原来的类型 可相互赋值
  • 如果 甲 乙 中存在同名的可选属性,那么如果要维持 甲 乙 的可相互赋值特性,则要求该 甲 乙 的该属性的类型也能相互赋值

该方案存在一些限制,它要求被装箱的类型是对象(能添加属性)。分析下来,题目要求能被处理的类型大部分已经是对象了,例如,对象、数组、函数,它们都是对象,而 js 中的基础类型,如 number, string, boolean,它们其实也是对象。超出对象范畴的类型,null, undefined,题目已经将它们排除掉了,但还存在 any, never, unknown 等特殊类型,因此,选用该方案,需要有意或无意地照顾好这些特殊类型。

该挑战下的大部分解答都是基于该方案的,大概思路是

  1. 约定一个可选字段,定制一种 key 的编码方式,要能编码当前添加的所有标签的 index 和 value,而其类型就是所有的标签(比如 string[])。装箱后的新类型附带上这个字段,就能实现附带信息的同时维持和原始类型的可相互赋值特性
  2. 实践中最好不要将上述字段直接添加到原始类型上,那样很难识别提取。最好是在原始类型上再约定一个 key 稳定的可选字段,然后将上述编码信息的字段包装到一个对象中并存放到这个 key 稳定的可选字段上。而这个稳定 key,为了实现全局无冲突,通常会使用 unique symbol。

方案二:基于并交结构

基于对象的可选属性已经可以解决问题,但在翻阅已提交的解答时,我发现了一个与众不同的 issue(致敬)。如果说对象的可选属性方案是基于 ts 的特性,那么这个方案则是更多基于正统的集合逻辑。

核心是装箱后的结构:

T | (T & TTags<T, Tags>)

其中,T 是被包裹的原始类型,Tags 所有标签值,例如 string[],而 TTags 则是存放了 T 和 Tags 的包装类型,例如,() => [T, Tags][T, Tags] 都是可以的。

很容易看出这个结构是能携带额外信息的,但它是如何维持与 T 的可相互赋值特性的呢?从集合的视角来看, T & TTagsT 的子集,而 T | (T 的子集)T,因此,TT | (T & TTags) 可相互赋值。

理论上的东西大概就这些,上述 issue 也单纯基于该方案解决了挑战。但我在实践发现中,受制于 ts 的特殊类型,| 运算符的特殊性,直接使用这个结构来装箱还是比较麻烦,容易泄露,识别取用不便,需要处理特殊类型,而且比较难扩展和维护。

方案三(我采用的方案):同时基于 对象可选字段 以及 并交结构

能否融合上述两种方案,取长补短呢?结论是可以的。方法是:

  1. 使用 对象可选字段 + unique symbol 来做第一层装箱
  2. 使用 并交结构 来做第二层装箱,代替方案一中的编码字段

这样做的好处是

  • 装箱、拆箱、识别提取 很容易
  • 用类型结构上的构造与匹配代替字符串级别的编码,避免了编码的细节逻辑,突破了字符串、字段 key 的限制,理论上标签值的类型可以不受限于字符串

至此,再对照回看答案中的版本一的代码,应该很容易理解了。

后记

版本一的代码比较干净,易于理解、学习、串联知识点。

但这个挑战的要求过于具体,特别是测试用例,部分用例都不是基于题目定义,而是题目的灰色地带在作者解答的具体实现下的实际效果硬拉出来的。为了通过测试,基于版本一改造出了版本二,如果只是为了学习探讨,不在意通过完全通过测试,可不用关心。