最近使用 ts 时大部分精力都放在了怎么写完整的类型上,反而基础知识框架有些遗忘现象,导致项目搭建时遇到了一些问题,写这篇文章来重新梳理下我的 typescript 知识框架,也是通过实践来巩固理论知识。
目标:
- 逆变(contra-variant)和协变(co-variant)
- 联合分配率
-
extends
Excluede
infer
联合类型 用法及用例 -
satisfies
Operator -
namespace
declare
module
模块化 - 类型体操
知识结构
基础(The Basics)
静态类型检查
const message = 'hello!';
message();
// Error: 'message' is not a function
非异常故障
const user = {
name: 'Daniel',
age: 26,
};
user.location;
// Error: Property 'location' does not exist on type '{ name: string; age: number; }'
不做重点展开,详见文档
…
Downleveling 降级
typescript 会将高版本的代码降级为低版本的代码,比如 es6 降级为 es5。
基础类型
原始类型(primitives):stirng
, number
, boolean
String
, Number
, Boolean
也是合法的语法,但是引用了一些很少出现在代码中的特殊内置类型。始终使用 string
,number
,boolean
就可以。
以 String
和 string
为例:
string
是原始类型,而 String
是非原始类型装箱对象
const Str: String = '';
const newStr: string = Str;
// Type 'String' is not assignable to type 'string'.
// 'string' is a primitive, but 'String' is a wrapper object. Prefer using 'string' when possible.(2322)
/* WRONG */
function reverse(str: String): String;
/* OK */
function reverse(s: string): string;
类型别名和接口(type and interface)
从一个例子开始:
function greet(person: { name: string; age: number }) {
return 'Hello ' + person.name;
}
这个函数中的参数 person
的类型是一个匿名类型,我们可以使用 type
或者 interface
给这个类型起一个名字
type Person = { name: string; age: number };
// or
interface Person {
name: string;
age: number;
}
function greet(person: Person) {
return 'Hello ' + person.name;
}
type
和 interface
有很多相似之处,但是也有一些不同:
type
可以是任何类型的别名,而interface
只能命名对象类型
type Point = { x: number; y: number };
type ID = string | number;
type Name = string;
type Greet = (name: string) => string;
interface
可以扩展,而type
不行
interface Person {
name: string;
}
interface Person {
age: number;
}
type Person = {
name: string;
};
type Person = {
age: number;
};
// Duplicate identifier 'Person'.(2300)
-
interface
合并时会创建一个扁平的object
类型来检测属性冲突,而type
只会进行简单的递归合并,往往会产生never
类型 -
对于编译器来说,使用带有扩展的接口通常比带有交集的类型别名具有更高的性能
-
interface
始终以原始形式出现在错误信息中(以名字的形式使用时),在 4.2 版本之前type
可能会出现在错误消息中,有时也会以等效的匿名类型替换
综上,大部分情况下使用 interface
即可直到需要使用 type
的特性时再使用 type
此外 typescript 只关心类型结构,不关心名称。
strictNullChecks 严格空检查
开启后访问可能为空的的属性时会报错,有利于减少运行时错误
typescript 在某些情况下,当确定访问属性不为空时可以使用 !
断言
类型缩小(Narrowing)
typescript 的类型分析叠加在 javascript 的运行时控制流结构之上,可以通过控制流语句来缩小类型范围。
typeof
类型守卫
常见的类型守卫有 typeof
, instanceof
, in
, ==
, ===
, ’&&’, ’||’, ’?:’ 等
大部分情况下 typeof
都可以按照正常的预期的来缩小类型范围
但是,需要注意的是在 javascript 中有一个历史遗留的问题 typeof null === 'object'
,好在 typescript 也对这种情况进行了兼容依然可以正确的识别出可能为 null
的情况
function printAll(strs: string[] | null) {
if (typeof strs === 'object') {
for (const s of strs) {
// Error: strs is possibly 'null'.
console.log(s);
}
} else if (typeof strs === 'string') {
console.log(strs);
} else {
const _: never = strs; // 只有 never 类型可以赋值给 never 类型
}
}
类型谓词 (predicates)
类型守卫可以完成大部分的类型缩小,但是有时候我们需要更直接的控制类型的缩小,这时候就需要使用类型谓词
function isFish(pet: Fish | Brid): pet is Fish {
return (pet as Fish).swim !== undefined;
}
const pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
this
类型守卫
当类的方法返回 this
类型时,所有继承该类的子类的实例方法都会返回 this
,而接受 this
类型只能接受他所指向的类的实例,而不能接受其他类的实例包括父类
class Box {
content: string = '';
sameAs(other: this) {
return other.content === this.content;
}
}
class DerivedBox extends Box {
otherContent: string = '?';
}
const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
// Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
// Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.
同样 this
作为方法中隐含的参数也可以用作类型谓词
class FileSystemObject {
isFile(): this is FileRep {
return this instanceof FileRep;
}
isDirectory(): this is Directory {
return this instanceof Directory;
}
isNetworked(): this is Networked & this {
return this.networked;
}
constructor(
public path: string,
private networked: boolean,
) {}
}
class FileRep extends FileSystemObject {
constructor(
path: string,
public content: string,
) {
super(path, false);
}
}
class Directory extends FileSystemObject {
children: FileSystemObject[];
}
interface Networked {
host: string;
}
const fso: FileSystemObject = new FileRep('foo/bar.txt', 'foo');
if (fso.isFile()) {
fso.content;
const fso: FileRep;
} else if (fso.isDirectory()) {
fso.children;
const fso: Directory;
} else if (fso.isNetworked()) {
fso.host;
const fso: Networked & FileSystemObject;
}
区分联合(Discriminated unions)
当联合类型中的每个类型都包含相同的具有字面量类型的属性时,typescript 会认为这个联合是可区分的,并且可以缩小联合的成员范围
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
}
}
详尽性检查
可以利用只有 never 类型能给分配给 never 类型的特性,确保 switch 语句的完整性
当我们扩充联合类型时,typescript 会给出错误提示
interface Triangle {
kind: 'triangle';
sideLength: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
default:
const _: never = shape; // Type 'Triangle' is not assignable to type 'never'.
return _;
}
}
函数
调用签名(Call Signatures)
在 javascript 中,函数除了可以调用外还可以具有属性,带有属性的类型签名写法
type DescribableFunction = {
description: string;
(someArg: number): boolean;
};
function doSomething(fn: DescribableFunction) {
console.log(fn.description + ' returned ' + fn(6));
}
function myFunc(someArg: number) {
return someArg > 0;
}
myFunc.description = 'takes a number and returns a boolean';
doSomething(myFunc);
构造函数签名(Construct Signatures)
使用 new
关键字调用的函数称为构造函数,构造函数签名的写法
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
类型约束(Constraints)
typescript 中的泛型可以约束类型,比如约束一个泛型类型必须具有 length
属性
function longest<Type extends { length: number }>(a: Type, b: Type) {
if (a.length >= b.length) {
return a;
} else {
return b;
}
}
const longerArray = longest([1, 2], [1, 2, 3]);
const longerString = longest('alice', 'bob');
const notOK = longest(10, 100); // Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
编写良好的泛型
类型参数(泛型)
泛型实际上是一种类型参数,通常是用来关联多个值的,当一个泛型没有关联多个值时它通常是不必要的
function greet<Str extends string>(s: Str) {
console.log('Hello, ' + s);
}
这里创建的泛型 Str
仅在参数中出现了一次,没有关联多个值,所以是不必要存在的
function greet(s: string) {
console.log('Hello, ' + s);
}
下推类型(Push Type Parameters Down)
类型参数(泛型)应该用在更具体的位置,而不是在更高的层次上过度泛化
function firstElement1<Type>(arr: Type[]) {
return arr[0];
}
function firstElement2<Type extends any[]>(arr: Type[]) {
return arr[0];
}
// a:number (good)
const a = firstElement1([1, 2, 3]);
// b:any (bad)
const b = firstElement2([1, 2, 3]);
firstElement2
中的类型约束 Type extends any[]
使得 typescript 必须使用约束类型解析返回值,而不是等待元素调用时再解析。
函数重载(Function Overloads)
在 Typescript 中,可以通过重载签名来指定可以以不同方式调用的函数,函数重载类型定义由两部分组成 重载签名(Overload Signatures)
和 实现签名(Implementation Signature)
实现签名是不能被直接调用的,例如:
function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
if (d !== undefined && y !== undefined) {
return new Date(y, mOrTimestamp, d);
} else {
return new Date(mOrTimestamp);
}
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3); // No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.(2575)
这里的 d3
的调用方式虽然符合实现签名的类型定义,但依然会得到错误。
编写好的函数重载
优先考虑联合类型能否满足需求,不能时再考虑函数重载
function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
return x.length;
}
len(Math.random() > 0.5 ? 'hello' : [0, 1, 2]);
// No overload matches this call.
// Overload 1 of 2, '(s: string): number', gave the following error.
// Argument of type '"hello" | number[]' is not assignable to parameter of type 'string'.
// Type 'number[]' is not assignable to type 'string'.
// Overload 2 of 2, '(arr: any[]): number', gave the following error.
// Argument of type '"hello" | number[]' is not assignable to parameter of type 'any[]'.
// Type 'string' is not assignable to type 'any[]'.(2769)
len
函数的多个重载具有相同的参数数量和相同的返回类型,所以可以使用联合类型来替代
function len(s: string | any[]): number {
return s.length;
}
声明 this
javascript 不允许函数使用 this
作为函数的参数名,因此 typescript 利用了这个语法使用 this
参数来声明函数体中 this
的类型,编译后会移除 this
参数,this
在 typescript 中必须是第一个参数
function fancyDate(this: Date) {
return `${this.getDate()}/${this.getMonth()}/${this.getFullYear()}`;
}
需要注意箭头函数的特殊性
interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}
const db = getDB();
// bad case
// @ts-expect-error The containing arrow function captures the global value of 'this'.
const admins = db.filterUsers(() => this.admin);
// good case
const admins = db.filterUsers(function (this: User) {
return this.admin;
});
函数的可分配性
函数返回 void
类型,理解为函数可以返回任意类型,但会被忽略
这使得以下这种情况是合法的
const src = [1, 2, 3];
const dst = [0];
src.forEach(el => dst.push(el));
虽然 push
会返回一个 number
类型,但是会被忽略
需要额外注意的是,字面量函数声明的返回类型为 void
时,函数必须不返回任何值,否则会报错
function foo(): void {
// @ts-expect-error
return true;
}
对象类型 object
修饰符
可选属性
interface PaintOptions {
shape: Shape;
xPos?: number;
yPos?: number;
}
只读属性
interface SomeType {
readonly prop: string;
}
映射修饰符(mapping modifiers) 可以删除或者添加修饰符,省略时默认为添加
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
type Readonly<T> = {
+readonly [P in keyof T]: T[P]; // 这里的 + 可以省略
};
type Required<T> = {
[P in keyof T]-?: T[P];
};
type Partial<T> = {
[P in keyof T]+?: T[P];
};
索引类型
可以将索引签名设置为只读,来防止添加新属性
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ['Alice', 'Bob'];
// @ts-expect-error Index signature in type 'ReadonlyStringArray' only permits reading.
myArray[2] = 'Mallory';
多余属性检查
typescript 会对对象字面量中的属性进行检查,检查对象字面量中是否包含了接口中不存在的属性,如果包含则会报错
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
return {
color: config.color || 'red',
area: config.width ? config.width * config.width : 20,
};
}
// @ts-expect-error Property 'colour' does not exist on type '{ color: string; area: number; }'.(2339)
let mySquare = createSquare({ colour: 'red', width: 100 });
绕过这个检查的方法最简单的是使用类型断言 as
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);
还有一种绕过检查的方式,将对象分配给一个变量,由于分配的变量不会进行多余属性检查,所以不会报错
let squareOptions = { colour: 'red', width: 100 } as SquareConfig;
let mySquare = createSquare(squareOptions);
如果没有任何公共对象属性,这也会报错
let squareOptions = { colour: 'red' };
// @ts-expect-error Type '{ colour: string; }' has no properties in common with type 'SquareConfig'.
let mySquare = createSquare(squareOptions);
这些绕过检查的动作都是不推荐的,应该尽可能通过修改相关类型的声明来修复这个错误。
类型操作
泛型(类型参数)
function identity<Type>(arg: Type): Type {
return arg;
}
typscript 可以根据参数推断出泛型参数的具体类型
const output /* string */ = identity('myString');
泛型约束
可以使用 extends
关键字来约束泛型的实现
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length);
return arg;
}
loggingIdentity([1, 2, 3]);
在类型约束中使用泛型(类型参数)
可以将一个泛型作(类型参数)为另一个泛型(类型参数)的约束
下面这个例子从对象中获取一个属性,通过在两个泛型之间建立约束关系,可以确保传入的 key
参数名存在于对象中
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
类型约束也可用于约束传入泛型的具体实现
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // 通过约束确保了 arg 有 length 属性
return arg;
}
loggingIdentity([1, 2, 3]);
// @ts-expect-error Argument of type 'number[]' is not assignable to parameter of type 'Lengthwise'.
loggingIdentity(3);
在泛型中使用 Class
当我们在 TypeScript 中创建一个工厂函数时,我们可以通过类的构造函数来获取类的类型,这样我们就可以不必关心传入类的具体类型
function crate<Type>(c: { new (): Type }): Type {
return new c();
}
条件类型
条件类型有助于描述输入和输出类型之间的关系
在 typescript 中,条件类型的形式为 type Result = Type1 extends Type2 ? TrueType : FalseType
和 js 中的三元运算符相似,如果 Type1
能够分配给 Type2
时,会返回 TrueType
类型,否则会返回 FalseType
interface Animal {
live(): void;
}
interface Dog extends Animal {
woof(): void;
}
// number
type Example1 = Dog extends Animal ? number : string;
// string
type Example2 = RegExp extends Animal ? number : string;
逆变(contra-variant)和协变(co-variant)
1.为什么需要逆变和协变?
typescript 的类型系统支持子类型化(subtyping),子类型化指一个类型可以被另一个类型所替代,而不改变程序的正确性。
比如 Dog
是 Animal
的子类型,所以所有可以传入 Animal
类型的地方都可以传入 Dog
类型,而不改变程序的正确性。
interface Animal {
name: string;
}
interface Dog extends Animal {
woof(): void;
}
function printAnimal(animal: Animal) {
console.log(animal.name);
}
printAnimal(new Dog());
如果类型是不可变的,那么 Animal
和 Dog
是两个完全不同的类型,上面代码中 printAnimal
函数只能接受 Animal
类型的参数,而不能接受 Dog
类型的参数。
这显然是不符合直觉的,且会使编程变得更加复杂,所以 typescript 类型系统是可变的,然而实际使用中的类型往往会比我们这里的例子要复杂的多,一个类型经常会有多个部分构成
比如,() => Animal
这个类型,它由两部分构成,() => T
和 Animal
,() => T
表示这是一个函数类型,Animal
表示这个函数返回 Animal
类型
或者 Animal[]
这个类型,它由 Animal
和 T[]
两部分构成,T[]
表示这是一个数组类型,Animal
表示这个数组中的元素是 Animal
类型
上面例子中的
T
表示一个类型参数(即泛型),而Animal
则是填补到这个参数T
中的具体类型
为了描述这种[复杂类型]的子类型化 与[构成它们的类型]的子类型化之间的关系,引入了协变和逆变的概念
举例来说,类型
() => Animal
子类型化为() => Dog
与它的构成部分Animal
子类型化为Dog
之间的关系,被称为协变
2.什么是协变,什么是逆变?
协变:指的是,如果类型 B 是类型 A 的子类型(比如 Dog 是 Animal 的子类型),那么 T<B> 应该也是 T<A> 的子类型。换句话说,协变允许我们在上下文中替换为更具体的类型。
逆变:指的是,如果类型 B 是类型 A 的子类型(比如 Dog 是 Animal 的子类型),那么 T<A> 应该也是 T<B> 的子类型。换句话说,逆变允许我们在上下文中替换为更泛型的类型。
从实际的例子分析,假设我们有两个类型,以及两个工厂函数:
class Animal {
public name: string = '';
}
class Dog extends Animal {
public woof() {
console.log('woof');
}
}
function makeAnimal(): Animal {
return new Animal();
}
function makeDog(): Dog {
return new Dog();
}
假设我们有一个函数,接受工厂函数作为参数,并使用这个工厂函数创建一个动物并返回动物的名字
function createAnimal(factory: () => Animal): string {
return factory().name;
}
createAnimal(makeAnimal);
createAnimal(makeDog); // 合法类型
虽然这里接受的参数是返回 Animal
类型的工厂函数,但是我们依然可以传入 makeDog
这是因为函数返回值的类型在子类型化时遵循协变的规则,即与构成它的类型具有相同的子类型化关系
再看一个逆变的例子,假设我们有一个 forEach 函数,接受一个回调函数作为参数,并且会将动物列表中的每个实例作为参数传递给回调函数
function forEachAnimals(callback: (animal: Animal) => void) {
const animals: Animal[] = [new Animal(), new Animal(), new Dog()];
for (const animal of animals) {
callback(animal);
}
}
forEachAnimals((animal: Animal) => {
console.log(animal.name);
});
// @ts-expect-error Argument of type '(dogs: Dog) => void' is not assignable to parameter of type '(animal: Animal) => void'.
// Types of parameters 'dogs' and 'animal' are incompatible.
// Property 'woof' is missing in type 'Animal' but required in type 'Dog'.(2345)
forEachAnimals((dogs: Dog) => {
dogs.woof();
});
这里的我们给 forEachAnimals
函数传递了一个 (dog: Dog) => void
类型的回调函数,结果导致了类型错误,就是因为函数参数的类型在子类型化时遵循逆变的规则,即与构成它的类型具有相反的子类型化关系
将两个例子简化放到一起对比
// 第一个例子中
type A = () => Animal;
type B = () => Dog;
let a: A = () => new Animal();
let b: B = () => new Dog();
a = b; // 合法类型,此处类型中的 Dog 可以替换 Animal,因为函数返回值的类型在**子类型化**时遵循**协变**的规则
// 第二个例子中
type C = (animal: Animal) => void;
type D = (dog: Dog) => void;
let c: C = (animal: Animal) => {};
let d: D = (dog: Dog) => {};
// @ts-expect-error 类型“D”不能赋值给类型“C”。
c = d; // 不合法,此处类型中的 Dog 不能替换 Animal,因为函数参数的类型在**子类型化**时遵循**逆变**的规则
3.应用
联合类型转交叉类型
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer P,
) => void
? P
: never;
type T0 = UnionToIntersection<{ a: number } | { b: number }>; // { a: number } & { b: number }
逐段解析这个类型的变化过程
第一部分 U extends any ? (k: U) => void : never
这里的条件类型必然成立,所以会返回 (k: U) => void
类型,而这个返回的类型将 U
放到了函数参数的位置
再来看传入的参数 U
,这里 U
是一个联合类型,所以会遍历 U
中的每一项,将每一项作为参数类型传入到 (k: U) => void
中获得最新的类型,并重新组成一个新的联合类型
于是得到 (k: { a: number }) => void | (k: { b: number }) => void
类型
将前面的结果带入第二部分
(((k: { a: number }) => void) | ((k: { b: number }) => void)) extends (k: infer P) => void ? P : never
类型
同样联合类型会遍历每个组成项,并重新组成一个新的联合类型
这里需要注意的一个点是同一类型变量的多个候选类型会被推断为交集类型
利用这点,P
的类型最终会被推断为 { a: number } & { b: number }
于是完成了转换
下面这个例子展示了在协变位置上,同一类型变量的多个候选类型会被推断为联合类型:
type Foo<T> = T extends { a: infer U; b: infer U } ? U : never; type T10 = Foo<{ a: string; b: string }>; // string type T11 = Foo<{ a: string; b: number }>; // string | number
同样的,逆变位置中同一类型变量的多个候选类型会被推断为交集类型:
type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never; type T20 = Bar<{ a: (x: string) => void; b: (x: string) => void }>; // string type T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; // string & number
模块化
当一个文件中包含 import
或 export
时,typescript 会将文件解析为模块,其中定义的类型都只能在模块作用域内访问
相反,不包含 import
和 export
时会解析为全局类型,其内部类型可被直接访问
如果我们想要在一个模块文件中定义全局作用域的类型则需要通过 declare global
的形式扩大类型的作用域
// types.ts
declare global {
// 扩大为全局作用域
interface SomeInterface {
someValue: string;
}
}
export {}; // 使文件被解析为模块
等价于
// types.ts
interface SomeInterface {
someValue: string;
}
注:不管是哪种方式定义的类型文件,都需要被引入后才会被 typescript 解析
TODO: typeAcquisition
配置
参考
Distributive conditional types
Type inference in conditional types #21496