开篇
金庸笔下的武侠功法秘籍不胜枚举,主角凭借着至高无上的功法自在行走于江湖。对于我们前端工程师来说如何轻松驾驭日常开发呢?势必不可缺少 TS 技能。
TS 作为 JS 的严格静态类型语言,意味着在编码阶段,可以及早发现可能带入生产环境的隐患。
所以,对于前端开发,这本 TS 秘籍必不可少。接下来我们一起来看看 TS 的一些高级用法。
一、基础类型
如果基础类型你已掌握,可以 跳过 去阅读高级类型。
1、boolean、number、string
- boolean
定义 JS 变量为布尔类型,只允许使用 true/false 值。
let flag: boolean = true;
- number
定义 JS 变量为数字类型。
let count: number = 1;
- string
定义 JS 变量为字符串类型。
let str: string = 'hello ts.';
2、null 和 undefined
null 和 undefined 自身就是两种类型。
在 tsconfig.json 配置文件中没有开启 strict: true 严格模式时,null 和undefined 是所有类型的子类型,比如你可以把 null 和 undefined 赋值给 number 类型的变量。
let count: number = undefined; // ok
一旦开启了 strict 严格模式,null 和 undefined 只能赋值给 void 和它们各自。当一个变量可能存在 null 或 undefined 时,需要使用「联合类型」来定义(联合类型下文会介绍)。
let count: number | undefined = undefined; // ok
3、any、void、never
- any
表示任意类型,即不参与 TS 类型检查。当我们不希望类型检查器对值进行检查,而是直接让值通过编译阶段的检查,那么我们可以使用 any 类型来标记这些变量。
let notSure: any = 4;
notSure = "maybe a string instead"; // ok
一般对于自己编写的代码,推荐是声明好具体类型,不建议使用 any。如果是使用未定义类型的第三方库,有可能需要定义为 any 类型。
- void
它表示没有任何类型。当一个函数没有返回值时,你通常会见到其返回值类型是 void:
function warnUser(): void {
console.log("This is my warning message");
}
-
never
never 表示不存在、不可能的值的类型,一般不会用它来做类型声明。哪些场景会使用 never 呢?never 有一个特性:在参与「联合类型」定义时会被忽略。如下定义,只会得到
'name' | 'age'的联合类型:
type B = 'name' | never | 'age'; // type B = "name" | "age"
这一特性会在编写「泛型工具」时会被广泛应用,如实现 Exclude(排除) 泛型工具。
never 类型是任何类型的子类型,也可以赋值给任何类型;但任何类型包括 any 都不可赋值给 never(除了 never 本身)。
4、object 与 interface
-
object
object 表示非原始类型,也就是除 number,string,boolean,symbol,null 或 undefined 之外的类型。如果不关注对象内的具体成员,可以使用
object定义类型,比如像 Object.create 这样的 API。
declare function create(o: object | null): void;
create({ prop: 0 }); // OK
create(null); // OK
如果需要明确对象中有哪些成员,推荐使用 interface 接口来定义。
- interface
1)interface 最常用的用途是:定义对象的成员类型。如下定义一个 User 用户信息:
interface User {
name: string;
age: number;
job: {
type: string;
name: string;
}
}
2)和类一样,接口也可以相互继承(extends),能够从一个接口里复制成员到另一个接口里,可以更灵活地将接口分割到可重用的模块里。
interface MemberUser extends User {
memberType: string;
}
3)此外,interface 还可以用来声明函数,比如 React.FunctionComponent 声明函数签名 和 函数自身属性:
interface FunctionComponent<P = {}> {
// 定义函数签名
(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
// 定义函数自身属性
propTypes?: WeakValidationMap<P>;
contextTypes?: ValidationMap<any>;
defaultProps?: Partial<P>;
displayName?: string;
}
5、array
定义 JS 变量为数组类型有两种方式:
// 第一种,可以在元素类型后面接上 []
let list: number[] = [1, 2, 3];
// 第二种,使用数组泛型,Array<元素类型>
let list: Array<number> = [1, 2, 3];
上面我们定义的数组类型,它要求数组元素必须为同一类类型,即 number。如果元素存在多种类型,可以使用「联合类型」定义。(联合类型 下文会介绍)
let list: (number | string)[] = [1, '2', 3];
// or
let list: Array<(number | string)> = [1, '2', 3];
如果已知数组元素的个数,要为每个元素单独设定各自的类型,可以使用「元组 Tuple」定义。
6、Tuple 元组
Tuple 元组类型可以表示一个已知元素数量和类型的数组,其中各元素的类型可以不必相同。
- 声明元组类型:
使用type(类型别名)关键字声明。
type Tuple = ['left', 'top', 'right', 'bottom', 'center'];
- 获取元组中某个元素的类型:
元组 类似 JS 数组,拥有「索引取值」操作。
type Top = Tuple[1]; // type Top = "top"
上面是在已知 索引 的前提下获取元素类型。如果是在编程中获取 最后一项 元素类型,可以借助 infer 关键字来实现,可参考下文 「infer 关键字 - 提取类型」 一节。
- 获取元组长度:
元组 同样具有 JS 数组'length'属性。(注意是字符串 length)
type Length = Tuple['length']; // type Length = 5
- 解构元组:
元组 同样具有 JS 数组「解构元素」的特性,比如通过...解构将两个元组合并为一个元组。(解构同样适用于数组类型)
type Tuple1 = ["left", "right"];
type Tuple2 = ["top", "bottom"];
type Tuple = [...Tuple1, ...Tuple2];
// type Tuple = ["left", "right", "top", "bottom"]
- 将元组中所有元素类型构造成「联合类型」:
通过[number]索引签名 的方式访问元组,将得到一个由所有元素类型构造成的「联合类型」。([number]同样适用于数组类型)
type UnionType = Tuple[number];
// type UnionType = "left" | "top" | "right" | "bottom" | "center"
- 根据 JS 数组变量 推导出 元组类型:
先利用as const将数组声明为只读数组,然后利用typeof关键字将 只读数组变量 转成 元组类型,这样元组中的每个元素,对应数组中元素的字面量形式。(typeof的用法下文介绍)
const Placements = ['left', 'top', 'right', 'bottom', 'center'] as const;
type Tuple = typeof Placements;
// type Tuple = readonly ["left", "top", "right", "bottom", "center"]
7、enum 枚举
enum 枚举类型可以看做是代替 JS 常量定义的一种写法,使用枚举类型可以为一组数值赋予友好的名字。
比如我们有一个文件上传任务,任务的状态有以下几类:wait(等待上传)、inProgress(上传中)complete(上传完成),在 TS 环境下,通常会使用枚举来代替常量定义它们。
enum EStatus {
Wait,
InProgress,
Complete,
}
const uploadStatus: EStatus = EStatus.Wait;
默认情况下,从 0 开始为元素编号。你也可以手动的指定成员的数值,例如,我们将上面的例子改成从 1 开始编号:
enum EStatus {
Wait = 1,
InProgress,
Complete,
}
或者,全部都采用手动赋值:
enum EStatus {
Wait = 1,
InProgress = 2,
Complete = 3,
}
此外,枚举类型提供的一个便利是:你可以由枚举的值得到它的名字,即「反向查找」。例如,我们知道数值为2,但是不确定它映射到 EStatus 里的哪个名字,我们可以查找相应的名字:
enum EStatus {
Wait = 1,
InProgress,
Complete,
}
const statusName: string = EStatus[2]; // 得到 'InProgress'
你可能会好奇 enum 枚举为何能「反向」通过值来查找属性,我们可以从下面的 TS 代码编译结果中得到答案。
var EStatus;
(function (EStatus) {
EStatus[EStatus["Wait"] = 1] = "Wait";
EStatus[EStatus["InProgress"] = 2] = "InProgress";
EStatus[EStatus["Complete"] = 3] = "Complete";
})(EStatus || (EStatus = {}));
8、unknown
unknown 类型是一种特殊的类型,它用于表示任意类型。但与 any 类型不同,unknown 类型更加注重类型安全。
当你声明一个变量为 unknown 类型时,TypeScript 编译器将不允许你直接对其进行操作,除非你通过「类型断言」或「类型守卫(类型保护)」来验证其具体类型。
function callback(data: unknown) {
// 使用类型守卫
if (typeof data === "string") {
return data.toUpperCase(); // data 的类型是 string
}
// 使用类型断言
return data as number;
}
二、必备知识
1、类型断言
作为开发者,在某些情况下,你会比 TypeScript 更了解某个值的详细信息,通过类型断言这种方式可以告诉编译器:“相信我,我知道自己在干什么”,让 TS 跳过这一处的类型检查。
类型断言有两种形式。 其一是“尖括号”语法:
let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;
另一个为 as 语法:
let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;
2、属性修饰符
? 可选属性,在可选属性名字定义的后面加一个?符号;readonly 只读属性,一些对象属性只能在对象刚刚创建的时候设置其值。你可以在属性名前用readonly来指定只读属性;
interface User {
name: string;
readonly id: string;
age?: number;
}
- 取消属性修饰符,在使用「映射类型」时,可以通过- 属性修饰符来取消设置的修饰符。
// 取消可选属性
type MyRequired<T> = {
[P in keyof T]-?: T[P];
}
// 取消只读属性
type CancelReadonly<T> = {
-readonly[P in keyof T]: T[P];
}
3、as const
as const 是一种「变量的断言方式」,它可以将一个 JS 变量的类型(如 string)推断成一个「常量类型」(如 字符串字面量类型 'name')。
- 作用于
string类型:
比如有一个 str 变量,在没有显式声明类型时,它会被自动推导成string类型,可以被修改为任何字符串值。
let str = 'name'; // let str: string
str = 'age'; // success
当使用 as const 断言后,str 的类型只能是 name 字符字面量类型,赋值其他字符串都会报错。
let str = 'name' as const; // let str: "name"
str = 'age'; // error. Type '"age"' is not assignable to type '"name"'
- 作用于
number类型:
比如有一个 num 变量,在没有显式声明类型时,它会被自动推导成number类型,可以被修改为任何数字值。
let num = 20; // let num: number
num = 26; // success
当使用 as const 断言后,num 的类型只能是 20 数字字面量类型,赋值其他数字都会报错。
let num = 20 as const; // let num: 20
num = 26; // error. Type '26' is not assignable to type '20'.
- 作用于
数组类型:
比如有一个 arr 变量,在没有显式声明类型时,它会被自动推导成数组中包含的类型。
let arr = [1, 2, 3, '4']; // let arr: (string | number)[]
当使用 as const 断言后,数组中每一个元素都视为一种字面量类型,且为元素设置为 readonly 只读不可修改。
let arr = [1, 2, 3, '4'] as const; // let arr: readonly [1, 2, 3, "4"]
arr[0] = 1; // error. Cannot assign to '0' because it is a read-only property.
4、泛型变量
TS 泛型提供了类型定义的复用及灵活性,一个简单的泛型定义如下:
function identity<T>(value: T): T {
return value;
}
identity<string>('name');
上面 T 就是一个泛型变量,它是定义在函数中的类型占位符:将用户指定的实际类型,链式传递给参数类型和返回值类型。
泛型变量可以是任意字母,常见的泛型变量定义有:
- T(Type)代表任意事物的类型;
- K(Key)表示对象中键的类型;
- V(Value)表示对象中值的类型;
- E(Element)表示元素类型;
5、type 与 interface 区别
- 类型别名
type: 用于给一个或一组类型(包括基本类型、联合类型、接口类型)起一个新名字。我们常见的泛型工具函数(如:Pick)都是采用 type 类型别名 定义。 - 接口类型
interface: 只能用于定义对象、或者函数类型,定义它们自身的属性和方法。
- 相同点:
- 两者都可以用来描述对象和函数;
- 两者都支持扩展,type 使用
&扩展组合多个类型,interface 通过extends方式扩展;
- 不同点:
- 类型别名更灵活,基本类型、联合类型、元素类型都可以,而 interface 不行;
- 同名的两个定义类型,interface 会自动合并类型内容,而类型别名不会;
举例:当一个参数存在多种类型时,type 比 interface 更适合使用。比如一个创建文件接口,既可以根据 文件 url 线上地址创建,也可以根据 文件 FormData 来创建:
type TCreateFileParams = {
url: string;
} | FormData
6、any 和 unknown 对比
any类型,使用它来声明类型,与没有引入 TS 的体验没有差别;你可以将任意类型值赋给 any 类型的变量,并对该变量进行任何操作。unknown类型,你可以把任意类型值赋给 unknown 类型的变量,但在使用这个变量时必须进行类型保护或类型断言。
为了保证类型安全,我们应当尽可能使用 unknown 类型。
function invokeCallback(callback: unknown) {
if (typeof callback === 'function') {
callback();
}
}
7、复合写法的模块导出
如果在一个模块中,先输入后输出同一个模块,import 语句可以和 export 语句写在一起,比如 antd 将所有组件整合在 index.ts 中进行导出。
- 如果是导出类型,可以在 export 后面增加 type 来标识要导出 TS 类型声明;
- 如果是导出组件、枚举类型以及其他非类型的 export 模块,不需要在 export 后面增加 type。
// 导入导出类型
export type { FormInstance, FormProps, FormItemProps } from './form';
// 导出模块、枚举变量
export { default as Form, EFormType } from './form';
8、类型安全(协变与逆变)
在 TS 中,类型安全主要是为了保证类型成员始终可用。
1、协变
下面我们有一个 Person 和一个 Sportsman,其中 Sportsman 继承了 Person,我们认为 Person 是父类,Sportsman 是子类。
// 一个人的信息
interface Person {
name: string;
age: number;
}
// 一个运动员的信息
interface Sportsman extends Person {
motion: "basketball" | "football"; // 运动员类型:篮球 或 足球
}
如果我们创建一个 person 并把它赋值给 Sportsman 类型会提示报错:缺少 motion 类型。这就是 TS 做出的限制,保证 Sportsman 下的每一个类型成员都可用,如果缺少类型成员会报错。
let person = {
name: "一个人",
age: 20,
}
// error: Property 'motion' is missing in type '{ name: string; age: number; }' but required in type 'Sportsman'
let basketballSportsman: Sportsman = person;
但如果反过来,我们创建一个 sportsman 则可以赋值给 Person,却不会报错,这是因为 sportsman 中包含了 Person 的所有成员,满足 Person 所有成员始终可用的条件。
let basketballSportsman = {
name: "篮球运动员",
age: 20,
motion: "basketball",
}
let person: Person = basketballSportsman;
这就是 TS 协变(Covariance) 类型兼容:当类型 A(Sportsman) 是类型 B(Person) 的子类型时,如果在某种上下文中,类型 A 可以赋值给类型 B,并且这种替换是安全的,那么这个上下文就是协变的。
总结:子类变量 可以赋值给 父类变量。
一般「变量赋值」和「函数返回值」,都遵循「协变」特性。
而「函数的参数」,通常都遵循「逆变」特性。
2、逆变
下面我们有一个 transform 和一个 subTransform 函数,其中 transform 接收参数类型 Person,subTransform 接收参数类型 Sportsman,我们可以间接地认为 transform 是父类,subTransform 是子类。
type Transform = (x: Person) => any;
type SubTransform = (x: Sportsman) => any;
如果我们定义一个 SubTransform 类型的函数,把它赋值给 Transform 类型的变量时,会提示报错:
const subTransform: SubTransform = x => x;
// 当把 subTransform 赋值给 transform 时会报错:Type 'SubTransform' is not assignable to type 'Transform'.
const transform: Transform = subTransform;
这是因为:当实际调用 transform 方法时会要求传入 Person,但 transform 函数体的实现是 subTransform,在函数体内用到 Person 上不存在的成员如:motion,这将存在类型安全问题,不满足 Sportsman 成员始终可用条件。。
而如果定义一个 Transform 类型的函数,把它赋值给 SubTransform 类型的变量时,却不会报错。这是因为在执行 Transform 类型函数时,实际传入的是 Sportsman,满足 Person 所有成员始终可用的条件。
const transform: Transform = x => x;
const subTransform: SubTransform = transform;
你会发现,这一规则和上面「协变」正好相反。
这就是 TS 逆变(Contravariance) 类型兼容:当类型 A(SubTransform) 是类型 B(Transform) 的子类型时,如果在某种上下文中,类型 B 可以赋值给类型 A,并且这种替换是安全的,那么这个上下文就是逆变的。
总结:父类变量 可以赋值给 子类变量。
9、特殊符号
合理灵活地运用「TS 特殊符号」可以简化我们的程序逻辑和代码量,一定程度上提升代码的易读性。
1、?.(可选链)
通常用作访问对象的可选属性。在遇到 null 或 undefined 时可以立即停止表达式的运行。
比如我们有一个 Info 接口和一个 info 对象,其中 desc 又是一个对象,但它是个可选属性:
interface Info {
title: string;
desc?: {
'zh-cn': string;
'en': string;
};
}
const info: Info = {
title: '信息',
}
如果我们直接访问 desc 下的属性会报错:
console.log(info.desc.en); // error TS2532: Object is possibly 'undefined'.
这时我们可以使用 可选链 ?. 操作符来解决报错,它会在遇到 undefined 时停止表达式的执行,并返回 undefined:
console.log(info.desc?.en); // undefined
再比如有一组 list,你需要查找其中的某一项,你也可以使用可选链操作符来减少逻辑判断:
const list = [
{ id: 1, desc: 'first' },
{ id: 2, desc: 'second' },
{ id: 3, desc: 'thrid' },
];
const desc = list.find(v => v.id === 3)?.desc || ''; // const desc: string
2、!.(非空类型断言)
它的含义是:确保某个可选值是有值的。它不会像 ?. 那样遇到空值返回 undefined,而是类似 类型断言:告诉 TS 尽管这个属性是个可选值,但在这里我认为它是一定有值的。
function func(value?: string) {
value!.length
}
3、??(空值合并运算符)
这个容易理解:当左侧操作数为 null 或 undefined 时,其返回右侧的操作值,否则返回左侧的操作值。
let myName: string | undefined = undefined;
myName ?? 'cegz'
10、函数类型重载
当定义一个函数,它的参数和返回值拥有多种可能性时,通过 类型重载 来定义复数类型,逐个匹配第一个满足条件的类型。
如:React.createElement 支持接收 HTML 元素、FunctionComponent、ClassComponent 作为参数,并返回不同的类型,函数重载可以解决这类问题,提高代码可读性。
function createElement<P extends HTMLAttributes<T>, T extends HTMLElement>(
type: keyof ReactHTML,
props?: ClassAttributes<T> & P | null,
...children: ReactNode[]
): DetailedReactHTMLElement<P, T>;
function createElement<P extends {}>(
type: FunctionComponent<P>,
props?: Attributes & P | null,
...children: ReactNode[]
): FunctionComponentElement<P>;
function createElement<P extends {}>(
type: ClassType<P, ClassicComponent<P, ComponentState>, ClassicComponentClass<P>>,
props?: ClassAttributes<ClassicComponent<P, ComponentState>> & P | null,
...children: ReactNode[]
): CElement<P, ClassicComponent<P, ComponentState>>;
三、高级类型
1、联合类型(Union Type)
- 概念:
联合类型是由两种或多种其他类型组合而成的类型,表示该值的类型是这些类型之一。TS 中使用
|操作符来创建联合类型。如将 number 和 string 组合成新的类型:
type NumOrStr = number | string;
const str: NumOrStr = 'abc';
const num: NumOrStr = 123;
- 场景: 一个函数的参数变量支持传入单个数据和多条数据,我们可以通过联合类型来支持这样场景:
function greet(person: string | string[]): string | string[] {
if (typeof person === 'string') {
return `Hello ${person}`;
} else if (Array.isArray(person)) {
return person.map(name => `Hello ${name}`);
}
}
由于参数变量定义为联合类型,我们只能访问 string 和 array 的共有属性如:indexOf 等。
这里我们使用了 typeof 操作符对联合类型进行缩窄变量的类型范围,使得变量类型只能为一种情况。
- 扩展:读取多个成员类型(在对象类型下)
联合类型还可用于一次访问对象下的多个成员类型,并将成员的类型构成一个新的联合类型。注意:如果成员的类型值是 never,则不会包含在返回结果中。
type Info = {
message: string;
setMessage: Function;
id: never
}["message" | "setMessage"];
// type Info = string | Function
2、交叉类型(Intersection Types)
通过交叉运算符 & 对多个类型进行交叉合并,新类型拥有所有类型的成员。
比如,两个 interface 对象类型的到的交叉类型:
interface Point {
x: number;
y: number;
c: number;
}
interface Named {
name: string;
c: string;
}
Point & Named -->
{
x: number;
y: number;
name: string;
c: never;
}
由于 Point 和 Named 都有一个公共类型成员 c,但由于 c 在二者中类型分别是 number 和 string,交叉后类型是 never。
3、映射类型(in)
顾名思义,「映射」出一个新的类型。TS 提供了一种将 string | number | symbol 或 字面量的 联合类型 作为 key 映射为一个新的对象结构类型。
type Keys = 'name' | 'sex';
type User = {
[K in Keys]: string;
}
// 等同于
type User = {
name: string;
sex: string;
}
若想添加额外的成员,需结合 交叉类型 一起使用:
type Keys = 'name' | 'sex';
type User = {
// age: number; // 不要这样扩展新成员
[K in Keys]: string;
} & { age: number }; // 推荐这样使用
映射类型也可以结合 属性修饰符 一起使用,如实现 Partial、Readonly、Required 工具函数。
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
type MyReadonly<T> = {
readonly [P in keyof T]: T[P];
};
// 取消可选属性
type MyRequired<T> = {
[P in keyof T]-?: T[P];
}
// 取消只读属性
type CancelReadonly<T> = {
-readonly[P in keyof T]: T[P];
}
映射类型具有分配性。在泛型中,泛型变量 T 若是一个对象联合类型,对于每个联合成员类型,TypeScript 会独立应用映射类型。如下示例:ToString<Foo | Bar>,实际会被处理成:type Res = ToString<Foo> | ToString<Bar>。
type Foo = {
name: string
age: number
}
type Bar = {
name: string
gender: number
}
// 将类型 T 中的成员,全部映射成 string
type ToString<T> = {
[K in keyof T]: string;
}
type Res = ToString<Foo | Bar>; // type Res = ToString<Foo> | ToString<Bar>
4、字面量类型
在 TypeScript 中,「字面量类型」是指直接指定一个或多个具体的值作为类型的一部分。这意味着变量的值只能是指定的字面量之一。字面量类型可以用于增加代码的安全性和可读性,确保变量的值不会超出预期的范围。
boolean、number、string 的值都可以作为字面量定义,这里以最常用的 string 字面量来作为示例。
let color: "red" | "blue" | "green";
color = "red"; // 正确
color = "yellow"; // 错误,因为 "yellow" 不是 "red", "blue", 或 "green"
5、typeof 操作符
typeof 操作符作用有两个:一是类型保护(类型守卫),二是推导变量的类型。
1、类型保护
在 unknown 类型一节我们知道,typeof 可以检查一个变量是否是特定的原始类型,从而起到类型保护的作用。
function callback(data: unknown) {
// 使用类型守卫
if (typeof data === "string") {
return data.toUpperCase(); // data 的类型是 string
}
}
2、推导变量的类型
typeof 在 TS 中另一个作用是:可以根据一个 JS 变量、对象或者函数,推导出其类型。
- 基础变量
当 typeof 检测一个基本类型值,会得到它的基本类型;当 typeof 的值是一个 const 常量时,得到的则是值的字面量。
let unknownString = 'cegz';
type S = typeof unknownString; // string
const unknownString = 'cegz';
type S = typeof unknownString; // 'cegz'
// 等同于:
let unknownString = 'cegz' as const;
- 对象
typeof 检测一个对象时,会得到一个对象类型,类型 interface:
const info = {
title: '信息',
num: 24
}
type Info = typeof info;
// 得到:
type Info = {
title: string;
num: number;
}
如果变量使用 const 断言,会构造成字面量类型,并且字面量属性都使用 readonly 修饰。
const info = {
title: '信息',
num: 24
} as const
type Info = typeof info;
// 得到:
type Info = {
readonly title: "信息";
readonly num: 24;
}
- 函数
typeof 用于函数时得到一个函数签名。
const Fn = (value: string) => value;
type F = typeof Fn;
// 得到:
type F = (value: string) => string;
- 数组
typeof 结合as const可根据一个 JS 数组推导出元组类型。具体可以查看上文「Tuple 元组」一节。
6、keyof 操作符
keyof 是「索引类型查询」操作符,可用于获取类型的所有键构成一个「联合类型」,通常会用它来构造联合类型。
- keyof 作用于 number、string、boolean
keyof作用于一个「基本类型」时,得到一个由 基本类型 原型上所有方法名称构成的「联合类型」。
「基本类型」字面量形式,得到的结果同「基本类型」。如 字符串字面量类型 Example3 和 Example2 类型的结果一致。
type Example1 = keyof number;
// type Example1 = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
type Example2 = keyof string;
// type Example2 = number | "toString" | "valueOf" | typeof Symbol.iterator | "charAt" | "charCodeAt" | "concat" | "indexOf" ...more
type Example3 = keyof 'name';
// type Example3 = number | "toString" | "valueOf" | typeof Symbol.iterator | "charAt" | "charCodeAt" | "concat" | "indexOf" ...more
type Example4 = keyof boolean;
// type Example4 = "valueOf"
- keyof 作用于 undefined、null、function
keyof作用于一个「空值」或「函数」时,得到 never 类型。
type Example5 = keyof null; // never
type Example6 = keyof undefined; // never
type Example7 = keyof (() => void); // never
- keyof 作用于 对象
keyof作用于一个「对象类型」时,则得到一个由对象所有 key 组成的「联合类型」。(最常用的一种情况)
interface Point {
x: number;
y: number;
}
type P = keyof Point; // 'x' | 'y'
现在,我们定义一个 getProperty 方法用来返回对象的属性,并约束第二参数只能传入对象上存在的属性,实现如下:
function getProperty<T extends object, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
const user = {
id: '1',
name: 'xiaoming'
}
const userId = getProperty(user, 'id'); // 1
const userName = getProperty(user, 'name'); // xiaoming
const userAge = getProperty(user, 'age'); // error. Argument of type '"age"' is not assignable to parameter of type '"id" | "name"'.
4、keyof 作用于 对象联合类型
在集合对象中使用联合类型 | ,会把共有属性组成联合类型。
type Foo = {
name: string
age: string
}
type Bar = {
name: string
age: string
gender: number
}
type result = keyof (Foo | Bar) // "name" | "age"
5、keyof 作用于 对象交集类型
在集合对象中使用交集类型 &,会把所有属性组成联合类型。
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
type ColorfulCircle = keyof (Colorful & Circle) // "color" | "radius"
6、keyof 联合枚举类型
keyof 和 typeof 一起使用可以用于联合枚举类型的 key:
enum EStatus {
success,
warning,
error,
}
type TStatusKey = keyof typeof EStatus;
// 得到:
type TStatusKey = "success" | "warning" | "error";
7、in 操作符 - 类型保护
类型保护的方式我们已经接触到:as 和 typeof。现在再介绍一种保护类型:in 操作符。
const changeDesc = (data: { desc: string } | { guide_desc: string }) => {
// (data as { desc: string }).desc; // 一种是断言
if ('desc' in data) { // 一种是使用 in 操作符
data.desc
} else {
data.guide_desc
}
}
8、extends 关键字(三种用法)
extends 在 TS 中有三种用法:接口继承、条件判断 以及 泛型约束。
1、接口继承
-
场景:
我们在编写代码时通常会将公共部分进行抽离,在需要用到的地方引入复用。代码是如此,TS 类型同样可以这样。 -
解释:
对于可重用的类型,我们会选择将其分割到一个独立的接口中,其他模块则通过继承来拥有这部分类型。 -
使用:
教师和学生都用姓名、性别和年龄等公共属性,除此之外,教师拥有「所属办公室」属性,学生拥有「所属班级」属性,我们可以这样定义类型:
interface IBaseInfo {
name: string;
sex: string;
age: number;
}
interface ITeacher extends IBaseInfo {
office: string; // 办公室
}
interface IStudent extends IBaseInfo {
classroom: string; // 教室
}
2、条件判断
-
解释:
extends当用作条件判断时,它的语句和 JS 三目运算符 很像,在满足条件时返回?后面的类型,否则返回:后面的类型。 -
基本类型判断
type Str = string;
type Num = number;
type Val1 = Str extends string ? true : false; // type Val1 = true
type Val2 = Num extends string ? true : false; // type Val2 = false
type Val3 = 'name' extends string ? true : false; // type Val3 = true
type Val4 = 100 extends number ? true : false; // type Val4 = true
extends 判断前后类型是否同属于一个「基本类型」。
联合类型判断:
type Val1 = "description" extends "description" | "title" ? true : false; // true
type Val2 = "description" | "title" extends "description" ? true : false; // false
后者 包含 前者,则 extends 条件成立。
object类型判断:
其中 对象、数组、函数、null、undefined 都满足 object 条件。
type Val1 = { name: string } extends object ? true : false; // true
type Val2 = string[] extends object ? true : false; // true
type Val3 = (() => void) extends object ? true : false; // true
type Val4 = null extends object ? true : false; // true
type Val5 = undefined extends object ? true : false; // true
type Val6 = number extends object ? true : false; // false
type Val7 = string extends object ? true : false; // false
type Val8 = boolean extends object ? true : false; // false
- 对象类型之间的判断:
interface A1 {
name: string;
}
interface A2 {
name: string;
age: number;
}
type Value = A2 extends A1 ? string : number; // string
const value: Value = 'is string';
extends 判断 对象类型 真假的逻辑:extends 前面的类型能够赋值给 extends 后面的类型,则表达式为真,否则为假。
由于 A2 的接口一定可以满足 A1,所以条件为真,Value 类型为 string。
条件判断 - 分布式条件类型(重点)
Distributive Conditional Types 分布式条件类型也叫「分配条件类型」,是 TypeScript 中的一种特性。
在泛型中,当你对「联合类型」使用「条件类型」时,TypeScript 会将条件类型自动应用到联合类型的每个成员上,并产生一个新的联合类型。
注意,这里有一个先决条件:联合类型必须来自于「泛型变量」。
也就是说,如果泛型变量 T 是一个联合类型 A | B | C,并且你对 T 应用了条件类型 T extends U ? X : Y,TypeScript 实际上会将这个条件类型展开为:
(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)
如下,将联合类型 'x' | 'y' 参与 extends 条件判断,实际上是将 x 和 y 分别与条件 x 比较,得到的结果可能是 a 也可能是 b,于是得到联合类型:a | b。
type P<T> = T extends 'x' ? 'a' : 'b';
type Value = P<'x' | 'y'>; // 'a' | 'b'
分配条件类型应用场景:
实现 Exclude(排除) 泛型工具类型,从一个「联合类型」中排除某些类型。
type Exclude<T, U> = T extends U ? never : T;
type Values = Exclude<'x' | 'y' | 'z', 'x'>; // 'y' | 'z'
避免分布式行为:
某些场景下我们并不需要使用分布式特性,可以用 方括号 [] 将 extends 关键字的每一侧括起来。
使用分布式行为:
type Res<T, U> = T extends U ? 1 : 2;
type Values = Res<'x' | 'y', 'x'>; // type Values = 1 | 2
不使用分布式行为:
type Res<T, U> = [T] extends [U] ? 1 : 2;
type Values = Res<'x' | 'y', 'x'>; // type Values = 2
3、泛型约束
泛型指的是在定义函数/接口/类型时,不预先指定具体的类型,而是在使用的时候再指定具体类型的一种特性。
但有时候我们希望泛型变量可以精确到某一类类型,extends 可以用来约束泛型变量的类型范围。下面这个例子要求参数必须满足 Person 成员属性:
interface Person {
name: string;
age: number;
}
const student = <T extends Person>(data: T): T => {
return data;
}
student({ name: 'cegz' }); // error. Property 'age' is missing in type '{ name: string; }' but required in type 'Person'.ts(2345)
student({ name: 'cegz', age: 24 }); // success
student({ name: 'cegz', age: 24, sex: 'male' }); // success
比如 React.lazy 它会限制必须返回 Promise<T>,其中的 T 约束必须是一个 React 组件。
function lazy<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>
): LazyExoticComponent<T>;
9、infer 关键字 - 提取类型
infer 意为「推断」,通过模式匹配的方式,将需要提取的类型放入 infer 声明的一个局部变量 中,并结合 extends 条件判断将其返回。
infer关键字只能在extends条件判断 语句中使用。且推断出的局部变量,只能在extends条件判断为真的语句中使用,条件为假的语句中不能使用。
1、提取元组类型中最后一个元素类型:
元组支持使用
...操作符将前面的1到多个元素,整合到一个新的元组中。
type Last<Arr extends unknown[]> =
Arr extends [...infer rest,infer Ele]
? Ele
: never;
type Value = Last<[1, 2, 3]>; // 3
如果我们想明确最后一个元素类型为 number,可以使用 infer extends 语法来约束推导的类型:
type Last<Arr extends unknown[]> =
Arr extends [...infer rest,infer Ele extends number]
? Ele
: never;
2、实现 ReturnType 提取函数的返回值作为类型:
type GetReturnType<Func extends Function> =
Func extends (...args: any[]) => infer ReturnType
? ReturnType
: never;
type Result = GetReturnType<() => 'return string.'> // "return string."
3、枚举出对象联合类型的 key
type Name = { name: string };
type Age = { age: number };
type Union = Name | Age;
type UnionKey<P> = P extends infer T ? keyof T : never;
type T = UnionKey<Union>; // 'name' | 'age'
如果直接使用
keyof Union会得到never,因为 keyof 作用于 Name 和 Age 联合类型时,它们没有共有属性。而泛型变量具有「分布式条件类型」,会让 Name 和 Age 分别应用 keyof,得到一个新的联合类型。
4、实现 FirstArg 取出函数第一个参数类型
type fa = FirstArg<(name: string, age: number) => void>; // string
// 思路公式:T 是一个函数 ? 取出函数的第一个参数类型 : T
type FirstArg<T> = T extends (first: infer F, ...args: any[]) => any ? F : T;
5、实现 ArrayType 提取数组中元素的类型
type ItemType1 = ArrayType<[string, number]>; // string | number
type ItemType2 = ArrayType<string[]>; // string
type ArrayType<T> = T extends (infer I)[] ? I : T;
10、`` 模板字面量类型
在 TS 4.1 中引入了模板字面量类型,类似于 ES6 模板字符串,在 TS 场景下字面量类型中可以使用 「类型变量」 ,类型变量的类型为:string、number、boolean、bigint。
1、组合类型变量
如将两个「类型变量」组合为一个新的「字符串字面量」类型:
type Prefix = 'prefix'
type Name = 'mui'
type Class = `${Prefix}-${Name}`; // type Class = "prefix-mui"
2、结合 infer 操作字符串类型
如实现 Capitalize<T> 泛型工具,将字符串首字母转成大写:
type MyCapitalize<S extends string> = S extends `${infer First}${infer Other}`
? `${Uppercase<First>}${Other}`
: S;
Uppercase是一个内置泛型工具,用于将传入的字母转成大写。
3、模板具有分布式特性
在模板语法中使用「联合类型」,最终结果也会是一个「联合类型」,这与 extends 分布式条件类型 很相似。
type Type = `btn__${'success' | 'error'}`;
// type Res = "btn__success" | "btn__error"
借助这一特性,在一定场景下可以简化我们的类型定义,减少重复代码。
假设我们定义 CSS padding 和 margin 属性:
type CssPadding =
| "padding-left"
| "padding-right"
| "padding-top"
| "padding-bottom";
type CssMargin =
| "margin-left"
| "margin-right"
| "margin-top"
| "margin-bottom";
其中 left、right、top、bottom 为重复定义内容。下面我们使用「模板字面量类型」来简化重复内容:
type Direction = "left" | "right" | "top" | "bottom";
// type CssPadding = "padding-left" | "padding-right" | "padding-top" | "padding-bottom"
type CssPadding = `padding-${Direction}`;
// type CssMargin = "margin-left" | "margin-right" | "margin-top" | "margin-bottom"
type CssMargin = `margin-${Direction}`;
四、项目配置 tsconfig.json
如果一个目录下存在一个 tsconfig.json 文件,这意味着此目录是 TypeScript 项目的根目录。在 tsconfig.json 文件可指定编译这个项目的根文件和编译选项。
如果没有,可以执行以下命令初始化创建 tsconfig.json:
npx tsc --init
1、编译范围
编译器默认包含当前目录和子目录下所有 的TypeScript 文件(.ts, .d.ts 和 .tsx),如果配置选项 allowJs 被设置成 true,JS文件(.js和.jsx)也被包含进来。
"files" 和 "include" 字段都是用来指定哪些文件在编译范围内。
"files"指定一个包含相对或绝对文件路径的列表;"include"指定一个文件glob匹配模式列表,对于前端工程来说,一般指定编译范围是src目录;
{
"files": [
"core.ts",
"sys.ts",
"types.ts",
],
"include": [
"src/**/*"
],
}
"exclude" 字段用来指定哪些文件不在编译范围内,通常会排除 node_modules 目录。
{
"exclude": [
"node_modules",
]
}
2、编译选项
TS 的编译选项是在 compilerOptions 字段下定义。
以 React 项目为例,常用的编译选项有:
"lib": ["DOM", "DOM.Iterable", "ESNext"], 指定项目需要包含的 TypeScript 内置类型定义库。它决定了您的项目中可以使用哪些内置对象、函数和类型定义。(对于浏览器环境,需要提供DOM类型)"strict": true, 表示开启严格模式,会启用所有严格类型检查选项。如选项strictNullChecks表示不能将 null 和 undefined 直接赋值给其他类型,除非显式声明两者;"target": "ES5", 指定 TypeScript 编译器将 TypeScript 代码转换成哪种版本(ECMAScript 目标版本)的 JavaScript 代码。默认是 ES3;"module": "ES6", 指定生成哪种模块系统代码,ES6 表示生产 ESModule 模块化代码,CommonJS 表示生成 Node commonjs 模块化代码;"moduleResolution": "node", 如何处理模块的查找规则,一般使用node查找规则,从node_modules下查找第三方模块;"resolveJsonModule": true, 允许 TypeScript 编译器处理 以 .json 为扩展名的模块导入。"jsx": "preserve", 指定 TS 编译 JSX 的方式,有三种模式:preserve,react 和 react-native。preserve:在 preserve 模式下生成代码中会保留 JSX 语法以供后续的转换操作使用(比如:Babel),输出文件会带有 .jsx 扩展名;react:react 模式会生成 React.createElement,在使用前不需要再进行转换操作了,输出文件的扩展名为 .js;react-native:react-native 相当于 preserve,它也保留了所有的 JSX,但是输出文件的扩展名是 .js。
"noEmit": true, 不生成输出文件,即 TypeScript 编译器将仅执行类型检查,而不会产生任何输出文件;"skipLibCheck": true, 忽略所有的声明文件( *.d.ts)的类型检查。可以在编译过程中节省时间;"esModuleInterop": true, 允许使用 import 语句来导入 CommonJS 模块,而不需要显式地访问 .default 属性。这使得 CommonJS 模块的导入方式与 ES6 模块保持一致,提高了代码的一致性和可读性。"baseUrl": ".", 指定模块解析的基准目录,用于确定如何查找模块和文件。"paths": { "@/*": ["./src/*"] }, 路径别名,基于 baseUrl 的路径映射的列表。一般会配置@/*指向./src/*;"allowJs": false, 允许编译器处理 .js 文件。一般全新的 TS 不建议开启;"declaration": true, 输出生成的类型文件 '.d.ts' file。编写工具库一般会使用;"declarationDir": "types", 类型文件生成的目录。编写工具库一般会使用;"emitDeclarationOnly": true, 只输出 .d.ts 类型文件,不输出编译后的 JavaScript 文件。编写工具库一般会使用。"noImplicitThis": false, 关闭 this 为 any 类型时的 TS 产生错误。
一个简单的 tsconfig.json 配置文件:
{
"compilerOptions": {
"strict": true,
"target": "ES5",
"module": "ES6",
"moduleResolution": "node",
"jsx": "preserve",
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
},
...
}
3、extends 继承配置
tsconfig.json 文件可以利用 extends 属性从另一个配置文件里继承配置。
// configs/base.json:
{
"compilerOptions": {
"strict": true,
},
"include": [
"src/**/*"
],
}
// tsconfig.json
{
"extends": "./configs/base",
"compilerOptions": {
"jsx": "preserve",
},
}
对于 compilerOptions 字段来说,会把两个文件的选项进行合并,如果有相同的配置,会采用后者。
五、类型声明文件(d.ts)
1、认识 d.ts 文件
.d.ts 文件,也称为声明文件,主要作用是提供现有 JavaScript 代码的类型声明,使得 TypeScript 编译器能够理解这些代码的类型信息。
比如我们需要在工程下为 window 对象上扩展全局变量,第一步可以在 tsconfig.json 中引入类型声明文件:
// tsconfig.json
{
...
"include": [
"src/**/*",
"./@types/**/*" // 配置的.d.ts文件
],
}
第二步,declare 可用于声明全局变量,通过 declare global 扩展 Window 属性类型定义:
// @types/global.d.ts
// 在 .d.ts 配置文件中编写 global 属性时,要先进行 export
export {};
declare global {
// 全局变量声明
function i18n(text: string): string;
interface Window {
language: string;
}
}
此外,我们还可以使用 declare 声明 .css、.scss、.png 等文件模块,解决 TS 环境下引入模块时的无法识别问题:
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.module.scss' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.gif';
declare module '*.bmp';
2、namespace 命名空间
在 TypeScript 中,namespace 是一种组织代码的方式,用于创建一个作用域,在这个作用域内可以定义变量、函数、类等,且不会与全局作用域或其他作用域内的同名实体冲突。
命名空间是 TypeScript 中实现模块化的一种方式,但在 ECMAScript 2015(ES6)及以后的版本中,推荐使用单文件 import 和 export 来实现模块化。
使用:
namespace MyNamespace {
export let message: string = 'Hello, TypeScript!';
export function sayHello() {
console.log(message);
}
namespace NestedNamespace {
export let nestedMessage: string = 'This is a nested namespace';
}
}
// 使用命名空间的成员
MyNamespace.sayHello(); // 输出: Hello, TypeScript!
// 使用嵌套命名空间的成员
console.log(MyNamespace.NestedNamespace.nestedMessage); // 输出: This is a nested namespace
在这个示例中,MyNamespace 是一个包含导出成员的命名空间,它还包含了一个嵌套的命名空间 NestedNamespace。使用 export 关键字可以导出命名空间的成员,使得它们可以被外部访问。
3、为 JS 库定义类型声明文件
在 TS 没有流行之前,大多数库都是 JS 编写的,这导致在 TS 环境下使用这些库,会看到一连串「找不到类型」的错误提示。
“.d.ts” 文件用于解决这类问题:在不用重构 JS 代码的情况下,为 JavaScript API 编写对应的 TS 类型信息。
TS 默认会查找 node_modules/@types 下是否有相关库类型定义,比如 react 它的类型定义是:@types/react,它下面的 d.ts 文件存放了 React 类型定义。
但,有时候我们并不想为了给 JS 包提供类型,而去创建一个 npm 发包到线上;一种简单的方式是:可以通过在当前包下编写 .d.ts 类型定义,并在 package.json 中声明类型定义位置:
// package.json
{
...
"typings": "./types/index.d.ts"
}
六、常见泛型工具类型
1、Partial
Partial 可以将一个已有的 interface 接口属性成员,变为可选类型,简化我们定义新的接口来为成员加上可选修饰符的工作。
使用示例:
interface IUser {
name: string;
avatar: string;
contacts: {
name: string;
age: number;
}
}
type PartialUser = Partial<IUser>;
得到类型如下:
type PartialUser = {
name?: string | undefined;
avatar?: string | undefined;
contacts?: {
name: string;
age: number;
} | undefined;
}
从上我们可以看出 Partial 只对第一层属性成员添加可选修饰符,具体实现如下:
// 浅处理
type ShallowPartial<T> = {
[K in keyof T]?: T[K];
}
// 深处理(递归)(一个函数也满足 `extends object`,所以前置条件先判断是否满足 `extends Function`。)
type DeepPartial<T> = T extends Function
? T
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
Partial 的实现理解了,相信你会推断出 Readonly(将属性成员变成只读)、Required的实现。
2、Record
Record 用于构建一个新的 interface 类型,接收两个泛型变量:
- 第一个泛型变量 K,可以是字面量类型或联合类型,指定新类型的成员属性;
- 第二个泛型变量 T,是每一个成员属性的类型定义。
通常用于处理一组相同类型的对象属性非常有用。使用示例:
interface PageInfo {
title: string;
}
type Page = 'home' | 'about' | 'contact';
const x: Record<Page, PageInfo> = {
about: { title: 'about' },
contact: { title: 'contact' },
home: { title: 'home' },
};
实现如下:
type Record<K extends (string | number | symbol), T> = {
[P in K]: T;
}
3、Pick
Pick 用于从一个类型中挑选出部分属性,来构造出一个新的 interface 类型。
使用示例:
interface IUser {
name: string;
age: string;
avatar: string;
}
type UserPreview = Pick<IUser, 'name' | 'avatar'>;
const IUser: UserPreview = {
name: 'cegz',
avatar: 'avatar.png',
};
实现如下:
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
}
4、Exclude
Exclude 排除,从联合类型 T 中排除 U 中的类型,来构造一个新的类型。
使用示例:
type T = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
实现如下:
type Exclude<T, U> = T extends U ? never : T;
5、Omit
Omit 与 Pick 相反,用于从类型 T 中剔除部分属性 K 来构造类型。
使用示例:
interface IUser {
name: string;
age: string;
avatar: string;
}
type TUser = Omit2<IUser, 'age'>;
// 得到:
type TUser = {
name: string;
avatar: string;
}
结合 Pick 和 Exclude,实现如下:
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
6、Required
Required 可将一个类型的所有可选属性,变为必填属性。
interface IUser {
name: string;
age: number;
sex?: string;
hobby?: string;
}
type TUser = Required<IUser>;
// 得到:
type TUser = {
name: string;
age: number;
sex: string;
hobby: string;
}
原理实现:
type Required<T> = {
[P in keyof T]-?: T[P];
}
7、ReturnType
ReturnType 用于提取一个函数类型的返回值,作为新的类型返回。
使用示例:
type R = ReturnType<() => string>; // string
思路:
- T 如果是一个函数 ? 函数的返回值类型 : T
- 利用 extends 判断是否为一个函数;
- 利用
infer提取函数返回值类型。
实现如下:
type ReturnType<T extends (...args: any[]) => any> = // 约束 T 必须是一个函数
T extends (...args: any[]) => infer R ? R : any;
8、Parameters
Parameters 用于提取函数的参数类型,并形成一个元组类型。
使用示例:
function greet(name: string) {
return `Hello, ${name}.`;
}
type TParams = Parameters<typeof greet>;
const params: TParams = ['xiaoming'];
实现如下:
type Parameters<T extends (...args: any[]) => any> =
T extends (...args: infer P) => any ? P : never;
可以看到,ReturnType 和 Parameters 结合 typeof 关键字,可以很轻松的对一个 JS 函数进行类型拆解。
9、Optional
Optional 可将一个接口的 指定参数 变为 可选参数。
假设我们有一个 文章 的信息结构如下:
interface Article {
title: string;
content: string;
author: string; // 作者
date: Date; // 日期
readCount: number; // 阅读量
}
现在要创建一个文章,不过,创建文章并不需要 Article 中的所有信息,只需要 title 和 content 即可完成创建。
推荐的做法是:能够基于 Article 运算出来一个 CreateArticleOptions 类型。
type CreateArticleOptions = Optional<Article, "author" | "date" | "readCount">;
function createArticle(options: CreateArticleOptions) {
// options. 借助编辑器查看属性信息
}
那么,这就需要我们实现一个类型工具函数:Optional,原理很简单:Omit、Pick、Partial 以及 & 交叉类型 结合实现。
// K extends keyof T --> 约束 K 是 T 中的一部分
// Omit<T, K> --> 拿到 除 K 以外的其他字段
// Partial<Pick<T, K>> --> 将 K 从 T 中提取出来,并设置为可选字段
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
10、GetOptionals
实现一个 GetOptionals 泛型工具类,接收一个类型,可将类型的可选属性全部提取出来,构造成一个新的类型。
interface IUser {
name: string;
age: number;
sex?: string;
hobby?: string;
}
const keys: GetOptionals<IUser>;
// 得到 { sex: string; hobby: string };
实现 GetOptionals:
type GetOptionals<T> = {
// 步骤 1)P in keyof T 可以将 T 中的每一个属性遍历出来;
// [P in keyof T]: T[P];
// 步骤 2)as `get${P&string}` 可以对一个属性进行重命名;(有一些场景可能需要对字段增加公共前缀,可以这样来实现)
// [P in keyof T as `get${P&string}`]: T[P];
// 步骤 3)若使用重命名 as never 则可以去除掉一个属性
// [P in keyof T as never]: T[P];
// 步骤 4)如果是必填属性,使用 as never,反之保留属性。这里要用到三目运算。
// [P in keyof T as 属性为必填 ? never : P]: T[p];
// 最终版,Required<T> 用于将类型的属性全部转为必填,最后使用 extends 来判断 T[P] 类型是否为必填。
[P in keyof T as (T[P] extends Required<T>[P] ? never : P)]: T[P];
}
小技巧
很多时候,多个类型工具函数组合使用,可以解决很多场景问题。
比如上面的 Omit,它可以由 Pick 和 Exclude 组合来实现。
七、思考题
1、将非函数的属性去掉
假设我们现有如下对象,对象自身具有属性和方法:
const EffectModule = {
message: '',
setMessage() {
return this.message;
},
}
我们如何过滤掉对象中的属性,只保留对象的方法来构造成一个新的类型?
实现:
type PickFunc<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
解释:
在 in 映射联合类型时,我们使用 as 断言属性 K,如果 T[K] 值不是函数类型,则断言成 never 实现属性排除。
我们应用到示例中:
type E = PickFunc<typeof EffectModule>;
// 得到:
type E = {
setMessage: () => string;
}
2、转换函数类型签名
假设我们现有如下函数,从:
type Fn<T, U> = (arg: Promise<T>) => Promise<U>;
变为:
(arg: T) => U;
要实现上述操作,核心点在于:提取 Promise 泛型中的变量。
我们先来实现一个简单操作:提取函数的参数类型,借助 infer 可以轻松实现:
type ParamsType<T> = T extends (params: infer P) => any ? P : T;
type Params = ParamsType<Fn<string, number>>; // Params = Promise<string>
现在,如果我们想要拿到 Promise 中的泛型变量,可以这样实现:
type ExtractPromise<T> =
T extends (arg: Promise<infer T>) => Promise<infer U>
? (arg: T) => U
: never;
type PromiseType = ExtractPromise<Fn<string, number>>; // PromiseType = (arg: string) => number
现在,我们来看一道思考题,来加深对上面两个操作的印象。
3、来一道面试编程题
假设有一个 EffectModule 类,它上面会有属性、同步方法 setMessage 和 异步方法 delay,定义如下:
interface Action<T> {
payload?: T;
type: string;
}
class EffectModule {
count = 1;
message = "hello!";
delay(input: Promise<number>): Promise<Action<string>> {
return input.then((i) => ({
payload: `hello ${i}!`,
type: "delay",
}));
}
setMessage(action: Action<Date>): Action<number> {
return {
payload: action.payload!.getMilliseconds(),
type: "set-message",
};
}
}
现要求根据 EffectModule 的类型,构造出一个仅包含方法的类型,并且同步方法和异步方法签名由上面的定义变成如下所示:
type NewType = {
delay(input: number): Action<string>;
setMessage(action: Date): Action<number>;
};
现在,我们要对 EffectModule 实例上的函数签名进行修改,并过滤掉非函数属性,返回一个新类型。结合上面的知识,代码实现如下:
type PickFuncKeys<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type ExtractContainer<P> = {
[K in PickFuncKeys<P>]: // 首先过滤掉非函数属性
P[K] extends (arg: Promise<infer T>) => Promise<infer U> ? (arg: T) => U : // 对 Promise 类型方法的处理
P[K] extends (arg: Action<infer T>) => Action<infer U> ? (arg: T) => Action<U> : // 对 Action 类型当法的处理
never
}
type NewType = ExtractContainer<EffectModule>;
// 得到:
type NewType = {
delay: (arg: number) => Action<string>;
setMessage: (arg: Date) => Action<number>;
}
4、将字符串转成元组
假设有字符串 'abc',你能通过 TS 编程将其转换成元组 ['a', 'b', 'c'] 吗?
思路:定义一个元组集合「T」,借助「`` 字符串模板语法」 + 「infer」 提取出第一个字符,放入元组集合 T 中,然后 「递归」 处理后续字符。
实现:
type StrToTuple<S extends string, T extends string[] = []> =
S extends `${infer F}${infer R}` ? StrToTuple<R, [...T, F]> : T
type Res = StrToTuple<'abc'>; // type Res = ["a", "b", "c"]
扩展:
如何获取字符串长度?假设有字符串 'abc',得到它的长度为 3。
我们知道,只有「元组」具有 'length' 属性可以获取元素个数:
type Len = ['a', 'b', 'c']['length']; // type Len = 3
在上面我们已经实现了 字符串 转 元组,获取字符串长度自然 轻而易举:Tuple['length']。
type LengthOfString<S extends string, T extends string[] = []> =
S extends `${infer F}${infer R}` ? LengthOfString<R, [...T, F]> : T['length'];
type Len = LengthOfString<'abc'>; // type Len = 3
5、将字符串转成联合类型
如下,实现 StringToUnion 将字符串 "123" 转成联合类型:"1" | "2" | "3"。
type Test = "123"
type Result = StringToUnion<Test> // type Result = "1" | "2" | "3"
从上面「4、如何将字符串转成元组?」我们知道了字符串可以转换成元组。在元组中有一个 number 属性,通过它可以得到一个由元组成员构成的「联合类型」。
type StringToUnion<S extends string, T extends string[] = []> =
S extends `${infer F}${infer R}`
? StringToUnion<R, [...T, F]>
: T[number];
当然我们还有另一种更为简单的方式实现:使用 | 将每个字符与其他字符构成联合类型。
type StringToUnion<S extends string> = S extends `${infer F}${infer R}`
? F | StringToUnion<R>
: never;
6、将数字转成字符串
在 TS 中可以通过 `` 模板语法轻松将 number 转成 string。
type A = `${12}`; // type A = "12"
扩展:
如何将一个负值 number,转换成正数字符串呢?如数字 -100 转换成正数字符串 "100"。
上面我们已经实现了 数字 转 字符串,接下来匹配字符串第一个字符是否为 - 即可实现。
type Absolute<T extends number | string | bigint> =
`${T}` extends `-${infer U}` ? U : `${T}`;
7、判断泛型变量 T 是 never 类型
我们知道 never extends never 条件成立,但是 never 作为泛型变量传入,进行判断得到的却是 never。如下:
type IsNever<T> = T extends never ? true : false;
type R = IsNever<never>; // type R = never
这是为何?
其实在泛型变量 T 参与 extends 判断时,默认会使用「分布式条件类型」特性,而 never 应用这一特性后,实际上得到一个空的联合类型,致使 T extends ... 过程被整体跳过了,最后的结果就是 never。
为了解决「分布式条件类型」下 never 不参与 extends 条件判断这一问题,可以把 never 放入一个 [] 元组中,从而跳过「分布式条件类型」去参与条件判断。
最终的实现如下:
type IsNever<T> = [T] extends [never] ? true : false;
8、判断泛型变量 T 是否是联合类型
实现类型 IsUnion,它接受一个输入类型 T,判断 T 是否为 union 联合类型。
示例:
type case1 = IsUnion<string> // false
type case2 = IsUnion<string | number> // true
type case3 = IsUnion<[string | number]> // false
思路:
借助「分配条件类型特性」,让 整个联合类型 与 联合类型中的单个成员 参与 extends 条件比较,若不满足,我们认为类型 T 是一个联合类型。
实现:
type IsUnion<T, B = T> = T extends B ? [B] extends [T] ? false : true : never;
说明:
T由外部传入,先解释一下:当 T 是联合类型时,在extends表达式中会参与「分配条件类型」,此时extends表达式中的 T 表示联合类型中的某一项;- 扩展变量
B = T目的是保存传入的 T 联合类型的引用,即 B 始终是 T 完整的联合类型。 T extends B,泛型变量 T 遇到 extends 关键字,表示遍历 T 联合类型中的每一个成员类型,参与 extends 条件表达式;[B] extends [T],表示 跳过分配条件类型特性(使用[]包裹),将完整的联合类型 B 与当前遍历到的成员 T 类型比较,如果满足条件,说明不是一个联合类型,否则认为是一个联合类型。
9、获取字符串的末尾字符
给定字符串 'abc',如何获取最后一个字符呢,即字符 c。
假如是获取第一个字符,我们可以使用 `` 模板语法 + infer 轻松实现。
type FirstChat<T extends string> = T extends `${infer F}${infer Other}` ? F : '';
type Res = FirstChat<'abc'>; // type Res = "a"
由于模板语法与 infer 关键字,会从左侧将第一个字符提取出来,剩余的一到多个字符会全部提取到 Other 内,因此无法直接实现提取最后一个字符。
不过我们可以换一种思路来实现:先将字符翻转,再提取第一个字符。
type ReverseString<S extends string> = S extends `${infer First}${infer Rest}`
? `${ReverseString<Rest>}${First}`
: '';
type LastChat<T extends string> = ReverseString<T> extends `${infer F}${infer Other}`
? F
: '';
type Res = LastChat<'abc'>; // type Res = "c"
10、排除 interface 中类型为 never 的成员
假设我们有一个 MyInterface 接口,期望实现一个泛型工具 ExcludeNever,来排除 MyInterface 中类型为 never 的成员,即排除成员 age。
示例:
interface MyInterface {
name: string;
age: never;
sex: string;
}
// 期望:
type Res = ExcludeNever<MyInterface>; // type Res = { name: string; sex: string; }
思路: 我们可以通过「映射类型」和「泛型约束」来实现。
- 使用映射类型
in遍历输入类型的所有键(keyof T) - 使用
as断言,来检查每个键的类型; - 如果某个属性的类型是
never,它将被排除(使用never类型作为映射的结果是 TS 排除属性的一种惯用法),否则保留该属性。
实现:
// 定义一个类型别名 ExcludeNever,用于排除 never 类型的成员
type ExcludeNever<T> = {
[K in keyof T as T[K] extends never ? never : K]: T[K];
}
!!! 结合以上思路,可以在 interface 中排除任意类型的成员,不局限于 never。
11、实现一个泛型工具,返回一个联合类型
比如实现 ObjectEntries 泛型工具,传入一个对象类型,最终得到一个由 [key, value] 组成的联合类型。
示例:
interface Model {
name: string;
age: number;
locations: string[] | null;
}
type modelEntries = ObjectEntries<Model> // ['name', string] | ['age', number] | ['locations', string[] | null];
我们知道 in 映射类型只能生成对象类型,这个场景不合适使用。
这里我们需要使用 「分布式条件类型」 来实现。在泛型中使用 extends 关键字时,如果参与条件的 泛型变量 是一个 联合类型,会将联合类型中的每一个成员分别与 extends 进行运算,将得到的结果构成联合类型。
有了这个思路,ObjectEntries 泛型工具实现如下:
type ObjectEntries<T, K extends keyof T = keyof T> =
K extends string ? [K, T[K]] : never;
- 扩展一个泛型变量
K,它是类型T所有成员构成的联合类型; - 将
K联合类型参与extends条件运算,命中 「分布式条件类型」 特性,最终返回一个联合类型。
12、使用类型变量作为对象类型的成员 Key
如果按照 JS 对象[变量] 的方式使用,我们发现行不通,因为类型变量仅是一种类型,不能作为值去使用。
type Key = "name";
type User = {
[Key]: string; // error,“Key”仅指一种类型,不能作为值使用
}
不过可以将类型变量 Key 看做是一个联合类型,并使用 in 映射类型来实现:
type Key = "name";
type User = {
[K in Key]: string;
} // type User = { name: string }
扩展:
在泛型中,infer 提取的类型变量,默认类型是 type parameter(类型参数),为了使变量能作用于 in 条件映射,我们可以为变量使用 & string 交叉类型,即约束了变量类型为 string,又能得到变量自身的字面量形式。
如实现一个 TupleToObject<T, U>,将元组 T 中第一个成员作为对象类型的属性,值为 U。
type TupleToObject<T, U> = T extends [infer F, ...infer Other]
? { [K in F & string]: U } // F & string --> 'a'
: never;
type Res = TupleToObject<["a"], string>; // type A = { a: string; }
// & string 解释:
type A = 'a' & string; // type A = "a"
13、判断一个元组中是否包含某个元素
假设我们有一个元组 Tuple:
type Tuple = ['left', 'top', 'right', 'bottom', 'center'];
现在需要判断 'left' 是否在 Tuple 中。
我们可以先使用 Tuple[number] 来获取 Tuple 中的所有元素,然后使用 extends 来判断是否包含:
type UnionType = Tuple[number]; // expected to be "left" | "top" | "right" | "bottom" | "center"
type Res = 'left' extends UnionType ? true : false; // expected to be `true`
文末
感谢阅读。若阅读本文对您有益,可以为此篇文章点一个小赞赞,谢谢支持。
参考文章:
1. 快速掌握 TypeScript 新语法:infer extends
2. 考察 TypeScript
3. 精读《@types react 值得注意的 TS 技巧》
4. TS 动画版进阶教程
5. TypeScript进阶之工具类型&高级类型
6. 想去力扣当前端,TypeScript 需要掌握到什么程度?