10-套路六:特殊特性要记清
课程
1
如何阅读本小册
已学完
学习时长: 2分6秒
2
为什么说 TypeScript 的火爆是必然?
已学完
学习时长: 4分28秒
3
TypeScript 类型编程为什么被叫做类型体操?
已学完
学习时长: 9分16秒
4
TypeScript 类型系统支持哪些类型和类型运算?
已学完
学习时长: 19分27秒
5
套路一:模式匹配做提取
已学完
学习时长: 41分32秒
6
套路二:重新构造做变换
已学完
学习时长: 39分51秒
7
套路三:递归复用做循环
学习时长: 42分22秒
8
套路四:数组长度做计数
学习时长: 27分58秒
9
套路五:联合分散可简化
学习时长: 19分36秒
10
套路六:特殊特性要记清
学习时长: 36分41秒
11
类型体操顺口溜
学习时长: 27分26秒
12
TypeScript 内置的高级类型有哪些?
学习时长: 31分59秒
13
真实案例说明类型编程的意义
学习时长: 34分36秒
14
类型编程综合实战一
学习时长: 25分55秒
15
类型编程综合实战二
学习时长: 29分9秒
16
新语法 infer extends 是如何简化类型编程的
学习时长: 11分14秒
17
原理篇:逆变、协变、双向协变、不变
学习时长: 11分31秒
18
原理篇:编译 ts 代码用 tsc 还是 babel?
学习时长: 19分49秒
19
原理篇:实现简易 TypeScript 类型检查
学习时长: 47分51秒
20
原理篇:如何阅读 TypeScript 源码
学习时长: 19分27秒
21
原理篇:一些特殊情况的说明
已学完
学习时长: 12分39秒
22
小册总结
学习时长: 2分32秒
23
加餐:3 种类型来源和 3 种模块语法
学习时长: 17分6秒
24
加餐:用 Project Reference 优化 tsc 编译性能
已学完
学习时长: 4分47秒
juejin_logo copyCreated with Sketch.

我们会了提取、构造、递归、数组长度的计数、联合类型的分散之后,各种类型体操都能写了 ,只不过有些类型的特性比较特殊,要专门记一下。

这是类型体操的第六个套路:特殊特性要记清。

特殊类型的特性

TypeScript 类型系统中有些类型比较特殊,比如 any、never、联合类型,比如 class 有 public、protected、private 的属性,比如索引类型有具体的索引和可索引签名,索引还有可选和非可选。。。

如果给我们一种类型让我们判断是什么类型,应该怎么做呢?

类型的判断要根据它的特性来,比如判断联合类型就要根据它的 distributive 的特性。

我们分别看一下这些特性:

IsAny

如何判断一个类型是 any 类型呢?要根据它的特性来:

any 类型与任何类型的交叉都是 any,也就是 1 & any 结果是 any。

所以,可以这样写:

type IsAny<T> = 'dong' extends ('guang' & T) ? true : false

这里的 'dong' 和 'guang' 可以换成任意类型。

当传入 any 时:

当传入其他类型时:

试一下

IsEqual

之前我们实现 IsEqual 是这样写的:

type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false);

问题也出在 any 的判断上:

因为 any 可以是任何类型,任何类型也都是 any,所以当这样写判断不出 any 类型来。

所以,我们会这样写:

type IsEqual2<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
    ? true : false;

这样就能正常判断了:

这是因为 TS 对这种形式的类型做了特殊处理,是一种 hack 的写法,它的解释要从 TypeScript 源码找答案了,我放到了原理篇。感兴趣可以提前看一下。

试一下

IsUnion

还记得怎么判断 union 类型么?要根据它遇到条件类型时会分散成单个传入做计算的特性:

type IsUnion<A, B = A> =
    A extends A
        ? [B] extends [A]
            ? false
            : true
        : never

这里的 A 是单个类型,B 是整个联合类型,所以根据 [B] extends [A] 是否成立来判断是否是联合类型。(详见上节)

当传入联合类型时:

当传入单个类型时:

试一下

IsNever

never 在条件类型中也比较特殊,如果条件类型左边是类型参数,并且传入的是 never,那么直接返回 never:

type TestNever<T> = T extends number ? 1 : 2;

当 T 为 never 时:

所以,要判断 never 类型,就不能直接 T extends number,可以这样写:

type IsNever<T> = [T] extends [never] ? true : false

这样就能正常判断 never 类型了:

试一下

除此以外,any 在条件类型中也比较特殊,如果类型参数为 any,会直接返回 trueType 和 falseType 的合并:

type TestAny<T> = T extends number ? 1 : 2;

试一下

联合类型、never、any 在作为条件类型的类型参数时的这些特殊情况,也会在后面的原理篇来解释原因。

IsTuple

元组类型怎么判断呢?它和数组有什么区别呢?

元组类型也是数组类型,但每个元素都是只读的,并且 length 是数字字面量,而数组的 length 是 number。

第一个特性,元组类型也是数组类型,并且每个元素都是只读,这个很好理解。

我们重点来看第二个特性:

如图,元组和数组的 length 属性值是有区别的。

那我们就可以根据这两个特性来判断元组类型:

type IsTuple<T> = 
    T extends readonly [...params: infer Eles] 
        ? NotEqual<Eles['length'], number> 
        : false

类型参数 T 是要判断的类型。

首先判断 T 是否是数组类型,如果不是则返回 false。如果是继续判断 length 属性是否是 number。

如果是数组并且 length 不是 number 类型,那就代表 T 是元组。

NotEqual 的实现是这样的:

type NotEqual<A, B> = 
    (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
    ? false : true;

A 是 B 类型,并且 B 也是 A 类型,那么就是同一个类型,返回 false,否则返回 true。

这样就可以判断出元组类型:

当传入元组时:

当传入数组时:

试一下

UnionToIntersection

类型之间是有父子关系的,更具体的那个是子类型,比如 A 和 B 的交叉类型 A & B 就是联合类型 A | B 的子类型,因为更具体。

如果允许父类型赋值给子类型,就叫做逆变

如果允许子类型赋值给父类型,就叫做协变

(关于逆变、协变等概念的详细解释可以看原理篇)

在 TypeScript 中有函数参数是有逆变的性质的,也就是如果参数可能是多个类型,参数类型会变成它们的交叉类型。

所以联合转交叉可以这样实现 :

type UnionToIntersection<U> = 
    (U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown
        ? R
        : never

类型参数 U 是要转换的联合类型。

U extends U 是为了触发联合类型的 distributive 的性质,让每个类型单独传入做计算,最后合并。

利用 U 做为参数构造个函数,通过模式匹配取参数的类型。

结果就是交叉类型:

试一下

函数参数的逆变性质一般就联合类型转交叉类型会用,记住就行。

GetOptional

如何提取索引类型中的可选索引呢?

这也要利用可选索引的特性:可选索引的值为 undefined 和值类型的联合类型

过滤可选索引,就要构造一个新的索引类型,过程中做过滤:

type GetOptional<Obj extends  Record<string, any>> = {
    [
        Key in keyof Obj 
            as {} extends Pick<Obj, Key> ? Key : never
    ] : Obj[Key];
}

类型参数 Obj 为待处理的索引类型,类型约束为索引为 string、值为任意类型的索引类型 Record<string, any>。

用映射类型的语法重新构造索引类型,索引是之前的索引也就是 Key in keyof Obj,但要做一些过滤,也就是 as 之后的部分。

过滤的方式就是单独取出该索引之后,判断空对象是否是其子类型。

这里的 Pick 是 ts 提供的内置高级类型,就是取出某个 Key 构造新的索引类型:

type Pick<T, K extends keyof T> = { [P in K]: T[P]; }

比如单独取出 age 构造的新的索引类型是这样的:

可选的意思是这个索引可能没有,没有的时候,那 Pick<Obj, Key> 就是空的,所以 {} extends Pick<Obj, Key> 就能过滤出可选索引。

值的类型依然是之前的,也就是 Obj[Key]。

这样,就能过滤出所有可选索引,构造成新的索引类型:

注意,可选不是值可能是 undefined 的意思,比如这样:

type Obj = {
    a: 'aaa' | undefined
};

这个 a 的索引是可选的么?

明显不是,加上 ? 才是。

type Obj = {
    a?: 'aaa' | undefined
};

可选的意思是指有没有这个索引,而不是索引值是不是可能 undefined。

试一下

GetRequired

实现了 GetOptional,那反过来就是 GetRequired,也就是过滤所有非可选的索引构造成新的索引类型:

type isRequired<Key extends keyof Obj, Obj> = 
    {} extends Pick<Obj, Key> ? never : Key;

type GetRequired<Obj extends Record<string, any>> = { 
    [Key in keyof Obj as isRequired<Key, Obj>]: Obj[Key] 
}

这样就过滤出了非可选类型:

试一下

RemoveIndexSignature

索引类型可能有索引,也可能有可索引签名。

比如:

type Dong = {
  [key: string]: any;
  sleep(): void;
}

这里的 sleep 是具体的索引,[key: string]: any 就是可索引签名,代表可以添加任意个 string 类型的索引。

如果想删除索引类型中的可索引签名呢?

同样根据它的性质,索引签名不能构造成字符串字面量类型,因为它没有名字,而其他索引可以。

所以,就可以这样过滤:

type RemoveIndexSignature<Obj extends Record<string, any>> = {
  [
      Key in keyof Obj 
          as Key extends `${infer Str}`? Str : never
  ]: Obj[Key]
}

类型参数 Obj 是待处理的索引类型,约束为 Record<string, any>。

通过映射类型语法构造新的索引类型,索引是之前的索引 Key in keyof Obj,但要做一些过滤,也就是 as 之后的部分。

如果索引是字符串字面量类型,那么就保留,否则返回 never,代表过滤掉。

值保持不变,也就是 Obj[Key]。

这样就可以过滤掉可索引签名:

试一下

ClassPublicProps

如何过滤出 class 的 public 的属性呢?

也同样是根据它的特性:keyof 只能拿到 class 的 public 索引,private 和 protected 的索引会被忽略

比如这样一个 class:

class Dong {
  public name: string;
  protected age: number;
  private hobbies: string[];

  constructor() {
    this.name = 'dong';
    this.age = 20;
    this.hobbies = ['sleep', 'eat'];
  }
}

keyof 拿到的只有 name:

所以,我们就可以根据这个特性实现 public 索引的过滤:

type ClassPublicProps<Obj extends Record<string, any>> = {
    [Key in keyof Obj]: Obj[Key]    
}

类型参数 Obj 为带处理的索引类型,类和对象都是索引类型,约束为 Record<string, any>。

构造新的索引类型,索引是 keyof Obj 过滤出的索引,也就是 public 的索引。

值保持不变,依然是 Obj[Key]。

这样就能过滤出 public 的属性:

试一下

as const

TypeScript 默认推导出来的类型并不是字面量类型。

比如对象:

数组:

但是类型编程很多时候是需要推导出字面量类型的,这时候就需要用 as const:

但是加上 as const 之后推导出来的类型是带有 readonly 修饰的,所以再通过模式匹配提取类型的时候也要加上 readonly 的修饰才行。

const 是常量的意思,也就是说这个变量首先是一个字面量值,而且还不可修改,有字面量和 readonly 两重含义。所以加上 as const 会推导出 readonly 的字面量类型。

比如反转那个三个元素的元组类型,不加上 readonly 再匹配是匹配不出来的:

加上 readonly 之后就可以正常匹配了:

这点在类型编程的实际应用中经常遇到,要注意一下。 试一下

总结

学完前面 5 个套路,我们已经能够实现各种类型编程逻辑了,但一些类型的特性还是要记一下。在判断或者过滤类型的时候会用到:

  • any 类型与任何类型的交叉都是 any,也就是 1 & any 结果是 any,可以用这个特性判断 any 类型。
  • 联合类型作为类型参数出现在条件类型左侧时,会分散成单个类型传入,最后合并。
  • never 作为类型参数出现在条件类型左侧时,会直接返回 never。
  • any 作为类型参数出现在条件类型左侧时,会直接返回 trueType 和 falseType 的联合类型。
  • 元组类型也是数组类型,但每个元素都是只读的,并且 length 是数字字面量,而数组的 length 是 number。可以用来判断元组类型。
  • 函数参数处会发生逆变,可以用来实现联合类型转交叉类型。
  • 可选索引的索引可能没有,那 Pick 出来的就可能是 {},可以用来过滤可选索引,反过来也可以过滤非可选索引。
  • 索引类型的索引为字符串字面量类型,而可索引签名不是,可以用这个特性过滤掉可索引签名。
  • keyof 只能拿到 class 的 public 的索引,可以用来过滤出 public 的属性。
  • 默认推导出来的不是字面量类型,加上 as const 可以推导出字面量类型,但带有 readonly 修饰,这样模式匹配的时候也得加上 readonly 才行。

这些类型的特性要专门记一下,其实过两遍就记住了。

熟悉了这些特殊的特性,配合提取、构造、递归、数组长度计数、联合分散这五种套路,就可以实现各种类型体操。

本文案例的合并

留言
Ctrl + Enter
全部评论(40)
hedgehog_boy的头像
删除
还是想问一下,顺序问题是不重要的是吗?
点赞
1
删除
(作者)
对的,联合类型顺序没影响
点赞
回复
三郎mr的头像
删除
type UnionToIntersection<U> =
(U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown
? R
: never

U extends U 是为了触发联合类型的 distributive 的性质,让每个类型单独传入做计算,最后合并。

为什么最终结果是交叉类型,而不是联合类型
展开
点赞
2
删除
(作者)
这个看逆变那一节,有解释
点赞
回复
删除
ok,感谢
这个看逆变那一节,有解释
点赞
回复
Asuka14024的头像
删除
前端工程师
type IsNever<T> = [T] extends [never] ? true : false 不理解这里的[]是什么作用
点赞
2
删除
(作者)
避免触发联合类型的分发,在套路五有讲
点赞
回复
删除
[害羞]这个是知道的,当时纠结的点在于不知道never是一个空的联合类型
避免触发联合类型的分发,在套路五有讲
点赞
回复
梦溪笔记的头像
删除
type IsTuple<T>
=
T extends unknown[]
? number extends T['length']
? false
: true
: false
试了下 IsTuple 可以这么写
展开
点赞
回复
前端小鹿的头像
删除
UnionToIntersection这里
如果允许子类型赋值给付类型,就叫做协变 付字错了吧
点赞
回复
爱吃鱼的桶哥Z的头像
删除
伪 · 全栈打杂攻城狮
最后这两小节确实是比前面的四节要难一点[哭笑]
2
1
删除
是的,看是看懂了,不太能够理解具体可以用在什么地方。好绕这几个名词
点赞
回复
重威的头像
删除
当对象索引是一个数字的时候,RemoveIndexSignature 也会把数字索引过滤掉,那是不是不合理呢,RemoveIndexSignature 能兼容不过滤数字索引的情况吗,我想了半天没写出来。。。
点赞
1
删除
type RemoveIndexSignature<Obj extends Record<string|number, any>> = {
[
Key in keyof Obj as
Key extends `${infer Str}`
? Str : Key extends number
? number extends Key
? never : Key
: never
]: Obj[Key]
}
展开
点赞
回复
WHY知行的头像
删除
研发
请教一下为啥我这里照抄的案例
type GetOptionalResult = GetOptional<{
name: string;
age?: number;
}>;
代码提示如下(没有undefined)
type GetOptionalResult = { age?: number }

输出内容没有undefined?
展开
点赞
2
删除
我的 IDE 也没有提示,不过我觉得知道是那个意思就行了
点赞
回复
删除
回复
会不会是ts的版本问题
我的 IDE 也没有提示,不过我觉得知道是那个意思就行了
点赞
回复
瓦嘞嘞的头像
删除
请教各位,为啥这两种写法结果不一样啊[流泪]

type IsEqual0<A, B> =
A extends B
? (B extends A
? true
: false)
: false
type IsEqual1<A, B> = (A extends B ? true : false) & (B extends A ? true : false);

// 测试用例
type E0 = IsEqual0<1, any>; // boolean
type E1 = IsEqual1<1, any>; // true
展开
1
2
删除
(作者)
用文中那个最终版的 equal
点赞
回复
删除
我瞎理解的:1 extends any时,any作为更宽泛的类型这里判断肯定为true。any extends 1时,把any想象成distributive的感觉,那这里的判断有可能true有可能false,即boolean;
对于IsEqual1类型,就被理解成了 true & boolean。
点赞
回复
YibuMe的头像
删除
Node.js探索者
这里的逆变,为什么合并的时候变成 交叉类型了? 不应该还是联合类型吗?
type UnionToIntersection<U> =
(U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown
? R
: never
2
1
删除
同问
点赞
回复
端同志的头像
删除
为啥这样判断RemoveIndexSignature 是错误的?无法移除索引类型。Key extends `${infer Str}` ? Str 和Key extends string ? Key 有什么区别
点赞
1
删除
(作者)
字面量类型才能用 infer 提取,string 不可以,按照这个来过滤可索引签名
点赞
回复
mark_huang的头像
删除
这样为什么是 never 呀。。。。
1
2
删除
'a' 与 'b' join 不就是为never吗,一个类型不会既是 'a' 又是 'b'
点赞
回复
删除
和你陷入了同样的误区[哭笑]
点赞
回复
0_0k的头像
删除
请教下,逆变/协变这里,换一种写法就没有逆变了,这个是什么原因呢?
type data = ((x: {
name: 'natasha';
}) => unknown) | ((x: {
gender: 'female';
}) => unknown)
type compareUnion<a> = a extends (x: infer R) => unknown ? R : never
type compareUnionRes = compareUnion<data> // 这里是 | 联合类型
type UnionToIntersectionMidRes = data extends (x: infer R) => unknown ? R : never // 这里 逆变 & 了呢?
展开
点赞
1
删除
点赞
回复
webgzh907247189的头像
删除
打杂 @ 打杂
type OptionalKeys<T, P = keyof T> = P extends keyof T ? Omit<T, P> extends T ? P : never : never;
type GetOptional<Obj extends Record<string, any>> = Pick<Obj, OptionalKeys<Obj>>;
type a1 = GetOptional<{ foo: number, bar?: string }>
type GetRequired<Obj extends Record<string, any>> = Omit<Obj, OptionalKeys<Obj>>;
type a2 = GetRequired<{ foo: number, bar?: string }>
这样是不是更好理解?
展开
点赞
1
删除
(作者)
还没讲到内置高级类型
1
回复
EEEEEEEEE的头像
删除
前端
光神 牛逼
点赞
1
删除
(作者)
[送心]
点赞
回复
hjiog的头像
删除
前端开发 @ 深圳今日头条科技有限公司
"在 TypeScript 中有函数参数是有逆变的性质的,也就是如果传入联合类型,会返回交叉类型"这句话是什么意思呢?我尝试了一下好像不是那样
点赞
3
删除
另外小册可以加多些协变与逆变的例子吗?对这块的知识一直比较模糊[可怜]
点赞
回复
删除
(作者)
是指 UnionToIntersection 那个类型的用法
点赞
回复
查看更多回复
年年呀的头像
删除
啥都懂一些的前端
太酷了 打开小册前从来不知道ts可以这么玩
点赞
1
删除
(作者)
[碰拳]
点赞
回复
威大大的头像
删除
光之呼吸
点赞
回复