TypeScript 类型运算符

TypeScript 提供强大的类型运算能力,可以使用各种类型运算符,对已有的类型进行计算,得到新类型。

keyof 运算符

简介

keyof 是一个单目运算符,接受一个对象类型作为参数,返回该对象的所有键名组成的联合类型。

  1. type MyObj = {
  2. foo: number,
  3. bar: string,
  4. };
  5. type Keys = keyof MyObj; // 'foo'|'bar'

上面示例中,keyof MyObj返回MyObj的所有键名组成的联合类型,即'foo'|'bar'

下面是另一个例子。

  1. interface T {
  2. 0: boolean;
  3. a: string;
  4. b(): void;
  5. }
  6. type KeyT = keyof T; // 0 | 'a' | 'b'

由于 JavaScript 对象的键名只有三种类型,所以对于任意对象的键名的联合类型就是string|number|symbol

  1. // string | number | symbol
  2. type KeyT = keyof any;

对于没有自定义键名的类型使用 keyof 运算符,返回never类型,表示不可能有这样类型的键名。

  1. type KeyT = keyof object; // never

上面示例中,由于object类型没有自身的属性,也就没有键名,所以keyof object返回never类型。

由于 keyof 返回的类型是string|number|symbol,如果有些场合只需要其中的一种类型,那么可以采用交叉类型的写法。

  1. type Capital<T extends string> = Capitalize<T>;
  2. type MyKeys<Obj extends object> = Capital<keyof Obj>; // 报错

上面示例中,类型Capital只接受字符串作为类型参数,传入keyof Obj会报错,原因是这时的类型参数是string|number|symbol,跟字符串不兼容。采用下面的交叉类型写法,就不会报错。

  1. type MyKeys<Obj extends object> = Capital<string & keyof Obj>;

上面示例中,string & keyof Obj等同于string & string|number|symbol进行交集运算,最后返回string,因此Capital<T extends string>就不会报错了。

如果对象属性名采用索引形式,keyof 会返回属性名的索引类型。

  1. // 示例一
  2. interface T {
  3. [prop: number]: number;
  4. }
  5. // number
  6. type KeyT = keyof T;
  7. // 示例二
  8. interface T {
  9. [prop: string]: number;
  10. }
  11. // string|number
  12. type KeyT = keyof T;

上面的示例二,keyof T返回的类型是string|number,原因是 JavaScript 属性名为字符串时,包含了属性名为数值的情况,因为数值属性名会自动转为字符串。

如果 keyof 运算符用于数组或元组类型,得到的结果可能出人意料。

  1. type Result = keyof ['a', 'b', 'c'];
  2. // 返回 number | "0" | "1" | "2"
  3. // | "length" | "pop" | "push" | ···

上面示例中,keyof 会返回数组的所有键名,包括数字键名和继承的键名。

对于联合类型,keyof 返回成员共有的键名。

  1. type A = { a: string; z: boolean };
  2. type B = { b: string; z: boolean };
  3. // 返回 'z'
  4. type KeyT = keyof (A | B);

对于交叉类型,keyof 返回所有键名。

  1. type A = { a: string; x: boolean };
  2. type B = { b: string; y: number };
  3. // 返回 'a' | 'x' | 'b' | 'y'
  4. type KeyT = keyof (A & B);
  5. // 相当于
  6. keyof (A & B) keyof A | keyof B

keyof 取出的是键名组成的联合类型,如果想取出键值组成的联合类型,可以像下面这样写。

  1. type MyObj = {
  2. foo: number,
  3. bar: string,
  4. };
  5. type Keys = keyof MyObj;
  6. type Values = MyObj[Keys]; // number|string

上面示例中,Keys是键名组成的联合类型,而MyObj[Keys]会取出每个键名对应的键值类型,组成一个新的联合类型,即number|string

keyof 运算符的用途

keyof 运算符往往用于精确表达对象的属性类型。

举例来说,取出对象的某个指定属性的值,JavaScript 版本可以写成下面这样。

  1. function prop(obj, key) {
  2. return obj[key];
  3. }

上面这个函数添加类型,只能写成下面这样。

  1. function prop(
  2. obj:object, key:string
  3. ):any {
  4. return obj[key];
  5. }

上面的类型声明有两个问题,一是无法表示参数key与参数obj之间的关系,二是返回值类型只能写成any

有了 keyof 以后,就可以解决这两个问题,精确表达返回值类型。

  1. function prop<Obj, K extends keyof Obj>(
  2. obj:Obj, key:K
  3. ):Obj[K] {
  4. return obj[key];
  5. }

上面示例中,K extends keyof Obj表示KObj的一个属性名,传入其他字符串会报错。返回值类型Obj[K]就表示K这个属性值的类型。

keyof 的另一个用途是用于属性映射,即将一个类型的所有属性逐一映射成其他值。

  1. type NewProps<Obj> = {
  2. [Prop in keyof Obj]: boolean;
  3. };
  4. // 用法
  5. type MyObj = { foo: number; };
  6. // 等于 { foo: boolean; }
  7. type NewObj = NewProps<MyObj>;

上面示例中,类型NewProps是类型Obj的映射类型,前者继承了后者的所有属性,但是把所有属性值类型都改成了boolean

下面的例子是去掉 readonly 修饰符。

  1. type Mutable<Obj> = {
  2. -readonly [Prop in keyof Obj]: Obj[Prop];
  3. };
  4. // 用法
  5. type MyObj = {
  6. readonly foo: number;
  7. }
  8. // 等于 { foo: number; }
  9. type NewObj = Mutable<MyObj>;

上面示例中,[Prop in keyof Obj]Obj类型的所有属性名,-readonly表示去除这些属性的只读特性。对应地,还有+readonly的写法,表示添加只读属性设置。

下面的例子是让可选属性变成必有的属性。

  1. type Concrete<Obj> = {
  2. [Prop in keyof Obj]-?: Obj[Prop];
  3. };
  4. // 用法
  5. type MyObj = {
  6. foo?: number;
  7. }
  8. // 等于 { foo: number; }
  9. type NewObj = Concrete<MyObj>;

上面示例中,[Prop in keyof Obj]后面的-?表示去除可选属性设置。对应地,还有+?的写法,表示添加可选属性设置。

in 运算符

JavaScript 语言中,in运算符用来确定对象是否包含某个属性名。

  1. const obj = { a: 123 };
  2. if ('a' in obj)
  3. console.log('found a');

上面示例中,in运算符用来判断对象obj是否包含属性a

in运算符的左侧是一个字符串,表示属性名,右侧是一个对象。它的返回值是一个布尔值。

TypeScript 语言的类型运算中,in运算符有不同的用法,用来取出(遍历)联合类型的每一个成员类型。

  1. type U = 'a'|'b'|'c';
  2. type Foo = {
  3. [Prop in U]: number;
  4. };
  5. // 等同于
  6. type Foo = {
  7. a: number,
  8. b: number,
  9. c: number
  10. };

上面示例中,[Prop in U]表示依次取出联合类型U的每一个成员。

上一小节的例子也提到,[Prop in keyof Obj]表示取出对象Obj的每一个键名。

方括号运算符

方括号运算符([])用于取出对象的键值类型,比如T[K]会返回对象T的属性K的类型。

  1. type Person = {
  2. age: number;
  3. name: string;
  4. alive: boolean;
  5. };
  6. // Age 的类型是 number
  7. type Age = Person['age'];

上面示例中,Person['age']返回属性age的类型,本例是number

方括号的参数如果是联合类型,那么返回的也是联合类型。

  1. type Person = {
  2. age: number;
  3. name: string;
  4. alive: boolean;
  5. };
  6. // number|string
  7. type T = Person['age'|'name'];
  8. // number|string|boolean
  9. type A = Person[keyof Obj];

上面示例中,方括号里面是属性名的联合类型,所以返回的也是对应的属性值的联合类型。

如果访问不存在的属性,会报错。

  1. type T = Person['notExisted']; // 报错

方括号运算符的参数也可以是属性名的索引类型。

  1. type Obj = {
  2. [key:string]: number,
  3. };
  4. // number
  5. type T = Obj[string];

上面示例中,Obj的属性名是字符串的索引类型,所以可以写成Obj[string],代表所有字符串属性名,返回的就是它们的类型number

这个语法对于数组也适用,可以使用number作为方括号的参数。

  1. // MyArray 的类型是 { [key:number]:string }
  2. const MyArray = ['a','b','c'];
  3. // 等同于 (typeof MyArray)[number]
  4. // 返回 string
  5. type Person = typeof MyArray[number];

上面示例中,MyArray是一个数组,它的类型实际上是属性名的数值索引,而typeof MyArray[number]typeof运算优先级高于方括号,所以返回的是所有数值键名的键值类型string

注意,方括号里面不能有值的运算。

  1. // 示例一
  2. const key = 'age';
  3. type Age = Person[key]; // 报错
  4. // 示例二
  5. type Age = Person['a' + 'g' + 'e']; // 报错

上面两个示例,方括号里面都涉及值的运算,编译时不会进行这种运算,所以会报错。

extends…?: 条件运算符

TypeScript 提供类似 JavaScript 的?:运算符这样的三元运算符,但多出了一个extends关键字。

条件运算符extends...?:可以根据当前类型是否符合某种条件,返回不同的类型。

  1. T extends U ? X : Y

上面式子中的extends用来判断,类型T是否可以赋值给类型U,即T是否为U的子类型,这里的TU可以是任意类型。

如果T能够赋值给类型U,表达式的结果为类型X,否则结果为类型Y

  1. // true
  2. type T = 1 extends number ? true : false;

上面示例中,1number的子类型,所以返回true

下面是另外一个例子。

  1. interface Animal {
  2. live(): void;
  3. }
  4. interface Dog extends Animal {
  5. woof(): void;
  6. }
  7. // number
  8. type T1 = Dog extends Animal ? number : string;
  9. // string
  10. type T2 = RegExp extends Animal ? number : string;

上面示例中,DogAnimal的子类型,所以T1的类型是numberRegExp不是Animal的子类型,所以T2的类型是string

一般来说,调换extends两侧类型,会返回相反的结果。举例来说,有两个类DogAnimal,前者是后者的子类型,那么Cat extends Animal就为真,而Animal extends Cat就为伪。

如果需要判断的类型是一个联合类型,那么条件运算符会展开这个联合类型。

  1. (A|B) extends U ? X : Y
  2. // 等同于
  3. (A extends U ? X : Y) |
  4. (B extends U ? X : Y)

上面示例中,A|B是一个联合类型,进行条件运算时,相当于AB分别进行运算符,返回结果组成一个联合类型。

如果不希望联合类型被条件运算符展开,可以把extends两侧的操作数都放在方括号里面。

  1. // 示例一
  2. type ToArray<Type> =
  3. Type extends any ? Type[] : never;
  4. // string[]|number[]
  5. type T = ToArray<string|number>;
  6. // 示例二
  7. type ToArray<Type> =
  8. [Type] extends [any] ? Type[] : never;
  9. // (string | number)[]
  10. type T = ToArray<string|number>;

上面的示例一,传入ToArray<Type>的类型参数是一个联合类型,所以会被展开,返回的也是联合类型。示例二是extends两侧的运算数都放在方括号里面,所以传入的联合类型不会展开,返回的是一个数组。

条件运算符还可以嵌套使用。

  1. type LiteralTypeName<T> =
  2. T extends undefined ? "undefined" :
  3. T extends null ? "null" :
  4. T extends boolean ? "boolean" :
  5. T extends number ? "number" :
  6. T extends bigint ? "bigint" :
  7. T extends string ? "string" :
  8. never;

上面示例是一个多重判断,返回一个字符串的值类型,对应当前类型。下面是它的用法。

  1. // "bigint"
  2. type Result1 = LiteralTypeName<123n>;
  3. // "string" | "number" | "boolean"
  4. type Result2 = LiteralTypeName<true | 1 | 'a'>;

infer 关键字

infer关键字用来定义泛型里面推断出来的类型参数,而不是外部传入的类型参数。

它通常跟条件运算符一起使用,用在extends关键字后面的父类型之中。

  1. type Flatten<Type> =
  2. Type extends Array<infer Item> ? Item : Type;

上面示例中,Type是外部传入的类型参数,如果它是数组Array<T>的子类型,那么就将类型变量Item推断为T,即Item代表数组的成员类型,写成infer Item,表示Item这个类型参数是从当前信息中推断出来的。

一旦定义了Item,后面的代码就可以使用这个类型参数了。

下面是上例的泛型Flatten<Type>的用法。

  1. // string
  2. type Str = Flatten<string[]>;
  3. // number
  4. type Num = Flatten<number>;

上面示例中,第一个例子Flatten<string[]>传入的类型参数是string[],可以推断出Item的类型是string,所以返回的是string。第二个例子Flatten<number>传入的类型参数是number,它不是数组的子类型,所以直接返回自身。

如果不用infer定义类型参数,那么就要传入两个类型参数。

  1. type Flatten<Type, Item> =
  2. Type extends Array<Item> ? Item : Type;

上面是不用infer的写法,每次使用Fleatten的时候,都要传入两个参数,就比较麻烦。

下面的例子使用infer,推断函数的参数类型和返回值类型。

  1. type ReturnPromise<T> =
  2. T extends (...args: infer A) => infer R
  3. ? (...args: A) => Promise<R>
  4. : T;

上面示例中,如果T是函数,就返回这个函数的 Promise 版本,否则原样返回。infer A表示该函数的参数类型为Ainfer R表示该函数的返回值类型为R

如果不使用infer,就不得不把ReturnPromise<T>写成ReturnPromise<T, A, R>,这样就很麻烦,相当于开发者必须人肉推断编译器可以完成的工作。

下面是infer提取对象指定属性的例子。

  1. type MyType<T> =
  2. T extends {
  3. a: infer M,
  4. b: infer N
  5. } ? [M, N] : never;
  6. // 用法示例
  7. type T = MyType<{ a: string; b: number }>;
  8. // [string, number]

上面示例中,infer提取了参数对象的属性a和属性b的类型。

下面是infer通过正则匹配提取类型参数的例子。

  1. type Str = 'foo-bar';
  2. type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar'

上面示例中,rest是从模板字符串提取的类型参数。

is 运算符

函数返回布尔值的时候,可以使用is运算符,限定返回值与参数之间的关系。

is运算符用来描述返回值属于true还是false

  1. function isFish(
  2. pet: Fish|Bird
  3. ):pet is Fish {
  4. return (pet as Fish).swim !== undefined;
  5. }

上面示例中,函数isFish()的返回值类型为pet is Fish,表示如果参数pet类型为Fish,则返回true,否则返回false

is运算符总是用于描述函数的返回值类型,写法采用parameterName is Type的形式,即左侧为当前函数的参数名,右侧为某一种类型。它返回一个布尔值,表示左侧参数是否属于右侧的类型。

  1. type A = { a: string };
  2. type B = { b: string };
  3. function isTypeA(x: A|B): x is A {
  4. if ('a' in x) return true;
  5. return false;
  6. }

上面示例中,返回值类型x is A可以准确描述函数体内部的运算逻辑。

is运算符可以用于类型保护。

  1. function isCat(a:any): a is Cat {
  2. return a.name === 'kitty';
  3. }
  4. let x:Cat|Dog;
  5. if (isCat(x)) {
  6. x.meow(); // 正确,因为 x 肯定是 Cat 类型
  7. }

上面示例中,函数isCat()的返回类型是a is Cat,它是一个布尔值。后面的if语句就用这个返回值进行判断,从而起到类型保护的作用,确保x是 Cat 类型,从而x.meow()不会报错(假定Cat类型拥有meow()方法)。

is运算符还有一种特殊用法,就是用在类(class)的内部,描述类的方法的返回值。

  1. class Teacher {
  2. isStudent():this is Student {
  3. return false;
  4. }
  5. }
  6. class Student {
  7. isStudent():this is Student {
  8. return true;
  9. }
  10. }

上面示例中,isStudent()方法的返回值类型,取决于该方法内部的this是否为Student对象。如果是的,就返回布尔值true,否则返回false

注意,this is T这种写法,只能用来描述方法的返回值类型,而不能用来描述属性的类型。

模板字符串

TypeScript 允许使用模板字符串,构建类型。

模板字符串的最大特点,就是内部可以引用其他类型。

  1. type World = "world";
  2. // "hello world"
  3. type Greeting = `hello ${World}`;

上面示例中,类型Greeting是一个模板字符串,里面引用了另一个字符串类型world,因此Greeting实际上是字符串hello world

注意,模板字符串可以引用的类型一共6种,分别是 string、number、bigint、boolean、null、undefined。引用这6种以外的类型会报错。

  1. type Num = 123;
  2. type Obj = { n : 123 };
  3. type T1 = `${Num} received`; // 正确
  4. type T2 = `${Obj} received`; // 报错

上面示例中,模板字符串引用数值类型的别名Num是可以的,但是引用对象类型的别名Obj就会报错。

模板字符串里面引用的类型,如果是一个联合类型,那么它返回的也是一个联合类型,即模板字符串可以展开联合类型。

  1. type T = 'A'|'B';
  2. // "A_id"|"B_id"
  3. type U = `${T}_id`;

上面示例中,类型U是一个模板字符串,里面引用了一个联合类型T,导致最后得到的也是一个联合类型。

如果模板字符串引用两个联合类型,它会交叉展开这两个类型。

  1. type T = 'A'|'B';
  2. type U = '1'|'2';
  3. // 'A1'|'A2'|'B1'|'B2'
  4. type V = `${T}${U}`;

上面示例中,TU都是联合类型,各自有两个成员,模板字符串里面引用了这两个类型,最后得到的就是一个4个成员的联合类型。