说明
去年开始入坑 TypeScript , 在这之前公司的项目中用的时 flow, 同样都是做类型检查,明显 TypeScript 更加严格,且社区支持更好一些。
上手 TypeScript 初期会有些不习惯,但是使用一段时间后会发现,TS 带来的类型检查、编辑器提示是真的方便,我也从 “只能写一些简单的类型组合” 慢慢的开始可以使用一些”进阶的技巧“
原本打算梳理一下自己的学习所得,但是发现这篇文章(TypeScript 的另一面:类型编程)写的很详细,并且很有用,我就不再献丑了,下面文章仅记录一些概念以及 ”遇到某些情况该如何写泛型“
参考
TypeScript 相关概念
1. 什么是静态类型检查?
- JavaScript 本身是动态类型语言,变量的类型在运行过程中存在变化的可能,增加了不确定性
- 增加类型检查可以提高代码的确定性、可维护性
- 在不运行代码的情况下,检测代码中的错误称为静态检查,根据操作的值的种类来确定是不是错误,被称为静态类型检查
2. 什么是泛型
- 泛型 – 带参数的类型,是指在定义函数、接口或者类的时候,不预先指定具体类型,而在使用的时候再指定类型的一种特性
- TS 中的泛型结合泛型约束,可以方便类型重用、提升类型系统的灵活性、严谨性
- 他与 JS 中函数的参数有类似的特性
- 泛型的值(类型)是在方法被调用、类被实例化等执行过程中确定的
function identity<Type>(arg: Type): Type {
return arg;
}
3. 什么是泛型约束
- 有了泛型之后,类型的可能性变得很大,这个时候我们可以通过类型约束,限制传入泛型的范围
- 将泛型约束为一类类型,收窄类型范围,就是泛型约束
interface Ilength {
length: number
}
function getLength<T extends Ilength> (arg: T) {
console.log(arg.length)
return arg
}
getLength<string>('22')
4. 什么是类型推断
- TypeScript 能根据一些简单的规则推断变量的类型,因此大部分情况我们不需要去通过类型注解来定义类型
infer R
用来给待推断的类型占位,R 表示为待推断的类型,通常 infer 不会被直接使用,而是与条件类型一起,这样我们即可获得 TS 推断后的类型
let a = 20;
a = 'string' // TS2322: Type 'string' is not assignable to type 'number'. 因为上一句已经将 a 推断为 number 类型了
// 定义一个用来获得函数返回值的类型
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R
? R
: never;
function test(a: number) {
return a > 10;
}
type TestFnReturnType = ReturnType<typeof test>; // boolean
5. 什么是类型守卫(类型保护、类型收缩)
- 将类型细化为比声明的类型更具体的类型的过程
- 例如 typeof 、instanceOf 、in 、is 等运算符,可以帮助将条件块中的变量类型缩小
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
// typeof 可以用来缩小不同分支中的类型,例如,代码执行到此处时类型检查会默认 padding 为 number, 后续的操作不需要考虑 string 类型的处理
return new Array(padding + 1).join(" ") + input;
}
return padding + input;
}
5. 什么是类型编程
- 通过组合各种类型运算符,我们可以根据已有的类型或者值,来表达新的类型
常见类型使用场景
1. 基本类型回顾
// 1. `string` `number` `boolean`
let a: string = 'sss';
// 2. 数组 `string[]` 或者 `Array<string>`
const arr: (string | number)[] = ['sss', 90];
// 3. 对象
const obj: {color: string} = {color: 'red'};
// 4. 函数 (可选值)
function test(param1: string, param2?: number): number {
return param1.length + (param2 || 0);
}
// 5. Class
interface CanRender {
render(): this
}
// strictPropertyInitialization 控制在构造函数中初始化类字段
class Parent extends XXX implements CanRender { // Parent 需要实现 CanRender 接口
static print(str: string) { // 静态方法
console.log(str);
}
// public:任何地方可访问;protected:类及子类可访问;private:仅当前类可访问
private name!: string;
// 使用非空断言 ! 来使得类的属性可以先定义类型再在初始化之后赋值
public age!: number;
constructor(name: string) {
super(); // 有继承需要先 super()
this.name = name; // 初始化
}
public render(): this {
this.name = 'name';
return this;
}
}
// 6. 枚举
enum CONST_VARS {
NAME = 'name',
}
// 7. 类型别名 type
type TItem = string;
// 8. 接口 interface
interface IItem {
name: string;
readonly age: number; // 只读
}
const a: IItem = { name: 'ss', age: 90 };
a.age = 100; // Error
// 9. 接口继承 interface
interface IItem2 extends IItem {
color: string;
}
const b: IItem2 = { name: 'ss', age: 90, color: 'red' };
// 9. 联合类型
type Item = string | number | boolean;
// 10. 交叉类型
interface IPerson {
name: string
}
interface IMen {
age: number
}
type IXiaoBai = IPerson & IMen
const nike: INike = { name: 'nike', age: 20 };
// 11. 索引类型 keyof
interface IObject {
name: string,
age: number,
color: string
}
const key: keyof IObject = 'name'; // 相当于 'name' | 'age' | 'color'
// 12. 映射类型 { [K in keyof T]: V }
type IReadOnlyObject = {
readonly [K in keyof IObject]: boolean
}
// 13. 泛型
function identity<Type>(arg: Type): Type {
return arg;
}
// 14. 条件类型 (类似于三元表达式)
type LiteralType<T> = T extends string ? "foo" : "bar";
2. 类型断言
有的时候,从 TS 的类型推断得到的类型比较宽泛,而我们获得的信息比 TS 推断的更加精确
这个时候我们可以使用 as 来断言类型
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
但是 TS 只允许类型断言转换为更具体的类型版本
// Error Type 'HTMLElement' is not comparable to type 'boolean' 不能转换为 boolean,
const myCanvas = document.getElementById("main_canvas") as boolean;
如果我们一定要转变类型则可以先将其转为 any 或者 unknown ,然后在断言为指定类型
const myCanvas = (document.getElementById('main_canvas') as unknown) as boolean;
除了 as ,我们还可以使用
!
来进行非空断言,它一般用在表达式后,将前边表达式返回值类型中的 null undefined 排除掉,表示:表达式的值不可能是 null 或者 undefined
function liveDangerously(x?: number | null) {
// No error
console.log(x!.toFixed());
}
/**
如果设置了 --strictPropertyInitialization 构造函数中初始化类字段
那么只声明属性类型而不再类中设置值,会得到 TS 的报错
如果打算通过构造函数以外的方式初始化字段(例如:const test = new Test(); test.name = 'xxx')则可以使用非空断言来解决报错的问题
*/
// strictPropertyInitialization
class Test {
name!: string;
}
3. 类型守卫 | 类型保护 | 类型收缩
实际上三者做的都是同一件事情(后边统称类型收缩):将传入函数的类型范围缩小、精确
类型收缩通常离不开条件语句,结合条件语句,类型收缩可以帮助将条件块中的变量类型缩小
通常应用与类型收缩的有 typeof 、instanceof 、in 、 A && A 、 A || A 、!A 、A in B 、instanceof 、A === B 、A !== B 、if 、switch 等判断语句
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
// 1. typeof 类型守卫,可以用来缩小不同分支中的类型,例如,代码执行到此处时类型检查会默认 padding 为 number, 后续的操作不需要考虑 string 类型的处理
return new Array(padding + 1).join(" ") + input;
}
return padding + input;
}
function compare(str: string, condition: string | RegExp) {
// 2. instanceof 检查是否为某个类的实例
if (condition instanceof RegExp) {
return condition.test(str);
}
return str === condition;
}
interface Circle {
type: 'circle';
radius: number;
x: number;
y: number;
}
interface Square {
type: 'square';
length: number;
}
type Shape = Circle | Square;
function test(shape: Shape) {
// 联合类型可以借助共同的属性来缩小条件块中的类型范围
if (shape.type === 'circle') {
// shape: Circle
console.log(shape.length * 100); // 报错因为 type === 'circle' 条件下没有 length 属性
console.log(shape.radius * 100); // 正确
}
// 也可以使用 in 来缩小
if ('x' in shape) {
// shape: Circle
console.log(shape.radius * 100); // 正确
console.log(shape.length * 100); // 报错因为包含 ‘x’ 属性证明是Circle 类型,没有 length 属性
}
}
以上方法都是借助 JS api 来在 if 条件块中收缩类型范围,如果要直接操作类型,我们可以使用类型谓词 is 来在类型定义中控制类型的范围
/**
注意 类型谓词结构为 parameterName is Type ,parameterName 即为传入参数名
*/
const isString = (arg: unknown): arg is string => {
return typeof arg === 'string';
};
function getLen(str: number | string) {
// 每次调用 isString 后, TS 都会将变量缩小到特定的类型
if (isString(str)) {
console.log(str.length); // 如果没有类型谓词,这里会报错
}
}
4. 函数的类型声明
1. 一般使用
// 1. 使用 type 来命名函数类型
type TTestFn = (shape: Circle | Square) => void;
// 2. 使用 interface 来命名函数类型
interface ITestFn {
(shape: Circle | Square): void;
}
// 3. 如果函数有别的属性
interface ITestFn {
(shape: Circle | Square): void;
description: string;
}
type TTestFn = { // type 也可以使用这种写法
(shape: Circle | Square): void;
description: string;
};
// 4. 构造函数(或者class)怎么表示
interface ITestFn {
new (shape: Circle | Square): void;
description: string;
}
const run = (Test: ITestFn) => {
console.log(Test.description);
console.log(new Test({ type: 'square', length: 90 }));
};
2. 函数重载
- 函数签名:定义了函数或者方法的输入输出
function test(shape: Circle): string;
- 函数定义:除了定义函数的输入输出,还需要有函数的实现
- 函数重载的实现,允许有两个以上的函数签名 + 一个函数定义
一些 JS 函数可以在不同参数组合下有不同的表现,直接操作函数定义得到的类型可能没有那么精确,或者传参不受控, 借助函数重载可以借助 TS 类型系统来控制函数的传参。
function test(shape: Circle | Square, num?: number): string | boolean {
if (shape.type === 'square') {
return shape.length > (num as number);
}
return shape.type;
}
如上函数中,只有 shape 的类型为 Square 时,才可以接收第二个参数 num, 但是普通的类型定义会导致出现
shape: Square, num: undefined
和shape: Circle, num: number
的情况,同时在 TS 类型检查时上述情况不会有报错,这并不符合预期,通过类型重载(如下:),我们可以解决这种问题
// 函数签名通常在函数实现前定义两个或者两个以上
function test(shape: Circle): string; // 传入一个参数被这里检查
function test(shape: Square, num: number): boolean; // 传入两个参数会被这里检查
// 函数的实现需要兼容两个函数签名
function test(shape: Circle | Square, num?: number): string | boolean {
if (shape.type === 'circle') {
return shape.type;
}
return shape.length > (num as number);
}
test({ type: 'circle', radius: 10 });
test({ type: 'square', length: 10 }, 20);
/**
* Error: type: 'square' 不满足 Circle 的类型
* 因为 函数只传入一个实参,因此会被当做第一个函数签名处理
* 第一个函数签名 function test(shape: Circle): string; 中要求传入 Circle 类型
* type: 'square' 表示的是 Square,不符合 Circle 类型,所以报错
* */
test({ type: 'square', length: 10 }); // Error
经过函数重载,我们的传参情况被收紧,更加精确了
不光函数, 类的方法,类的构造函数都可以实现重载
3. this 的使用
this 在 TS 中除了可以用在返回值类型定义中,还可以当做参数传入函数
在 JavaScript 中 函数的参数名被设置为 this 是非法的,会报错,但是在 TS 中不会报错,这是因为,this 作为参数,实际上是个伪参数,它只能放在参数列表的第一位,经过编译后,并不会生成实际的参数,而是用来限制函数执行时上下文的类型
class Test {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
// 限制函数执行时上下文为 Test
print(this: Test) {
console.log(this.name);
}
// 设置 this: void 可以禁止方法中使用 this
print2(this: void) {
console.log(this.name); // Error
}
}
const test = new Test('xiaobai', 90);
test.print();
const test2 = {
// name: 'xxx',
// age: 80,
print: test.print,
};
test2.print(); // 报错,this 上下文的结构与 Test 不一致,取消 test2 注释后不报错
5. 元组 类型
// 简单的元组
type Tuple1 = [number, string];
const a: Tuple1 = [90, 'string'];
// 带有可选元素的元组
type Tuple1 = [number, string?]; // 可选元素只能放在最后
const a: Tuple1 = [90];
// 带有剩余元素的元组
type Tuple1 = [string, ...number[], string]; // 必须用数组或者元组收集剩余元素
const a: A = ['asd', 90, 'sss'];
const b: A = ['asd', 'sss'];
6. 索引类型查询
可以通过索引来查找某种类型的特定属性
// 简单的索引
type Person = { age: number; name: string; alive: boolean };
type Age = Person['age']; // number
// 如果 传入联合类型
type Unit = Person['age' | 'name']; // string | number
// Array 类型的索引
type ArrayType = [string, number];
type ItemType = ArrayType[number]; // string | number
// 获得某个 Array 数据的元素类型 (注意需要使用 typeof)
const MyArray = [
{ name: 'Alice', age: 15 },
{ name: 'Bob', age: 23 },
{ name: 'Eve', age: 38 },
];
type Person = typeof MyArray[number]; // {name: string, age: number}
7. 使用 infer 在条件类型中进行推断
infer R
R 则被表示为待推断的类型,通常 infer 不会被直接使用,而是与条件类型一起,
用来从条件判断上等到信息
// 最常用案例 ReturnType 实现
type ReturnType<Func extends (...args: any[]) => any> = Func extends (...args: any[]) => infer R ? R : never;
const func = (a: string) => a.length;
type Ret = ReturnType<typeof func>; // number
如上例子中,在 ReturnType 被调用时,
Func extends (...args: any[]) => infer R
会帮助我们推导出 R 的类型,然后在条件语句中进行后续的判断,我们可以理解infer R
为一个占位符,在 TS 类型系统或者足够信息后,会将其兑换为推导后的值。
需要注意的时,在上述例子中 如果传入的函数式带有重载的,那么只有重载函数中的最后一个函数会被推导
8. 条件类型对联合类型泛型参数的分发
即,如果传入泛型类型的参数是一个联合类型,那么其结果将会是联合类型中的每个成员分别传入泛型类型结果的联合,转换成伪代码,如下
type Naked<T> = T extends boolean ? "Y" : "N";
// 传入联合类型
type Distributed = Naked<number | boolean>; // 返回值为 'N' | 'Y'
// 相当于联合类型中的每个成员分别传入泛型类型结果的联合
type Distributed = Naked<number> | Naked<boolean>;
// 相当于
type Distributed = (number extends boolean ? "Y" : "N") | (boolean extends boolean ? "Y" : "N")
这种分配大部分情况下是符合预期的,如果要避免这种默认行为,可以通过将关键字 用
[]
括起来。
type Wrapped<T> = [T] extends [boolean] ? 'Y' : 'N';
type NotDistributed = Wrapped<string | number>; // N 可以看到返回值不再是联合类型了
9. 映射类型
/**
1. 基本结构 [Property in Union]: Value
in 之后只要是一个联合类型就可以了
但是通常用来映射一个接口 interface, 因此最常见的是 [Property in keyof Type]: Value; 这种结构
*/
type OptionsFlags<Type, Value> = {
[Property in keyof Type]: Value;
};
// 2. 切换 readonly 与 required , 借助 +(追加) -(去除) 来实现
interface Test {
readonly name: string;
age?: number;
color: string;
}
// 如下泛型意为:将传入 Type 所有属性遍历,每个属性都变成 readonly 且去除 ?
type Transfer<Type> = {
readonly [Property in keyof Type]-?: boolean;
};
type TestTransfer = Transfer<Test>;
// 结果如下
// {
// readonly name: string;
// readonly age: number;
// readonly color: string;
// }
借助 as 实现键的重映射
- 需要 TS version >= 4.1
通过
[Property in keyof Type]: Value
这种方式,我们可以获得 Type 类型的 每一个 Property (键),然后将它映射为后边的 Value, Value 部分可以是一个包含 Property 的计算结果,例如[Property in keyof Type]: Type[Property] extends string ? 'Y' : 'N'
。
有的时候不仅 Value 部分需要根据遍历出的 Property 做变化,Key 的部分也需要,这个时候就需要借助 as 实现重映射
// 官方案例,如下意为,每一个遍历出的 Properties 都会当做 NewKeyType 使用
type MappedTypeWithNewProperties<Type> = {
[Properties in keyof Type as NewKeyType]: Type[Properties]
}
// NewKeyType 部分可以是一个模板字符串组合出的结果(也是一种类型--模板文字类型)
type Getters<Type> = {
[Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};
interface Person {
name: string;
age: number;
location: string;
}
type LazyPerson = Getters<Person>;
// type LazyPerson = {
// getName: () => string;
// getAge: () => number;
// getLocation: () => string;
// }
// 除了模板文字类型,还可以使用 返回 never 做一些过滤
type Transfer<Type, P extends keyof Type> = {
[Property in keyof Type as Property extends P ? Property : never]: Type[Property]
};
interface Props {
readonly name: string;
age: number;
color: 'red' | 'yellow';
}
type Test = Transfer<Props, 'age' | 'name'>; // 相当于 Pick<Props, 'age' | 'name'>
10. 类似于模板字符串的 “模板文字类型”
模板文字类型与模板字符串的写法基本上一样,用来扩展 TS 中的字符串文字类型
也可以用来扩展字符串类型组合成联合类型
type World = "world";
type Greeting = `hello ${World}`; // "hello world"
// 联合类型会被分发,有多个联合类型会被各自组合
type A = 'a' | 'A';
type B = 'b' | 'B';
type All = `${A}-${B}-xxx`; // "a-b-xxx" | "a-B-xxx" | "A-b-xxx" | "A-B-xxx"
11. 内置泛型类型
-
Partial<Type>
转变 Type 所有属性为可选类型 -
Required<Type
转变 Type 所有属性为必选类型 -
Readonly<Type>
转变 Type 所有属性为只读 -
Record<Keys,Type>
构造一个对象类型,键为 Keys 的映射, 值类型为 Type -
Pick<Type, Keys>
从 Type 中选取键为 Keys 项组成新的对象类型 -
Omit<Type, Keys>
从 Type 中排除键为 Keys 项,其余项组成新的对象类型 -
Exclude<Type, ExcludedUnion>
排除联合类型中的某几项,剩余的组成新的联合类型 -
Extract<Type, Union>
取得两个联合类型中相同的项组成新的联合类型 -
NonNullable<Type>
排除联合类型中的 undefined | null -
Parameters<Type>
获取函数参数的类型元祖 -
ConstructorParameters<typeof ParentClass>
获得类的构造函数的参数元祖 -
ReturnType<Type>
获得函数的返回值类型 -
InstanceType<typeof ParentClass>
获得构造函数的实例类型 -
ThisParameterType<Type>
获得函数的 this 参数类型,比较鸡肋,需要在函数定义时同时定义 this,function toHex(this: Number) {}
否则拿不到准确值 -
OmitThisParameter<Type>
获得删除 this 定义后的函数类型 -
ThisType<Type>
充当上下文 this 的标记,用来定义某些方法的上下文type ObjectDescriptor<D, M> = { data?: D; methods?: M & ThisType<D & M>; // methods 下 this 上下文为 D & M }; function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M { let data: object = desc.data || {}; let methods: object = desc.methods || {}; return { ...data, ...methods } as D & M; } let obj = makeObject({ data: { x: 0, y: 0 }, methods: { moveBy(dx: number, dy: number) { this.x += dx; // 借助类型推断以及 ThisType 这里的 this 包含 x,y,moveBy 三个属性 this.y += dy; // 如果不使用 ThisType 这里的 this 指向实际是存疑的,TS 会报错 }, }, });
-
Uppercase<'Hello, world'>
字符串转大写 -
Lowercase<'Hello, world'>
字符串转小写 -
Capitalize<'hello, world'>
字符串首字母大写 -
Uncapitalize<'Hello, world'>
字符串首字母小写
12. 接口合并
如果写两个同名 Type 会得到 TS 的错误警告,但是如果写两个同名的 interface ,则不一定
interface A {
height: number;
}
interface A {
width: number;
}
const a: A = {
height: 90,
width: 90,
};
如上定义两个 同名为 A 的 interface 并没有报错,反而其类型会被合并,这是在两者没有共同属性的前提下,实时上如果两者有共同属性,并且类型相同,则同样可以合并,如果类型都是函数,那么不同的函数会当做函数重载处理,如果是其他类型且类型不同那么 TS 会给出警告
interface A {
height: number;
run(a: string): void;
}
interface A {
height: string; // ERROR
width: number;
run(a: boolean): void; // 函数类型不同会被当做函数重载处理
}