typescript 类型推导是一个非常有趣的过程,你就像是在做一个推理游戏。在这个游戏里,主角就是你,是一个天才侦探,擅长推理;在分析掌握的类型信息后,通过对类型操作运算,得出最终的类型。
假设此时,你已经对typescript 的基础,比如:基本类型声明,泛型,类型别名,有一定的自己理解。如何没有,你可看一下typescript 打好基础,走起..。类型推导是一个演变的过程,类型运算让这个过程变得非常的灵活多变。在开始之前,请在记住一点: 对类型进行运算,为类型表达提供无尽可能
类型操作基础
在typescript 文档有提到2种类型:联合类型和交叉类型,也就是 Unions and Intersection Types。通过2个符号 |
,&
来实现。在类型推导中有一个非常重要的术语叫满足于
, 也就是类型推导成立,满足对类型约束的最低要求。在发生类型错误时,经常可以看到编译器提示:'类型X' is not assignable to type '类型Y',比如:'number' is not assignable to type 'string'。数字类型不可以赋值给字符串类型。
满足于
也好,可赋值给
也好,他们都是对类型推导是否成立的一个结果,相当于True or False
,没有什么实质上的区别,只是在类型推导过程,有时候使用其中一个比另个更加的自然,符合一些语言表达上的习惯。
联合/交叉 类型
联合/交叉 类型,实际上是比较简单的概念,我们来对比一下:
- 联合类型(unions type):满足其中一个对类型约束的条件即可
- 交叉类型:必须同是满足所有对类型约束的条件才可以
// 联合类型
type Odd = 1 | 3 | 5;
// 1 是 (1,3,5) 其中的一个,满足类型约束条件之一
const a: Odd = 1;
// 报类型错误:Type '2' is not assignable to type 'Odd'
// 因为2不满足任何的一个类型约束条件
const b: Odd = 2;
// 交叉类型
// 条件A:
interface A {
str: string;
}
// 条件B
interface B {
num: number;
}
// 同时满足条件AB
type AB = A & B;
/**
* 同时满足2个类型的约束条件
* 条件一:str 是字符串
* 条件二:num 是数字
*/
const Both: AB = {
str: "hello world",
num: 0,
};
|
, &
对类型操作,实际上是对约束条件或
,与
的关系处理。在业务开发中,可能用得更多的是对泛型类型参数约束,多个类型进行组合:
// 状态
type MediaType = "Text" | "Image" | "Video" | "Audio";
// 媒体的公有属性
// 类型参数 T 继承于 MediaType, 默认值 Video
// 也就是 T 要满足于对 MediaTyped 类型约束
interface Media<T extends MediaType = "Video"> {
// 标识符
id: string;
// 名称
name: string;
// 媒体类型
type: T;
}
type VideoMedia = Media & {
// 播放时长
duration: number;
};
const video: VideoMedia = {
id: 0,
name: "我是一个视频",
type: "Video",
duration: 120,
};
联合/交叉 类型, 不仅仅可以从定义某一个类型上理解,也可以从运算的角度去看,理解为一个类型运算符,像
加,减,乘,除
一样,是一个推导约束操作,会更加生动有趣。
运算符
typeScript 的类型系统非常强大,因为它允许根据其他类型来表达类型。通过类型运算符各种组合,我们可以以一种简洁,可维护的方式表达复杂的操作和值。
keyof 运算符
keyof 作用于一个对象类型(mapped types),并以它的键生成字符串或者数字组成的联合类型。
type Point = { x: number; y: number };
// 提取键 "x" | "y"
type Props = keyof Point;
type Mapish = { [k: string]: boolean };
/**
* 提取到的是 string | number;
* 因为在javascripte 中 obj[0] 会转化为 obj["0"]
*/
type Keys = keyof Mapish;
上面的索引签名涉及到类型兼容问题,有必要说明一下具体的原因。TypeScript支持两种索引签名:字符串和数字。 可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。 这是因为当使用 number来索引时,JavaScript会将它转换成string然后再去索引对象。 也就是说用 100(一个number)去索引等同于使用"100"(一个string)去索引,因此两者需要保持一致。
class Animal {
name: string;
}
class Dog extends Animal {
breed: string;
}
// 错误:使用数值型的字符串索引,有时会得到完全不同的Animal!
interface NotOkay {
[x: number]: Animal;
[x: string]: Dog;
}
typeof 运算符
TypeScript 添加了一个typeof运算符,您可以在类型上下文中使用它来引用变量或属性的类型。
- typeof 作用的对象是一个javascript 值,不可以是类型
- 对一个javascript 值,使用 typeof 会推导出值对应的类型。
let a = { id: 0, name: "hello world" };
// 推断出 变量 a 的类型是 { id: number;name: string;}
let b: typeof a = {
id: 0,
name: "",
};
interface A {
id: string;
}
// 报类型错误,typeof 不可以作用于一个 类型上
type C = typeof A;
索引访问
我们可以通过索引的方式,访问一个类型的属性
type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];
const MyArray = [
{ name: "Alice", age: 15 },
{ name: "Bob", age: 23 },
{ name: "Eve", age: 38 },
];
// 提取到 { name: string; age: number; }
type ListItem = typeof MyArray[number];
type AgeType = typeof MyArray[number]["age"];
const key = "name";
// 报类型错误 'key' refers to a value, but is being used as a type here
// key 必须是一个类型
type NameType = Person[key];
// 推导成功
type A = Person[typeof key];
object 对象类型
- in 运算符:
[Property in keyof Type]
把键限制在一个集合中,就是keyof 提取出来的联合类型 - 移除/添加修饰符:通过
-
或者+
, 表示移除/添加修饰符(主要是指:readonly, ?) - 重命名键名:对一个键名重命名, 4.1 以后的特性,要安装
JavaScript and TypeScript Nightly
- 对键类型运算:以键作为类型参数,进行类型运算
/// in 运算符
// [Property in keyof Type] 索引签名表示每一个键都是来自类型参数 Type
type OptionsFlags<Type> = {
[Property in keyof Type]: boolean;
};
type FeatureFlags = {
darkMode: () => void;
newUserProfile: () => void;
};
/**
* 运算结果: { darkMode: boolean; newUserProfile: boolean; };
*/
type FeatureOptions = OptionsFlags<FeatureFlags>;
// 同时移除 readonly, 可选 - 修饰符
type RemoveModifiers<Type> = {
-readonly [Property in keyof Type]-?: Type[Property];
};
type MaybeUser = {
readonly id?: string;
name?: string;
age?: number;
};
// 运算结果: { id: string; name: string; age: number; }
type MustUser = RemoveModifiers<MaybeUser>;
type RenameMapKey<Type> = {
[Property in keyof Type as `re_${string & Property}`]: Type[Property];
};
// 运算结果:{ readonly re_id?: string; re_name?: string; re_age?: number; }
// 很明显:每一个原来的键都加上 re 这个前缀
type RenameUser = RenameMapKey<MaybeUser>;
type KeyMoreOperator<Type> = {
// keyof 提取到键, 通过 Type[Property] 索引访问,你可进行任何的类型运算
// 这里只是抛砖引玉
[Property in keyof Type]: Type[Property] extends { [p: string]: number[] }
? string
: "others";
};
模板字符类型
模板文字类型建立在字符串文字类型之上,并且能够通过联合扩展为多个字符串。
在插值使用文字类型
type Str = "a" | "b" | "c";
type Num = 0 | 1 | 2;
//
type StrNum = `${Str | Num}`;
type Str_Num = Str | Num;
// 类型报错,因为类型 StrNum 是一个模板字符串类型,
// 在类型运算时 0 | 1 | 2 被内置函数转化为 "0" | "1" | "2"
let a: StrNum = 0;
// 可以的,等式成立
let b: Str_Num = 0;
内在字符串操作类型
为了帮助进行字符串操作,TypeScript 包含一组可用于字符串操作的类型。这些类型内置于编译器中以提高性能,并且无法在.d.tsTypeScript 包含的文件中找到。
type Greeting = "Hello, world"
type ShoutyGreeting = Uppercase<Greeting>
type ShoutyGreeting = "HELLO, WORLD"
从 TypeScript 4.1 开始,这些内置函数是直接使用 JavaScript 字符串运行时函数进行操作,并且使用者是感觉不到的。
function applyStringMapping(symbol: Symbol, str: string) {
switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
}
return str;
}
请注意版本要求4.1以上,推荐使用vs code 编辑器,并安装
JavaScript and TypeScript Nightly
插件
条件类型
条件类型就是一个三元表达式:SomeType extends OtherType ? TrueType : FalseType
, 为类型推导提供一个逻辑分支处理能力,是类型表达提供if ... else
能力。 关键字 extends
继承的意思,可以理解成 满足于
什么条件,可以是条件子类类,是一种类型约束表述。
条件类型是在typescript 2.8 版本中有详细的说明,可以看一下文档,英语还不错的,建议看官方文档,不容易遗漏关键细节。
// 返回基本类型
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T0 = TypeName<string>; // "string"
// 车
interface Car {
wheel: 4;
}
// 房子
interface House {
bed: true;
}
// 输入的类型参数 T 必须满足是车的有求
// 如果同时具有车,房的属性,就是房车,否则就是普通的车
type CarType<T extends Car> = T extends Car & House ? "motorhome" : "car";
分布条件类型
当输入的类型是联合类型或者交叉类型,条件类型是如何处理的呢?
- 联合类型:分布式条件会自动转化分布式的联合类型,一个一个约束条件的输入到类型参数中运算,再把得到结果类型用
|
合并 - 交叉类型:它就是一个类型,不用单独处理了,可以直接做类型操作
// 返回类型:"string" | "function"
// 第一次: string 输入 TypeName(string) 得到 string
// 第二次:() => void 输入 TypeName(() => void) 得到 function
// 把结果:string, function 用 `|` 链接得到联合类型 "string" | "function"
type T1 = TypeName<string | (() => void)>;
// 返回类型:function,
// 输入的类型参数 () => number 满足 T extends Function 的约束
type T2 = TypeName<string[] & (() => number)>;
类型变量的推断
在条件类型中,通过 infer 关键字声明一个类型变量要延迟推断。可以从2个方面去理解:
类型变量
:它是一个类型结构中的占位符,表示在指定位置
上的各种类型泛指推断
:是一个延迟过程,待到类型参数的输入时,才可以推断出类型变量的类型
/**
* 定义一个解包类型 Unpacked
* U 是一类型变量,分别在不同的位置作为类型占位符去推断
*/
type Unpacked<T> = T extends (infer U)[]
? U
: T extends (...args: any[]) => infer U
? U
: T extends Promise<infer U>
? U
: T;
/**
* Promise<string>[] 满足于 T extends (infer U)[]
* 类型变量 U 是 (infer U)[] 数组中元素的类型占位符
* 则可以推断出 U 指的就是 Promise<string>
*/
type TP = Unpacked<Promise<string>[]>; // Promise<string>
类型变量的位置
根据类型变量的位置,可以分为协变和逆变。维基百科是这样定义的:“协变与逆变(Covariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语”。
在一门程式设计语言的型别系统中,一个型别规则或者型别构造器是:
- 协变(covariant),如果它保持了子型别序关系≦。该序关系是:子型别≦基型别。
- 逆变(contravariant),如果它逆转了子型别序关系。
- 不变(invariant),如果上述两种均不适用。
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
// 协变
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
/**
* 当推断具有多个调用签名(例如函数重载类型)的类型时,用最后的签名
* 无法根据参数类型列表来解析重载
*/
declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;
type T30 = ReturnType<typeof foo>; // string | number
协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型。
Utility Types
现在你已经拥有对类型的操作运算的能力,他是类型推导的原始动力。在做类型转换,表达一些复杂类型时,经常要用到一些其它类型去运算。typescript 为我们提供了一些经内置的实用类型,你可以直接使用。
内置的类型,可以
command + 点击
查看定义,为了更加直观,方便理解,把直接定义复制出来了
Partial<Type>
把Type所有属性都设置为可选的类型
// 定义
type Partial<T> = {
// 把每个属性设置为可选的类型
[P in keyof T]?: T[P];
};
type Item = {
name: string;
status: number;
};
// 修改数据项时,可以只提供其中几个属性
function setItem(input: Partial<Item>) {
// do something
}
setItem({ name: "hello world" });
Required<Type>
Required:把Type所有属性都设置为必须提供的类型
// 定义
type Required<T> = {
[P in keyof T]-?: T[P];
};
type Item = {
id: number;
status?: number;
};
// 更新状态时,必须提供status 的值
function setStatus(input: Required<Item>) {
// do something
}
// 没有提供status 字段,报类型错误
setStatus({ id: 0 });
Readonly<Type>
Readonly:把Type所有属性都设置为只读的类型
// 定义
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Item = {
id: number;
name: string;
status?: number;
};
// 运算结果: { readonly id: number; readonly name: string; readonly status?: number; }
type LockItem = Readonly<Item>;
Record<Keys,Type>
构造一个对象类型,其属性键为Keys类型,属性值为Type类型。可用于将一种类型的属性映射到另一种类型
// 定义
type Record<K extends keyof any, T> = {
[P in K]: T;
};
type Item = {
id: number;
name: string;
status?: number;
};
type Keys = "a" | "b" | "c";
// 运算结果: { a: Item; b: Item; c: Item; }
type ItemMap = Record<Keys, Item>;
// 本质上是一种类型的属性映射到另一种类型,键值对的映射
type ItemList = Record<number, Item>;
const list: ItemList = [{ id: 1, name: "hello world" }];
Pick<Type, Keys>
选取一组属性Keys(字符串文字或字符串文字的并集)在类型Type属性集合中(也就是keyof Type)的键来构造一个类型。
- Keys 必须在 keyof Type 中,否则踢除
- 满足上面条件的Keys, 通过索引访问得到原来的类型
// 定义
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
type Item = {
a: number;
b: string;
c?: number;
};
type Keys = "a" | "b" | "d";
// 运算结果:{ a: number; b: string; }
// 因为 a, b 都是 Item 属性,d 不是
type SubItem = Pick<Item, Keys>;
Omit<Type, Keys>
从类型Type属性集合中(也就是keyof Type)删除在里面的Keys(字符串文字或字符串文字的并集),剩余的用来构造一个类型。
- 在 keyof Type 中踢除Keys,剩余的作为新类型的键
- 满足上面条件的剩余的键, 通过索引访问得到原来的类型
// 定义
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type Item = {
a: number;
b: string;
c?: number;
};
type Keys = "a" | "b";
// 运算结果:{ c?: number; }
type SubItem = Omit<Item, Keys>;
Pick,Omit 运算中都使用到keyof, 索引访问,他们是操作
键值对类型
的利器
Exclude<Type, ExcludedUnion>
从类型Type中,排除指定的ExcludedUnion产生一个新的类型。
- Type 是一个联合类型,否则排除就无从谈起
- ExcludedUnion 从词意上来看,也是要求是一个联合类型,当然这不是必须的,是一个类型就是可以的,不过是一个只有一个类型的联合类型
- 过滤操作使用的关键字是
extends
满足于
本质上讲,就是一过滤操作,过滤的条件就是 ExcludedUnion 中的类型是否可以赋值给Type,
是
,就排除。
// 定义
type Extract<T, U> = T extends U ? T : never;
type Keys = "a" | "b" | "c" | "d";
// 运算结果:"c" | "d"
type ResKeys = Exclude<Keys, "a" | "b">;
Extract<Type, Union>
从类型Type中,提取可以满足珠指定的Union类型,以生成一个新的类型。
- Type 是一个联合类型,否则提取也是无从谈起
- Union 从词意上来看,也是要求是一个联合类型,当然这不是必须的,是一个类型就是可以的,不过是一个只有一个类型的联合类型
- 使用的关键字是
extends
满足于
本质上讲,也就是一个过滤操作,过滤的条件就是提取出来的类型要满足于Union,
是
就提取出来作为新类的组成部分。
// 定义
type Extract<T, U> = T extends U ? T : never;
type Keys = "a" | "b" | "c" | "d";
// 运算结果:"a" | "b"
type ComKeys = Extract<Keys, "a" | "b">;
NonNullable<Type>
从Type 类型中,排除null和undefined,构造一个新的类型
// 定义
type NonNullable<T> = T extends null | undefined ? never : T;
// 运算结果:string[]
type T1 = NonNullable<string[] | null | undefined>;
typescript 也围绕函数的类型运算提供了参数类型、返回值类型、this 的类型、构造函数类型的类型操作工具。
Parameters<Type>
根据函数类型的参数中使用的类型构造元组类型。因为javacript 的函数参数内部是一个数组,返回一个元祖类型,也是很好理解的。
// 定义
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
const fun1 = (a: { name: string }, price: number) => {
return a.name + print;
};
// 运算结果: [a: { name: string; }, price: number]
type A = Parameters<typeof fun1>;
// 元祖类型的第一个类型 { name: string; }
type B = A[0];
// 报类型错误,Parameters 返回的是一个元祖,只支持数字索引访问
// a: { name: string; } 是对类型的描述,不要理解为object 对象
type C = A["a"];
ReturnType<Type>
由函数的返回值类型构造一个类型,就是提取函数的返回值的类型
// 定义
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
const f1 = (): string[] => {
return [];
};
type RType = ReturnType<typeof f1>;
ConstructorParameters<Type>
返回构造函数的参数类型元祖
// 定义
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;
interface Duck {
new (a: string, b: number): { name: string };
}
type ConTor = ConstructorParameters<Duck>;
// 报类型错误,因为 F1 没有明确声明 new 签名
type ProtoType = ConstructorParameters<typeof F1>;
InstanceType
获取构造函数的返回类型,这样太官方了,换一个说法:返回实例的类型。
// 定义
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;
class Cat {}
function Dog() {}
type CatType = InstanceType<typeof Cat>;
let cat: CatType = new Cat();
// 类型错误:没有提供 new (...args: any): any 签名
// 为什么会这样? Dog 构造函数,可以被当作普通参数调用
// class Cat 不可以,只允许用new 调用
type DogType = InstanceType<typeof Dog>;
// 类型错误:属性 prototype 在 Cat 实例中没有
// new Cat() 返回的是实例
let cat1: typeof Cat = new Cat();
// 类型推导成功
// 可以看得出 InstanceType 就是一个 typeof class["prototype"] 一个语法糖
let cat2: typeof Cat["prototype"] = new Cat();
ThisParameterType<Type>
返回函数this 的类型,因为 this 如果有,必须放在参数的第一个,通过infer 很容易提取到。
type ThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any ? U : unknown;
class Duck {
name = "hello world";
}
function fun(this: typeof Duck) {
console.log(this.name);
}
const duck = new Duck();
// 指定调用时 this 指向 duck
fun.call(duck);
OmitThisParameter<Type>
忽略函数的this参数类型,如何理解呢?
- 情况一:没有声明this 参数类型,直接返回 Type
- 情况二:如果type中有声明 this 参数类型, 则忽略 this 类型,返回剩余参数类型的作为新的函数类型,函数的返回值类型不变。
type OmitThisParameter<T> = unknown extends ThisParameterType<T> ? T : T extends (...args: infer A) => infer R ? (...args: A) => R : T;
function a(num: number): string {
return `${num}`;
}
function b(this: Window, str: string) {}
// 运算结果: (num: number) => string
type A = OmitThisParameter<typeof a>;
// 运算结果: (str: string) => void
type B = OmitThisParameter<typeof b>;
// 绑定 this 并返回新的函数
let bFun: B = b.bind(window);
ThisType<Type>
ThisType 这个类型工具不返回转换的类型,它作为 this 上下文类型的标记符而服务。只有打开 -- noImplicitThis 选项,才可以使用。
/**
* 定义就是一个空类型
*
*/
interface ThisType<T> { }
type ObjectDescriptor<D, M> = {
data?: D;
// 标记在methodsk中 this 的类型是 D & M
methods?: M & ThisType<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; // Strongly typed this
this.y += dy; // Strongly typed this
},
},
});
obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);
Intrinsic String Manipulation Types
TypeScript 包含一组可用于类型系统中的字符串操作的类型。您可以在模板文字类型文档中找到这些内容。
可以看上面模板文本类型
结语
类型运算是typescipt 表达能力的核心,如:keyof, extends, in, typeof, 条件类型,infer 类型变量,this 类型这些,就像数学中的加,减,乘,除
,一定要了然于心,才可以运用自如。
当学习一个编程技术,贴近业务,试着写一写,会有不一样的发现,有兴趣的可以看一下。
typecript 是一个完整的体系,进一步学习的还有声明文件,项目配置。文章中如有错漏,欢迎指正,谢谢。