TypeScript拓展
拓展
TypeScript中常用的一些语法以及概念。
泛型
泛型Generics
是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。举一个简单的例子,如果需要实现一个生成数组的函数,这个数组会填充默认值,这个数组填充的类型不需要事先指定,而可以在使用的时候指定。当然在这里使用new Array
组合fill
函数是一个效果。
function createArray<T>(value: T, length: number): T[] {
const result: T[] = [];
for (let i = 0; i < length; i++) {
result[i] = value;
}
return result;
}
console.log(createArray<number>(1, 3)); // 不显式地指定`number`也可以自动推断
2
3
4
5
6
7
8
9
10
11
我们也可以约束T的类型只能为number
与string
。
const createArray = <T extends number|string>(value: T, length: number): T[] => {
const result: T[] = [];
for (let i = 0; i < length; i++) {
result[i] = value;
}
return result;
}
console.log(createArray<number>(1, 3));
// console.log(createArray(true, 3)); // Argument of type 'boolean' is not assignable to parameter of type 'string | number'.(2345)
2
3
4
5
6
7
8
9
10
11
12
多个类型也可以相互约束,例如上边的Pick
,在这里的K
必须是T
中key
的子集。
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
2
3
在传递泛型的时候可以为T指定默认值,使用范型编写class
即泛型类也是完全支持的。
class Example<T = number> {
public value: T;
public add: (x: T, y: T) => T;
constructor(value: T, add: (x: T, y: T) => T){
this.value = value;
this.add = add;
}
}
let example = new Example<number>(1, (x, y) => x + y);
console.log(example.value); // 1
console.log(example.add(1, 2)); // 3
2
3
4
5
6
7
8
9
10
11
12
13
断言
类型断言Type Assertion
可以用来手动指定一个值的类型,由于<Type>value
的语法容易与TSX
冲突,所以通常都是使用value as Type
的语法。通常当TypeScript
不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型中共有的属性或方法。
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function getName(animal: Cat | Fish) {
return animal.name;
}
2
3
4
5
6
7
8
9
10
11
12
而有时候,我们确实需要在还不确定类型的时候就访问其中一个类型特有的属性或方法。
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function isFish(animal: Cat | Fish) {
if (typeof animal.swim === "function") { // Property 'swim' does not exist on type 'Cat | Fish'. Property 'swim' does not exist on type 'Cat'.(2339)
return true;
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
上面的例子中,获取animal.swim
的时候会报错,此时可以使用类型断言,将animal
断言成Fish
。当然这里只是举一个例子说明断言的使用,因为滥用断言是不提倡的,类型断言只能够欺骗TypeScript
编译器,而无法避免运行时的错误,滥用类型断言可能会导致运行时错误。
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function isFish(animal: Cat | Fish) {
if (typeof (animal as Fish).swim === "function") {
return true;
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
单个断言即value as Type
是有一定条件的,当S类型是T类型的子集,或者T类型是S类型的子集时,S能被成功断言成T。这是为了在进行类型断言时提供额外的安全性,完全毫无根据的断言是危险的,如果你想这么做,你可以使用any。
如果认为某个值value
必定是某种类型Type
,而单个断言无法满足要求,可以使用双重断言,即value as unknown as Type
,使用value as any as Type
也是同样的效果,但是若使用双重断言,则可以打破要使得A
能够被断言为B
,只需要A
兼容B
或B
兼容A
即可的限制,将任何一个类型断言为任何另一个类型。通常来说除非迫不得已,不要使用双重断言。
此外类型断言之所以不被称为类型转换,是因为类型转换通常意味着某种运行时的支持,而类型断言只会影响TypeScript
编译时的类型,类型断言语句在编译结果中会被删除,也就是说类型断言纯粹是一个编译时语法,同时其也是一种为编译器提供关于如何分析代码的方法。
与类型断言相关的还有一个!的表达式,其在TypeScript 2.7
被加入,其称为definite assignment assertion
显式赋值断言,显式赋值断言允许你在实例属性和变量声明之后加一个感叹号!,来告诉TypeScript
这个变量确实已被赋值,即使TypeScript
不能分析出这个结果。
let x: number;
let y!: number;
console.log(x + x); // Variable 'x' is used before being assigned.(2454)
console.log(y + y); // ok
2
3
4
既然说到了!,那么也可以说一下?
,在interface
中?
和undefined
并不是等效的,在下面的例子中,在b
未将?
声明的情况下,其在interface
下是required
,TypeScript
认为其是必须指定的key
即使其值只能为undefined
。
interface Example{
a?: number;
b: undefined;
}
const example1: Example = {}; // Property 'b' is missing in type '{}' but required in type 'Example'.(2741)
const example2: Example = { b: undefined }; // ok
2
3
4
5
6
7
infer
infer
是在extends
条件语句中待推断的类型变量,也可以认为其是一个占位符,用以在使用时推断。例如上边的ReturnType
就是通过infer
进行推断的,首先是范型约束了一个函数类型,然后在后边进行infer
占位后进行推断。
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
有一些应用,tuple
转union
,如[string, number, symbol] -> string | number | symbol
。
type ElementOf<T> = T extends Array<infer E> ? E : never;
type TTuple = [string, number, symbol];
type ToUnion = ElementOf<TTuple>; // string | number | symbol
2
3
4
5
还有一个比较离谱的实现。
type TTuple = [string, number, symbol];
type Res = TTuple[number]; // string | number | symbol
// https://stackoverflow.com/questions/44480644/string-union-to-string-array/45486495#45486495
2
3
4
还比如获取函数参数的第一个参数类型。
type fn = (a: number, b: string, ddd: boolean) => void;
type FirstParameter<T> = T extends (args1: infer R, ...rest: any[]) => any ? R : never;
type firstArg = FirstParameter<fn>; // number
2
3
4
5
函数重载
TypeScript
允许声明函数重载,即允许一个函数接受不同数量或类型的参数时,作出不同的处理。当然,最终声明即从函数内部看到的真正声明与所有重载兼容是很重要的。这是因为这是函数体需要考虑的函数调用的真实性质。
function reverse(x: number): number;
function reverse(x: string): string;
function reverse(x: number | string): number | string | void {
if (typeof x === "number") {
return Number(x.toString().split("").reverse().join(""));
} else if (typeof x === "string") {
return x.split("").reverse().join("");
}
}
2
3
4
5
6
7
8
9
还有一个比较实用的简单例子,在ios
上的Date
对象是不接受形如2022-04-05 20:00:00
的字符串去解析的,当在safari
的控制台执行时,会出现一些异常行为。这个字符串的解析在谷歌浏览器或者安卓上就没有问题,所以需要做一下兼容处理。
// safari
const date = new Date("2022-04-05 20:00:00");
console.log(date.getDay()); // NaN
// chrome
const date = new Date("2022-04-05 20:00:00");
console.log(date.getDay()); // 2
2
3
4
5
6
7
所以需要对时间日期对象做一下简单的兼容,但是做兼容时又需要保证TS的声明,这时就可以使用函数重载等方式处理。
function safeDate(): Date;
function safeDate(date: Date): Date;
function safeDate(timestamp: number): Date;
function safeDate(dateTimeStr: string): Date;
function safeDate(
year: number,
month: number,
date?: number,
hours?: number,
minutes?: number,
seconds?: number,
ms?: number
): Date;
function safeDate(
p1?: Date | number | string,
p2?: number,
p3?: number,
p4?: number,
p5?: number,
p6?: number,
p7?: number
): Date | never {
if (p1 === void 0) {
// 无参构建
return new Date();
} else if (p1 instanceof Date || (typeof p1 === "number" && p2 === void 0)) {
// 第一个参数为`Date`或者`Number`且无第二个参数
return new Date(p1);
} else if (typeof p1 === "number" && typeof p2 === "number") {
// 第一和第二个参数都为`Number`
return new Date(p1, p2, p3 || 1, p4 || 0, p5 || 0, p6 || 0, p7 || 0);
} else if (typeof p1 === "string") {
// 第一个参数为`String`
return new Date(p1.replace(/-/g, "/"));
}
throw new Error("No suitable parameters");
}
console.log(safeDate("2022-04-05 20:00:00").getDay()); // 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
type DateParams =
| []
| [string]
| [number, number?, number?, number?, number?, number?, number?]
| [Date];
const safeDate = <T extends DateParams>(...args: T): Date => {
const copyParams = args.slice(0);
if (typeof copyParams[0] === "string") copyParams[0] = copyParams[0].replace(/-/g, "/");
return new Date(...(args as ConstructorParameters<typeof Date>));
};
console.log(safeDate("2022-04-05 20:00:00").getDay()); // 2
2
3
4
5
6
7
8
9
10
11
12
声明文件
对于全局变量的声明文件主要有以下几种语法:
declare var声明全局变量。declare function声明全局方法。declare class声明全局类。declare enum声明全局枚举类型。declare namespace声明含有子属性的全局对象。interface和type声明全局类型。declare module拓展声明。
我们可以通过declare
关键字来告诉TypeScript
,某些变量或者对象已经声明,我们可以选择把这些声明放入.ts
或者.d.ts
里。declare namespace
表示全局变量是一个对象,包含很多子属性。
// global.d.ts
declare namespace App {
interface Utils {
onload: <T extends unknown[]>(fn: (...args: T) => void, ...args: T) => void;
}
}
declare interface Window{
utils: App.Utils
}
// main.ts
window.utils = {
onload: () => void 0
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
对于模块的声明文件主要有以下几种语法:
export导出变量。export namespace导出含有子属性的对象。export default ES6默认导出。export = 导出CommonJs模块。
模块的声明文件与全局变量的声明文件有很大区别,在模块的声明文件中,使用declare
不再会声明一个全局变量,而只会在当前文件中声明一个局部变量,只有在声明文件中使用export
导出,然后在使用方import
导入后,才会应用到这些类型声明,如果想使用模块的声明文件而并没有实际的export
时,通常会显示标记一个空导出export {}
。对于模块的声明文件我们更推荐使用 ES6标准的export default
和export
。
// xxx.ts
export const name: string = "1";
// xxxxxx.ts
import { name } from "xxx.ts";
console.log(name); // 1 // typeof name === "string"
2
3
4
5
6
如果是需要扩展原有模块的话,需要在类型声明文件中先引用原有模块,再使用declare module
扩展原有模块。
// xxx.d.ts
import * as moment from "moment";
declare module "moment" {
export function foo(): moment.CalendarKey;
}
// xxx.ts
import * as moment from "moment";
moment.foo();
2
3
4
5
6
7
8
9
10
import Vue from "vue";
declare module "vue/types/vue" {
interface Vue {
copy: (str: string) => void;
}
}
2
3
4
5
6
7
还有一些诸如.vue文件、.css、.scss文件等,需要在全局中进行声明其import时对象的类型。
declare module "*.vue" {
import Vue from "vue/types/vue";
export default Vue;
}
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在声明文件中,还可以通过三斜线指令即///
来导入另一个声明文件,在全局变量的声明文件中,是不允许出现import
、export
关键字的,一旦出现了,那么他就会被视为一个模块或UMD
库,就不再是全局变量的声明文件了,故当我们在书写一个全局变量的声明文件时,如果需要引用另一个库的类型,那么就必须用三斜线指令了。
// types/jquery-plugin/index.d.ts
/// <reference types="jquery" />
declare function foo(options: JQuery.AjaxSettings): string;
// src/index.ts
foo({});
2
3
4
5
6
协变与逆变
子类型在编程理论上是一个复杂的话题,而他的复杂之处来自于一对经常会被混淆的现象。简单来说,协变即类型收敛,逆变即类型发散。在这里由下面的例子引起关于这个问题的讨论,在这里我们定义了一个父类型以及一个子类型,而且我们验证了这个子类型在TS中是OK的。
type SuperType = (value: number|string) => number|string; // 父类型
type SubType = (value: number|string|boolean) => number; // 子类型 参数逆变 返回值协变
const subFn: SubType = (value: number|string|boolean) => 1;
const superFn: SuperType = subFn; // ok
2
3
4
5
首先我们可以探讨一下子类型,明显number
是number|string
的子类型,那么下面这个例子是完全OK的,这同样也是一个协变的过程,由此看来在上边例子的SubType
确实是SuperType
的子类型。
type SuperType = number|string; // 父类型
type SubType = number; // 子类型
const subValue: SubType = 1;
const superValue: SuperType = subValue; // ok
2
3
4
5
那么此时就回到最上边的例子,这个函数参数value
的类型就很奇怪,明明是子类型,反而类型的种类更多了,这个其实就是所谓的逆变,其实这就是为了保证类型的收敛是安全的。此时我们的subFn
实际代表的函数是SuperType
类型的,当我们实际调用的时候,传递的参数由于是SuperType
类型的即number|string
,所以必定是SubType
类型的子类即number|string|boolean
,这样也就保证了函数参数的收敛安全,之后当函数执行完成进行返回值时,由于函数实际定义时的返回类型是number
,那么在返回出去的时候也必定是number|string
的子类,这样也就保证了函数返回值的收敛安全。我们可以通过这个图示去理解这个函数子类型的问题,类似于以下的调用过程,由此做到类型收敛的安全。
父类型参数 -> 子类型参数 -> 执行 -> 子类型返回值 -> 父类型返回值
number|string -> number|string|boolean -> ... -> number -> number|string
2
我们可以进行一个总结: 除了函数参数类型是逆变,都是协变。将一个函数赋给另一个函数变量时,要保证参数类型发散,即比目标类型范围小。目标函数执行时是执行的原函数,传入的参数类型会收敛为原函数参数类型。协变表示类型收敛,即类型范围缩小或不变,逆变反之。本质是为了保证执行时类型收敛是安全的。另外可以看一下 这篇文章 对于协变与逆变的描述。开始文章之前我们先约定如下的标记,A ≼ B
意味着A
是B
的子类型;A → B
指的是以A
为参数类型,以B
为返回值类型的函数类型;x : A
意味着x
的类型为A
。假设我有如下三种类型:Greyhound ≼ Dog ≼ Animal
。
Greyhound
灰狗是Dog
狗的子类型,而Dog
则是Animal
动物的子类型,由于子类型通常是可传递的,因此我们也称Greyhound
是Animal
的子类型,问题: 以下哪种类型是Dog → Dog
的子类型呢。
Greyhound → Greyhound。Greyhound → Animal。Animal → Animal。Animal → Greyhound。
让我们来思考一下如何解答这个问题,首先我们假设f
是一个以Dog → Dog
为参数的函数,它的返回值并不重要,为了具体描述问题,我们假设函数结构体是这样的f :(Dog → Dog ) → String
,现在我想给函数f传入某个函数g来调用,我们来瞧瞧当g为以上四种类型时,会发生什么情况。
1.我们假设g : Greyhound → Greyhound,f(g)的类型是否安全?
不安全,因为在f内调用它的参数(g)函数时,使用的参数可能是一个不同于灰狗但又是狗的子类型,例如GermanShepherd牧羊犬。
2.我们假设g : Greyhound → Animal,f(g)的类型是否安全?
不安全。理由同1。
3.我们假设g : Animal → Animal,f(g)的类型是否安全?
不安全。因为f有可能在调用完参数之后,让返回值也就是Animal动物狗叫,并非所有动物都会狗叫。
4.我们假设g : Animal → Greyhound,f(g)的类型是否安全?
是的,它的类型是安全的,首先f可能会以任何狗的品种来作为参数调用,而所有的狗都是动物,其次,它可能会假设结果是一条狗,而所有的灰狗都是狗。如上所述,我们得出结论(Animal → Greyhound) ≼ (Dog → Dog)返回值类型很容易理解,灰狗是狗的子类型,但参数类型则是相反的,动物是狗的父类。用合适的术语来描述这个奇怪的表现,可以说我们允许一个函数类型中,返回值类型是协变的,而参数类型是逆变的。返回值类型是协变的,意思是A ≼ B就意味着(T → A ) ≼ ( T → B ),参数类型是逆变的,意思是A ≼ B就意味着(B → T ) ≼ ( A → T )即A和B的位置颠倒过来了。一个有趣的现象是在TypeScript中,参数类型是双向协变的,也就是说既是协变又是逆变的,而这并不安全,但是现在你可以在TypeScript 2.6版本中通过strictFunctionTypes或strict标记来修复这个问题。