携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第12天,点击查看活动详情
本文的很多内容是来源于如何进阶TypeScript功底?一文带你理解TS中各种高级语法,这里只是作为学习笔记加深印象。
类型谓词 is
function isString(s: unknown): boolean {
return typeof s === 'string'
}
// 参数转为大写函数
function upperCase(str: unknown) {
// 直接使用转大写方法报错, str有可能是其他类型
str.toUpperCase()
// 类型“unknown”上不存在属性“toUpperCase”。
}
// 判断参数是否为字符串,是在调用转大写方法
function ifUpperCase(str: unknown) {
if (isString(str)) {
str.toUpperCase()
// (parameter) str: unknown
// 报错:类型“unknown”上不存在属性“toUpperCase”
}
}
示例中我们虽然判断了参数str是string类型, 但是条件为true时, 参数str的类型还是unknown,也就是说这个条件判断并没有更加明确str的具体类型。
此时,可以在判断是否为string类型的函数返回值类型使用is关键词(即类型谓词)。
// 判断参数是否为string类型, 返回布尔值
function isString(s:unknown):s is string{
return typeof s === 'string'
}
// 判断参数是否为字符串,是在调用转大写方法
function ifUpperCase(str:unknown){
if(isString(str)){
str.toUpperCase()
// (parameter) str: string
}
}
因此当我们判断条件为true, 即str为string类型时, 代码块中str类型也转为更明确的string类型。
类型谓词的主要特点是:
- 返回类型谓词,如
s is string; - 包含可以准确确定给定变量类型的逻辑语句,如
typeof s === 'string'。
因此,我们在判断对一个变量是不是某个类型的时候就可以利用类型谓词了:
const toString = Object.prototype.toString
export function isPlainObject(val: any): val is Object {
return toString.call(val) === '[object Object]'
}
接口泛型位置
来看看这样一个简单的例子:
// 定义一个泛型接口 IPerson表示一个类,它返回的实例对象取决于使用接口时传入的泛型T
interface IPerson<T> {
// 因为我们还没有讲到unknown 所以暂时这里使用any 代替
new(...args: unknown[]): T;
}
function getInstance<T>(Clazz: IPerson<T>) {
return new Clazz();
}
// use it
class Person {}
// TS推断出函数返回值是person实例类型
const person = getInstance(Person);
定义接口 IPerson 时,这个接口定义了一个泛型参数 T 表示返回的实例类型。
当使用时,我们需要在使用接口时声明该 T 类型,比如IPerson<T>。
接下来我们在看对比另外一个例子:
// 声明一个接口IPerson代表函数
interface IPerson {
// 此时注意泛型是在函数中参数 而非在IPerson接口中
<T>(a: T): T;
}
// 函数接受泛型
const getPersonValue: IPerson = <T>(a: T): T => {
return a;
};
// 相当于getPersonValue<number>(2)
getPersonValue(2)
这里上下两个例子特别像强调的是关于泛型接口中泛型的位置是代表完全不同的含义:
-
当泛型出现在接口中时,比如
interface IPerson<T>代表的是使用接口时需要传入泛型的类型,比如IPerson<T>。 -
当泛型出现在接口内部时,比如第二个例子中的
IPerson接口代表一个函数,接口本身并不具备任何泛型定义。而接口代表的函数则会接受一个泛型定义。换句话说接口本身不需要泛型,而在实现使用接口代表的函数类型时需要声明该函数接受一个泛型参数。
keyof
所谓 keyof 关键字代表它接受一个对象类型作为参数,并返回该对象所有 key 值组成的联合类型。
interface IProps {
name: string;
age: number;
sex: string;
}
// Keys 类型为 'name' | 'age' | 'sex' 组成的联合类型
type Keys = keyof IProps
在了解了 keyof 关键字之后,让我们结合泛型来实现一个简单的例子来练练手。
比如此时,我们希望实现一个函数。该函数希望接受两个参数,第一个参数为一个对象object,第二个参数为该对象的 key 。函数内部通过传入的 object 以及对应的 key 返回 object[key] 。
function getValueFromKey(obj: object, key: string) {
// throw error
// key的值为string代表它仅仅只被规定为字符串
// TS无法确定obj中是否存在对应的key
return obj[key];
}
显然,我们直接为参数声明类型这是会报错的。
// 函数接受两个泛型参数
// T 代表object的类型,同时T需要满足约束是一个对象
// K 代表第二个参数K的类型,同时K需要满足约束keyof T (keyof T 代表object中所有key组成的联合类型)
// 自然,我们在函数内部访问obj[key]就不会提示错误了
function getValueFromKey<T extends object, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
TS 高级概念
分发
在讲述分发的概念,需要先知道 TS 中的 Conditional Types (条件类型)。
因为大多数高级类型都是基于条件类型,同时分发的概念也和 Conditional Types 息息相关,所以我们先来看看所谓的 Conditional Types 究竟是什么。
type isString<T> = T extends string ? true : false;
// a 的类型为 true
let a: isString<'a'>
// b 的类型为 false
let b: isString<1>;
其实所谓的条件类型就是这么简单,看起来和三元表达式非常相似,甚至你完全可以将它理解成为三元表达式。只不过它接受的是类型以及判断的是类型而已。
需要额外注意的是:
-
这里的
T extends string更像是一种判断泛型 T 是否满足 string 的判断,和之前所讲的泛型约束完全不是同一个意思。 -
其次,需要注意的是条件类型
a extends b ? c : d仅仅支持在 type 关键字中使用。
在了解了泛型约束之后,回到所谓分发的概念上来,一起来看看这样一个例子:
type GetSomeType<T extends string | number> = T extends string ? 'a' : 'b';
let someTypeOne: GetSomeType<string> // someTypeone 类型为 'a'
let someTypeTwo: GetSomeType<number> // someTypeone 类型为 'b'
let someTypeThree: GetSomeType<string | number>; // what ?
这里我们定义了一个 GetSomeType 的类型,它接受一个泛型参数 T 。这个泛型参数 T 在传入时需要满足为 string 和 number 的联合类型的约束。(换句话说,要么为 string 要么为 number 要么为 string | number)。
someTypeThree 定义时的类型 GetSomeType<'string' | 1> 我们传入的泛型参数为联合类型时 'string' | 1 时,它会的到什么类型呢?
首先不难想象,我们按照正常逻辑来思考。'string' | 1 一定是不满足 T extends string,因为一个 'string' | 1 的联合类型一定是无法和 string 类型做兼容的。
那么按照我们之前的逻辑来梳理,理所应当 someTypeThree 的类型应该是 'b' 对吧。
但是,someTypeThree 的类型竟然被推导成为了 'a' | 'b' 组成的联合类型,那么为什么会这样呢。
其实这就是所谓分发在捣鬼。
我们抛开晦涩的概念来解读分发,结合上边的 Demo 来说所谓的分发简单来说就是分别使用 string 和 number 这两个类型进入 GetSomeType 中进行判断,最终返回两次类型结果组成的联合类型。
// 你可以这样理解分发
// 伪代码:GetSomeType<string | number> = GetSomeType<string> | GetSomeType<number>
let someTypeThree: GetSomeType<string | number>
当然,你可以在使用 GetSomeType 你可以传入n个类型组成的联合类型作为泛型参数,同理它会进行进入 GetSomeType 类型中进行 n 次分发判断。
那么,什么情况下会产生分发呢?满足分发需要一定的条件,我们来一起看看:
- 首先,毫无疑问分发一定是需要产生在 extends 产生的类型条件判断中,并且是前置类型。
比如T extends string | number ? 'a' : 'b'; 那么此时,产生分发效果的也只有 extends 关键字前的 T 类型,string | number 仅仅代表一种条件判断。
- 其次,分发一定是要满足联合类型,只有联合类型才会产生分发(其他类型无法产生分发的效果,比如 & 交集中等等)。
- 最后,分发一定要满足所谓的裸类型中才会产生效果。,比如:T并不是一个单独的"裸类型"T 而是 [T]。
在了解了分发的概念和清楚了如何会产生分发的效果后。趁热打铁我们来看看利用分发我们可以实现什么样的效果:
在 TypeScript 内部拥有一个高级内置类型 Exclude 意为排除,它的用法如下:
type TypeA = string | number | boolean | symbol;
// ExcludeSymbolType 类型为 string | number | boolean,排除了symbol类型
type ExcludeSymbolType = Exclude<TypeA, symbol>;
用法非常简单,Exclude 内置类型会接受两个类型泛型参数。它会构造一个新的类型,这个类型会排除所有 TypeA 类型中满足 symbol 的类型。
那么,如果让你来实现一个 Exclude 内置类型,你会如何实现呢?
type TypeA = string | number | boolean | symbol;
type MyExclude<T, K> = T extends K ? never : T;
// ExcludeSymbolType 类型为 string | number | boolean,排除了symbol类型
type ExcludeSymbolType = MyExclude<TypeA, symbol>;
MyExclude 类型接受两个泛型参数,因为 T extends K ? never : T 中 T 满足裸类型并且在 extends 关键字前。
同时,我们传入的 TypeA 为联合类型,那么满足分发的所有条件。则会产生分发效果,也就是说会将联合类型 TypeA 中所有的单个类型依次进入 T extends K ? never : T; 去判断。
当满足条件时,也就是 T extends symbol 时,此时会得到 never 。(这里的 never 代表的也就是一个无法达到的类型,不会产生任何效果),自然就会被忽略。
当然和 Exclude 相反效果的内置类型 Extract、NonNullable也是基于分发实现的,有兴趣可以自行查阅实现。
循环 in
TypeScript 中同样存在对于类型的循环语法(Mapping Type),通过我们可以通过 in 关键字配合联合类型来对于类型进行迭代。
interface IProps {
name: string;
age: number;
highSchool: string;
university: string;
}
type IPropsKey = { [K in keyof IProps]: boolean };
IPropsKey类型为:
type IPropsKey = {
name: boolean;
age: boolean;
highSchool: boolean;
university: boolean;
}
你可以理解为 in 关键字的作用类似于 for 循环,它会循环 keyof IProps 这个联合类型中的每一项类型,同时在每一次循环中将对应的类型赋值给 K 。
那么在 TS 中我们可以利用循环的特性来做什么呢?不知道大家有没有用到 Partial 之类的内置类型。
自己动手来实现一下它,其实结合我们刚刚说到的循环来实现会非常简单。
interface IInfo {
name: string;
age: number;
}
type MyPartial<T> = { [K in keyof T]?: T[K] };
type OptionalInfo = MyPartial<IInfo>;
当然需要注意的是我们刚才提到的所有关键字,比如 extends 进行条件判断或者 in 进行类型循环时,仅仅支持在 type 类型声明中使用,并不可以在 interface 中使用,这也是 type 和 interface 声明的一个不同。
当然在循环的最后,我们来思考另一个问题。其实你会发现无论是 TS 内置的 Partial 还是我们刚刚自己实现的 Partial ,它仅仅对象中一层的转化并不能递归处理。
interface IInfo {
name: string;
age: number;
school: {
middleSchool: string;
highSchool: string;
university: string;
}
}
type OptionalInfo = Partial<IInfo>;
可以看到利用 Partial 关键字仅仅对于对象类型中的最外层进行了可选标记。
但是对于内层嵌套类型比如 school 仍是一个对象类型,那么此时是无法深度进入 school 类型中进行标记的。
那么假如此时我有需求希望实现深度可选,应该如何做呢?
interface IInfo {
name: string;
age: number;
school: {
middleSchool: string;
highSchool: string;
university: string;
};
}
// 其实实现很简单,首先我们在构造新的类型value时
// 利用 extends 条件判断新的类型value是否为 object
// 如果是 -> 那么我仍然利用 deepPartial<T[K]> 进行包裹递归可选处理
// 如果不是 -> 普通类型直接返回即可
type deepPartial<T> = {
[K in keyof T]?: T[K] extends object ? deepPartial<T[K]> : T[K];
};
type OptionalInfo = deepPartial<IInfo>;
let value: OptionalInfo = {
name: '1',
school: {
middleSchool:'xian'
},
};
逆变
首先,我们先来思考这样一个场景:
let a!: { a: string; b: number };
let b!: { a: string };
b = a
非空断言操作符 !: 表示在此处告诉编译器,此成员不会为null,不会为undefined;
我们都清楚 TS 属于静态类型检测,所谓类型的赋值是要保证安全性的。
通俗来说也就是多的可以赋值给少的,上述代码因为 a 的类型定义中完全包括 b 的类型定义,所以 a 类型完全是可以赋值给 b 类型,这被称为类型兼容性。
之后,我们再来思考这样一段代码:
let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;
fn1 = fn2; // TS Error: 不能将fn2的类型赋值给fn1
我们将 fn2 赋值给 fn1 ,刚刚才提到类型兼容性的原因 TS 允许不同类型进行互相赋值(只需要父/子集关系),那么明明 fn2 的参数包括了所有的 fn1 为什么会报错?
上述的问题,其实和刚刚没有什么本质区别。我们来换一个角度来理解这个问题:
针对于 fn1 声明时,函数类型需要接受两个参数,换句话说调用 fn1 时我需要支持两个参数的传入分别是 a:string和b:number。
同理 fn2 函数定义时,定义了三个参数那么调用 fn2 时自然也需要传入三个参数。
那么此时,我们将 fn2 赋值给 fn1 ,我们可以思考下。如果赋值成功了,当我调用 fn1 时,其实相当于调用 fn2 没错吧。
但是,由于 fn1 的函数类型定义仅仅支持两个参数 a:string和b:number 即可。但是由于我们执行了 fn1 = fn2。
调用 fn1 时,实际相当于调用了 fn2 函数。但是类型定义上来说 fn1 满足两个参数传入即可,而 fn2 是实打实的需要传入 3 个参数。
那么此时,如果执行了 fn1 = fn2 当调用 fn1 时明显参数个数会不匹配(由于类型定义不一致)会缺少一个第三个参数,显然这是不安全的,自然也不是被 TS 允许的。
那么反过来呢?
let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;
fn2 = fn1; // 正确,被允许
fn1 在执行时仅仅需要两个参数 a: string, b: number,显然 fn2 的类型定义中是满足这个条件的(当然它还多传递了第三个参数 c:boolean,在 JS 中对于函数而言调用时的参数个数大于定义时的参数个数是被允许的)。
自然,这是安全的也是被 TS 允许赋值。
就比如上述函数的参数类型赋值就被称为逆变,参数少(父)的可以赋给参数多(子)的那一个。看起来和类型兼容性(多的可以赋给少的)相反,但是通过调用的角度来考虑的话恰恰满足多的可以赋给少的兼容性原则。
上述这种函数之间互相赋值,他们的参数类型兼容性是典型的逆变。
我们再来看一个稍微复杂点的例子来加深所谓逆变的理解:
class Parent {}
// Son继承了Parent 并且比parent多了一个实例属性 name
class Son extends Parent {
public name: string = '19Qingfeng';
}
// GrandSon继承了Son 在Son的基础上额外多了一个age属性
class Grandson extends Son {
public age: number = 3;
}
// 分别创建父子实例
const son = new Son();
function someThing(cb: (param: Son) => any) {
// do some someThing
// 注意:这里调用函数的时候传入的实参是Son
cb(son);
}
someThing((param: Grandson) => param); // error
someThing((param: Parent) => param); // correct
注意这里,我们先用刚才的结论来推导。刚才我们提到过函数的参数的方式被称为逆变,所以当我们调用 someThing 时传递的 callback 需要赋给定义 something 函数中的 cb 。
换句话说类型 (param: Grandson) => param 需要赋给 cb: (param: Son) => any,这显然是不被允许的。
因为逆变的效果函数的参数只允许从少的赋值给多的,显然 Grandson 相较于 Son 来说多了一个 name 属性少,所以这是不被允许的。
相反,第二个someThing((param: Parent) => param);相当于函数参数将 Parent 赋给 Son 将少的赋给多的满足逆变,所以是正确的。
下面从类型兼容性的角度来分析,为什么第二个someThing((param: Parent) => param);是正确的。
someThing 内部cb 函数声明时需要满足 Son 的参数,它会在 cb 函数调用时传入一个 Son 参数的实参。
所以当我们传入 someThing((param: Parent) => param) 时,相当于在 something 函数内部调用 (param: Parent) => param 时会根据 someThing 中callback的定义传入一个 Son 。
那么此时,我们函数真实调用时期望得到是 Parent,但是实际得到了 Son 。Son 是 Parent 的子类涵盖所有 Parent 的公共属性方法,自然也是满足条件的。
反而言之,当我们使用someThing((param: Grandson) => param); ,由于 something 定义 cb 的类型传入 Son,但是真实调用 someThing 时,我们期望一个 Grandson 类型参数的函数,但是实际得到了 Son,Son 不能覆盖 Grandson 所有的属性和方法,所以这显然是不符合的。
协变
let fn1!: (a: string, b: number) => string;
let fn2!: (a: string, b: number) => string | number | boolean;
fn2 = fn1; // correct
fn1 = fn2 // error: 不可以将 string|number|boolean 赋给 string 类型
这里,函数类型赋值兼容时函数的返回值就是典型的协变场景,我们可以看到 fn1 函数返回值类型规定为 string,fn2 返回值类型规定为 string | number | boolean 。
显然 string | number | boolean 是无法分配给 string 类型的,但是 string 类型是满足 string | number | boolean 其中之一,所以自然可以赋值给 string | number | boolean 组成的联合类型。
待推断类型 infer
infer 代表待推断类型,它的必须和 extends 条件约束类型一起使用。
之前,我们在 类型关键字中遗留了 infer 关键字并没有展开讲述,这里我们了解了所谓的 extends 代表的类型约束之后我们来一起看看所谓 infer 带来的待推断类型效果。
在条件类型约束中为我们提供了 infer 关键字来提供实现更多的类型可能,它表示我们可以在条件类型中推断一些暂时无法确定的类型,比如这样:
type Flatten<T> = T extends Array<infer Item> ? Item : T;
上述我们定义了一个 Flatten 类型,它接受一个传入的泛型 T ,我们在类型定义内部对于传入的泛型 T 进行了条件约束:
- 如果 T 满足
Array<infer Item>,那么此时返回 Item 类型。 - 如果 T 不满足
Array<infer Item>类型,那么此时返回 T 类型。
关于如何理解 Array<infer Item>,一句话描述就是我们利用 infer 声明了一个数组类型,数组中值的类型我们并不清楚所以使用 infer 来进行推断数组中的值。
type Flatten<T> = T extends Array<infer Item> ? Item : T;
type sunT = Flatten<string> // string
type sunT1 = Flatten<[string, number]> // string | number
所谓的 Array<infer Item>代表的进行条件判断时要求前者(Type)必须是一个数组,但是数组中的类型我并不清楚(或者说可以是任意)。
自然我们使用 infer 关键字表示待推断的类型, infer 后紧跟着类型变量 Item 表示的就是待推断的数组元素类型。
我们类型定义时并不能立即确定某些类型,而是在使用类型时来根据条件来推断对应的类型。之后,因为数组中的元素可能为 string 也可能为 number,自然在使用类型时 infer Item 会将待推断的 Item 推断为 string | number 联合类型。
需要注意的是 infer 关键字类型,必须结合 Conditional Types 条件判断来使用。
那么,在条件类型中结合 infer 会帮助我们带来什么样的作用呢?我们一起来看看 infer 的实际用法。
在 TS 中存在一个内置类型 Parameters ,它接受传入一个函数类型作为泛型参数并且会返回这个函数所有的参数类型组成的元祖。
// 定义函数类型
interface IFn {
(age: number, name: string): void;
}
// type FnParameters = [age: number, name: string]
type FnParameters = Parameters<IFn>;
let a: FnParameters = [25, '19Qingfeng'];
它的内部实现恰恰是利用 infer 来实现的,可以自己尝试来实现这个内置类型。
type MyParameters<T extends (...args: any) => any> = T extends (
...args: infer R
) => any
? R
: never;
如果满足条件也就是 T extends ( ...args: infer R ) => any,需要注意的是条件判断中函数的参数并不是在类型定义时就确认的,函数的参数需要根据传入的泛型来确认后赋给变量 R, 所以使用了 infer R 来表示待推断的函数参数类型。
那么此时我会返回满足条件的函数推断参数组成的数组也就是 ...args 的类型 R ,否则则返回 never。
unknown & any
在 TypeScript 中同样存在一个高级类型 unknown ,它可以代表任意类型的值,这一点和 any 是非常类型的。
但是我们清楚将类型声明为 any 之后会跳过任何类型检查,比如这样:
let myName: any;
myName = 1
// 这明显是一个bug
myName()
而 unknown 和 any 代表的含义完全是不一样的,虽然 unknown 可以和 any 一样代表任意类型的值,但是这并不代表它可以绕过 TS 的类型检查。
let myName: unknown;
myName = 1
// ts error: unknown 无法被调用,这被认为是不安全的
myName()
// 使用typeof保护myName类型为function
if (typeof myName === 'function') {
// 此时myName的类型从unknown变为function
// 可以正常调用
myName()
}
通俗来说 unknown 就代表一些并不会绕过类型检查但又暂时无法确定值的类型,我们在一些无法确定函数参数(返回值)类型中 unknown 使用的场景非常多。比如:
// 在不确定函数参数的类型时
// 将函数的参数声明为unknown类型而非any
// TS同样会对于unknown进行类型检测,而any就不会
function resultValueBySome(val:unknown) {
if (typeof val === 'string') {
// 此时 val 是string类型
// do someThing
} else if (typeof val === 'number') {
// 此时 val 是number类型
// do someThing
}
// ...
}
强调:
unknown类型可以接收任意类型的值,但并不支持将unknown赋值给其他类型。
any类型同样支持接收任意类型的值,同时赋值给其他任意类型(除了never)
let a!: any;
let b!: unknown;
// 任何类型值都可以赋给any、unknown
a = 1;
b = 1;
// callback函数接受一个类型为number的参数
function callback(val: number): void {}
// 调用callback传入aaa(any)类型 correct
callback(a);
// 调用callback传入b(unknown)类型给 val(number)类型 error
// ts Error: 类型“unknown”的参数不能赋给类型“number”的参数
callback(b);