大家好,我是 CUGGZ。
在开发过程中,为了应对多变的复杂场景,我们需要了解一下 TypeScript 的高级类型。所谓高级类型,是 TypeScript 为了保证语言的灵活性,所使用的一些语言特性。这些特性有助于我们应对复杂多变的开发场景。
本文大纲如下:
- 字面量类型
- 联合类型
- 交叉类型
- 索引类型
- 条件类型
- 类型推断
- 类型保护
- 类型断言
1. 字面量类型
在 TypeScript 中,字面量不仅可以表示值,还可以表示类型,即字面量类型。TypeScript 支持以下字面量类型:
- 字符串字面量类型;
- 数字字面量类型;
- 布尔字面量类型;
- 模板字面量类型。
(1)字符串字面量类型
字符串字面量类型其实就是字符串常量,与字符串类型不同的是它是具体的值:
type Name = "TS"; const name1: Name = "test"; // ❌ 不能将类型“"test"”分配给类型“"TS"”。ts(2322) const name2: Name = "TS";
实际上,定义单个字面量类型在实际应用中并没有太大的用处。它的应用场景就是将多个字面量类型组合成一个联合类型,用来描述拥有明确成员的实用的集合:
type Direction = "north" | "east" | "south" | "west"; function getDirectionFirstLetter(direction: Direction) { return direction.substr(0, 1); } getDirectionFirstLetter("test"); // ❌ 类型“"test"”的参数不能赋给类型“Direction”的参数。 getDirectionFirstLetter("east");
这个例子中使用四个字符串字面量类型组成了一个联合类型。这样在调用函数时,编译器就会检查传入的参数是否是指定的字面量类型集合中的成员。通过这种方式,可以将函数的参数限定为更具体的类型。这不仅提升了代码的可读性,还保证了函数的参数类型。
除此之外,使用字面量类型还可以为我们提供智能的代码提示:
(2)数字字面量类型
数字字面量类型就是指定类型为具体的数值:
type Age = 18; interface Info { name: string; age: Age; } const info: Info = { name: "TS", age: 28 // ❌ 不能将类型“28”分配给类型“18” };
可以将数字字面量类型分配给一个数字,但反之是不行的:
let val1: 10|11|12|13|14|15 = 10; let val2 = 10; val2 = val1; val1 = val2; // ❌ 不能将类型“number”分配给类型“10 | 11 | 12 | 13 | 14 | 15”。
(3)布尔字面量类型
布尔字面量类型就是指定类型为具体的布尔值(true
或false
):
let success: true; let fail: false; let value: true | false; success = true; success = false; // ❌ 不能将类型“false”分配给类型“true”
由于布尔字面量类型只有true
或false
两种,所以下面 value
变量的类型是一样的:
letvalue: true | false;
letvalue: boolean;
(4)模板字面量类型
在 TypeScript 4.1 版本中新增了模板字面量类型。什么是模板字面量类型呢?它一字符串字面量类型为基础,可以通过联合类型扩展成多个字符串。它与 JavaScript 的模板字符串语法相同,但是只能用在类型定义中使用。
① 基本语法
当使用模板字面量类型时,它会替换模板中的变量,返回一个新的字符串字面量。
type attrs = "Phone" | "Name"; type target = `get${attrs}`; // type target = "getPhone" | "getName";
可以看到,模板字面量类型的语法简单,并且易读且功能强大。
假如有一个CSS内边距规则的类型,定义如下:
typeCssPadding = 'padding-left' | 'padding-right' | 'padding-top' | 'padding-bottom';
上面的类型是没有问题的,但是有点冗长。margin
和 padding
的规则相同,但是这样写我们无法重用任何内容,最终就会得到很多重复的代码。
下面来使用模版字面量类型来解决上面的问题:
type Direction = 'left' | 'right' | 'top' | 'bottom'; type CssPadding = `padding-${Direction}` // type CssPadding = 'padding-left' | 'padding-right' | 'padding-top' | 'padding-bottom'
这样代码就变得更加简洁。如果想创建margin
类型,就可以重用Direction
类型:
typeCssMargin = `margin-${Direction}`
如果在 JavaScript 中定义了变量,就可以使用 typeof
运算符来提取它:
const direction = 'left'; type CssPadding = `padding-${typeof direction}`; // type CssPadding = "padding-left"
② 变量限制
模版字面量中的变量可以是任意的类型吗?可以使用对象或自定义类型吗?来看下面的例子:
type CustomObject = { foo: string } type target = `get${CustomObject}` // ❌ 不能将类型“CustomObject”分配给类型“string | number | bigint | boolean | null | undefined”。 type complexUnion = string | number | bigint | boolean | null | undefined; type target2 = `get${complexUnion}` // ✅
可以看到,当在模板字面量类型中使用对象类型时,就报错了,因为编译器不知道如何将它序列化为字符串。实际上,模板字面量类型中的变量只允许是string
、number
、bigint
、boolean
、null
、undefined
或这些类型的联合类型。
③ 实用程序
Typescript 提供了一组实用程序来帮助处理字符串。它们不是模板字面量类型独有的,但与它们结合使用时很方便。完整列表如下:
Uppercase
:将类型转换为大写;Lowercase
:将类型转换为小写;Capitalize
:将类型第一个字母大写;Uncapitalize
:将类型第一个字母小写。
这些实用程序只接受一个字符串字面量类型作为参数,否则就会在编译时抛出错误:
type nameProperty = Uncapitalize<'Name'>; // type nameProperty = 'name'; type upercaseDigit = Uppercase<10>; // ❌ 类型“number”不满足约束“string”。 type property = 'phone'; type UppercaseProperty = Uppercase<property>; // type UppercaseProperty = 'Property';
下面来看一个更复杂的场景,将字符串字面量类型与这些实用程序结合使用。将两种类型进行组合,并将第二种类型的首字母大小,这样组合之后的类型符合驼峰命名法:
type actions = 'add' | 'remove'; type property = 'name' | 'phone'; type result = `${actions}${Capitalize<property>}`; // type result = addName | addPhone | removeName | removePhone
④ 类型推断
在上面的例子中,我们使用使用模版字面量类型将现有的类型组合成新类型。下面来看看如何使用模板字面量类型从组合的类型中提取类型。这里就需要用到infer
关键字,它允许我们从条件类型中的另一个类型推断出一个类型。
下面来尝试提取字符串字面量 marginRight
的根节点:
type Direction = 'left' | 'right' | 'top' | 'bottom'; type InferRoot<T> = T extends `${infer K}${Capitalize<Direction>}` ? K : T; type Result1 = InferRoot<'marginRight'>; // type Result1 = 'margin'; type Result2 = InferRoot<'paddingLeft'>; // type Result2 = 'padding';
可以看到, 模版字面量还是很强大的,不仅可以创建类型,还可以解构它们。
⑤ 作为判别式
在TypeScript 4.5 版本中,支持了将**模板字面量串类型作为判别式,**用于类型推导。来看下面的例子:
interface Message { type: string; url: string; } interface SuccessMessage extends Message { type: `${string}Success`; body: string; } interface ErrorMessage extends Message { type: `${string}Error`; message: string; } function handler(r: SuccessMessage | ErrorMessage) { if (r.type === "HttpSuccess") { let token = r.body; } }
在这个例子中,handler
函数中的 r
的类型就被推断为 SuccessMessage
。因为根据 SuccessMessage
和 ErrorMessage
类型中的type字段的模板字面量类型推断出 HttpSucces
是根据SuccessMessage
中的type
创建的。
2. 联合类型
(1)基本使用
联合类型是一种互斥的类型,该类型同时表示所有可能的类型。联合类型可以理解为多个类型的并集。 联合类型用来表示变量、参数的类型不是某个单一的类型,而可能是多种不同的类型的组合,它通过 |
来分隔不同的类型:
typeUnion = "A" | "B" | "C";
在编写一个函数时,该函数的期望参数是数字或者字符串,并根据传递的参数类型来执行不同的逻辑。这时就用到了联合类型:
function direction(param: string | number) { if (typeof param === "string") { ... } if (typeof param === "number") { ... } ... }
这样在调用 direction
函数时,就可以传入string
或number
类型的参数。当联合类型比较长或者想要复用这个联合类型的时候,就可以使用类型别名来定义联合类型:
typescript
复制代码
typeParams = string | number | boolean;
再来看一个字符串字面量联合类型的例子,setStatus 函数只能接受某些特定的字符串值,就可以将这些字符串字面量组合成一个联合类型:
type Status = 'not_started' | 'progress' | 'completed' | 'failed'; const setStatus = (status: Status) => { db.object.setStatus(status); }; setStatus('progress'); setStatus('offline'); // ❌ 类型“"offline"”的参数不能赋给类型“Status”的参数。
在调用函数时,如果传入的参数不是联合类型中的值,就会报错。
(2)限制
联合类型仅在编译时是可用的,这意味着我们不能遍历这些值。进行如下尝试:
typeStatus = 'not_started' | 'progress' | 'completed' | 'failed';
console.log(Object.values(Status)); // ❌ “Status”仅表示类型,但在此处却作为值使用。
这时就会抛出一个错误,告诉我们不能将 Status 类型当做值来使用。
如果想要遍历这些值,可以使用枚举来实现:
enum Status { 'not_started', 'progress', 'completed', 'failed' } console.log(Object.values(Status));
(3)可辨识联合类型
在使用联合类型时,如何来区分联合类型中的类型呢?类型保护是一种条件检查,可以帮助我们区分类型。在这种情况下,类型保护可以让我们准确地确定联合中的类型(下文会详细介绍类型保护)。
有很多方式可以做到这一点,这很大程度上取决于联合类型中包含哪些类型。有一条捷径可以使联合类型中的类型之间的区分变得容易,那就是可辨识联合类型。可辨识联合类型是联合类型的一种特殊情况,它允许我们轻松的区分其中的类型。
这是通过向具有唯一值的每个类型中添加一个字段来实现的,该字段用于使用相等类型保护来区分类型。例如,有一个表示所有可能的形状的联合类型,根据传入的不同类型的形状来计算该形状的面积,代码如下:
type Square = { kind: "square"; size: number; } type Rectangle = { kind: "rectangle"; height: number; width: number; } type Circle = { kind: "circle"; radius: number; } type Shape = Square | Rectangle | Circle; function getArea(s: Shape) { switch (s.kind) { case "square": return s.size * s.size; case "rectangle": return s.height * s.width; case "circle": return Math.PI * s.radius ** 2; } }
在这个例子中,Shape
就是一个可辨识联合类型,它是三个类型的联合,而这三个类型都有一个 kind
属性,且每个类型的 kind
属性值都不相同,能够起到标识作用。 函数内应该包含联合类型中每一个接口的 case
,以保证每个**case**
都能被处理。
如果函数内没有包含联合类型中每一个类型的 case,在编写代码时希望编译器应该给出代码提示,可以使用以下两种完整性检查的方法。
① strictNullChecks
对于上面的例子,先来新增一个类型,整体代码如下:
type Square = { kind: "square"; size: number; } type Rectangle = { kind: "rectangle"; height: number; width: number; } type Circle = { kind: "circle"; radius: number; } type Triangle = { kind: "triangle"; bottom: number; height: number; } type Shape = Square | Rectangle | Circle | Triangle; function getArea(s: Shape) { switch (s.kind) { case "square": return s.size * s.size; case "rectangle": return s.height * s.width; case "circle": return Math.PI * s.radius ** 2; } }
这时,Shape 联合类型中有四种类型,但函数的 switch
里只包含三个 case
,这个时候编译器并没有提示任何错误,因为当传入函数的是类型是 Triangle
时,没有任何一个 case
符合,则不会执行任何 return
语句,那么函数是默认返回 undefined
。所以可以利用这个特点,结合 strictNullChecks
编译选项,可以在tsconfig.json
配置文件中开启 strictNullChecks
:
{ "compilerOptions": { "strictNullChecks": true, } }
让函数的返回值类型为 number
,那么当返回 undefined
时就会报错:
function getArea(s: Shape): number { case "square": return s.size * s.size; case "rectangle": return s.height * s.width; case "circle": return Math.PI * s.radius ** 2; } }
上面的number
处就会报错:
② never
当函数返回一个错误或者不可能有返回值的时候,返回值类型为 never
。所以可以给 switch
添加一个 default
流程,当前面的 case
都不符合的时候,会执行 default
中的逻辑:
function assertNever(value: never): never { throw new Error("Unexpected object: " + value); } function getArea(s: Shape) { switch (s.kind) { case "square": return s.size * s.size; case "rectangle": return s.height * s.width; case "circle": return Math.PI * s.radius ** 2; default: return assertNever(s); // error 类型“Triangle”的参数不能赋给类型“never”的参数 } }
采用这种方式,需要定义一个额外的 asserNever
函数,但是这种方式不仅能够在编译阶段提示遗漏了判断条件,而且在运行时也会报错。
3. 交叉类型
(1)基本实用
交叉类型是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到成为一种类型,合并后的类型将拥有所有成员类型的特性。交叉类型可以理解为多个类型的交集。 可以使用以下语法来创建交叉类型,每种类型之间使用 &
来分隔:
typeTypes = type1 & type2 & .. & .. & typeN;
如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何意义的,因为不会有变量同时满足这些类型,那这个类型实际上就等于never
类型。
(2)使用场景
上面说了,一般情况下使用交叉类型是没有意义的,那什么时候该使用交叉类型呢?下面就来看看交叉类型的使用场景。
① 合并接口类型
交叉类型的一个常见的使用场景就是将多个接口合并成为一个:
type Person = { name: string; age: number; } & { height: number; weight: number; } & { id: number; } const person: Person = { name: "zhangsan", age: 18, height: 180, weight: 60, id: 123456 }
这里就通过交叉类型使 Person
同时拥有了三个接口中的五个属性。那如果两个接口中的同一个属性定义了不同的类型会发生了什么情况呢?
type Person = { name: string; age: number; } & { age: string; height: number; weight: number; }
两个接口中都拥有age
属性,并且类型分别是number
和string
,那么在合并后,age
的类型就是string & number
,也就是 never
类型:
const person: Person = { name: "zhangsan", age: 18, // ❌ 不能将类型“number”分配给类型“never”。 height: 180, weight: 60, }
如果同名属性的类型兼容,比如一个是 number
,另一个是 number
的子类型——数字字面量类型,合并后 age
属性的类型就是两者中的子类型:
type Person = { name: string; age: number; } & { age: 18; height: number; weight: number; } const person: Person = { name: "zhangsan", age: 20, // ❌ 不能将类型“20”分配给类型“18”。 height: 180, weight: 60, }
第二个接口中的age
是一个数字字面量类型,它是number
类型的子类型,所以合并之后的类型为字面量类型18
。
② 合并联合类型
交叉类型另外一个常见的使用场景就是合并联合类型。可以将多个联合类型合并为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员:
type A = "blue" | "red" | 999;
type B = 999 | 666;
type C = A & B; // type C = 999;constc: C = 999;
如果多个联合类型中没有相同的类型成员,那么交叉出来的类型就是never
类型:
type A = "blue" | "red"; type B = 999 | 666; type C = A & B; const c: C = 999; // ❌ 不能将类型“number”分配给类型“never”。
4. 索引类型
在介绍索引类型之前,先来了解两个类型操作符:索引类型查询操作符和索引访问操作符。
(1)索引类型查询操作符
使用 keyof
操作符可以返回一个由这个类型的所有属性名组成的联合类型:
type UserRole = 'admin' | 'moderator' | 'author'; type User = { id: number; name: string; email: string; role: UserRole; } type UserKeysType = keyof User; // 'id' | 'name' | 'email' | 'role';
(2)索引访问操作符
索引访问操作符就是[]
,其实和访问对象的某个属性值是一样的语法,但是在 TS 中它可以用来访问某个属性的类型:
type User = { id: number; name: string; address: { street: string; city: string; country: string; }; } type Params = { id: User['id'], address: User['address'] }
这里我们没有使用number
来描述id
属性,而是使用 User['id']
引用User
中的id
属性的类型,这种类型成为索引类型,它们看起来与访问对象的属性相同,但访问的是类型。
当然,也可以访问嵌套属性的类型:
typeCity = User['address']['city']; // string
可以通过联合类型来一次获取多个属性的类型:
typeIdOrName = User['id' | 'name']; // string | number
(3)应用
我们可以使用以下方式来获取给定对象中的任何属性:
function getPropertyextends keyof T>(obj: T, key: K) { return obj[key]; }
TypeScript 会推断此函数的返回类型为 T[K],当调用 getProperty
函数时,TypeScript 将推断我们将要读取的属性的实际类型:
const user: User = { id: 15, name: 'John', email: 'john@smith.com', role: 'admin' }; getProperty(user, 'name'); // string getProperty(user, 'id'); // number
name
属性被推断为string
类型,age
属性被推断为number
类型。当访问User中不存在的属性时,就会报错:
getProperty(user, 'property'); // ❌ 类型“"property"”的参数不能赋给类型“keyof User”的参数。
你不知道的 TypeScript 高级类型(下)https://developer.aliyun.com/article/1411325