1. 引言
TypeScript 为前端开发带来了静态类型检查,让我们可以写出更安全的代码。
但是对于是否使用 TypeScript,每个人都有自己的看法。社区中整体来看开源的库、框架大都会使用 TypeScript,以保证项目的质量和提供更友好的接入使用体验(TS 会提供声明文件,编辑器会有提示和自动补全功能,甚至有基于声明文件生成文档的工具 tsdoc)。
工作中自己参与的项目是否使用 TypeScript, 需要考虑的点有开发人员的能力、业务开发的时间、项目规模等等。对于笔者来说,本人会在 JS SDK 和少部分业务项目使用 TypeScript,其他大部分项目都是直接 JS 开发。
但是 TypeScript 的学习还是很有必要的,至少要有中级水平(能在项目中熟练使用,同时类型体操能完成或看懂中级难度的题目),这能让你前端开发水平更上一层楼。除此之外,鸿蒙 ArkTs 也是基于 TS 拓展,TS 的掌握对参与鸿蒙开发也有很大帮助。
对于 TypeScript 的学习,相信大部分开发都至少有入门水平,故本文的侧重点在于介绍 TypeScript 的中级知识和一些实践技巧。看完本文,希望你有以下提升:
- 能理解 TypeScript 常见的知识点
- 能够在项目中更好的使用 TypeScript,减少 any 的使用
- 能完成简单和部分中等难度的 TypeScript 类型体操
2. 基础
2.1. 类型的父类和子类
这块内容个人觉得比较重要,对后面内容理解也有帮助,故放在第一块内容来讲解。
父类和子类的关系判断可分为两种情况:
- 类、对象类型:整体上来看子类型会拓展父类型,子类型会有更多的属性方法;类有明显的继承关系,对象是通过鸭子类型来判断。
- 联合类型:整体上来看是一个类型缩窄的过程,宽松为父类,类型比较窄为子类型;父子关系和类、对象相比直观上是个相反的过程。
类型的父子关系判断可以通过 extends
来判断;或者实际赋值失败时 TS 会有提示,因为子类型可以赋值给父类型,父类型不能赋值给子类型。
ts
type p1 = { name: string }
type s1 = { name: string, age: number }
type p2 = number | string | boolean
type s2 = number | boolean
type relation1 = s1 extends p1 ? true : false // true
type relation2 = s2 extends p2 ? true : false // true
const sObj: s1 = { name: 'Jack', age: 22 }
const pObj: p1 = sObj // 子类型可以赋值给父类型
const sUnion: s2 = 12
const pUnion: p2 = sUnion // 子类型可以赋值给父类型
其他一些常见的父子关系:
- 具体值是基础类型的子类型:比如
1
是number
的子类型 never
类型是所有类型的子类型undefined
在 tsconfigstrictNullChecks
为 true 的情况下是void
和any
类型子类型,为 false 的情况下则除never
的子类型
ts
// 具体值是基础类型的子类型
const num1 = 1
const num: number = num1
// never 类型是所有类型的子类型
let num2 = 2
function returnNever(): never {
throw new Error()
}
num2 = returnNever()
// undefined 相关
let a: undefined;
let b: number = 1;
let c: void;
let d: any = 'jack'
b = a; // strictNullChecks 为 true 下报错:undefined 不是其他类型子类型;strictNullChecks 为 false 则可以赋值
c = a; // undefined 是 void 类型子类型
d = a; // undefined 是 any 类型子类型
基础类型下,当子类型与父类型组成联合类型时,实际效果等于父类型:
ts
type A = number | 1; // number
type B = never | string; // string
基础类型下,当子类型与父类型组成交集类型时,实际效果等于子类型:
ts
type A = number & 1; // 1
type B = never & string; // never
2.2. 数据类型
数据类型分为以下两类:
- 基础类型包括:
number
、string
、boolean
、bigint
、symbol
、null
、undefined
、any
、unnkonw
、never
、void
。 - 引用类型有
Array
、Function
、Object
、Enum
、Date
、Map
、Set
、Promise
等等。
基础类型的使用相信大家都会,不再过多介绍,后续仅对 any
、unnkonw
、never
、void
这四个类型做个介绍。
数组和对象在开发中会经常使用到,声明方式如下:
ts
// 数组声明
const array: number[] = [1, 2]
const array2: Array<number> = [1, 2]
// 元组,数量和类型严格匹配
const array3: [number, string] = [1, '2']
// 对象声明
interface MyObject {
name: string;
age?: number; // 可选属性
readonly id: number; // 只读属性
update(val: string): void // 方法声明
update: (val: string) => void // 另一种方法声明
(): { name: string } // 调用签名
new (): { name: string } // 构造函数签名
[prop: string]: string; // 索引签名属性,key 支持 string, number, symbol
}
// 数组通过泛型和索引签名属性定义
interface Array<T> {
length: number;
toString(): string;
[n: number]: T;
}
上述示例中 Array
的声明值得一看,通过 number
类型的索引签名属性定义。函数声明将在后续内容中讲解。
对象中除了 Enum
类型,其他在 JavaScript 中都存在。对于 Enum
类型,除非你非常熟悉它,不然尽量不要使用,你可以使用以下方式(as const)代替:
ts
// Enum
const enum EDirection { Up, Down, Left, Right }
function walk(dir: EDirection) {}
walk(EDirection.Left);
// 通过 as const 来实现
const ODirection = { Up: 0, Down: 1, Left: 2, Right: 3 } as const;
type Direction = typeof ODirection[keyof typeof ODirection];
function run(dir: Direction) {}
run(ODirection.Right);
2.3. any, unknow, never, void
any
any 类型表示变量可以为任何类型,也意味着任何值都可以赋值给 any 类型的变量,当你想跳过 TS 类型检查就可以使用 any。实际开发中,当你不想写复杂类型声明或者不知道如何处理类型报错时,可以使用 any 来跳过类型检查,不过还是建议尽可能少用 any。
ts
let obj: any = { x: 0 };
obj = 'ts'
obj = 3
unknow
unknow 类型代表任何值。它与 any 类型类似,但更安全,因为访问 unknow 的属性或方法都是不合法的:
ts
// 可以重新赋值
let a:unknown = 2
a = 'foo'
function foo(a: any) {
a.b(); // 类型检查不会报错
}
function f2(a: unknown) {
// 不允许做操作
a.b(); // 'a' is of type 'unknown'.'a' is of type 'unknown'.
}
当你的函数的入参或返回值是不确定的时候,你可以使用 unkown 来代替 any,这样当你想操作入参或者返回值,你需要先做类型判断。
ts
function f2(a: unknown) {
if (typeof a === 'string') {
return a.substring(1, 2)
}
return a
}
f2(1) // 1
f2('abc') // 'bc'
never
never 代表永远不存在的值,如果一个函数执行时抛出了异常,那么这个函数就永远不会有值了(后续代码不会执行),这时函数的返回就是 never。
ts
function fail(msg: string): never {
throw new Error(msg);
}
还有一个常见的使用场景,在 switch
中使用,用在 default 分支,保证 switch 分支都会有 case 来处理,永远不会到 default 分支,如果代码执行到了 never 分支,静态检查就报错。
ts
switch (animal) {
case 'cat':
// ...
break;
case 'dog:
// ...
break;
case 'pig':
// ...
break;
default:
const check: never = letter; // 如果代码执行到这边,静态检查会报错,不能赋值给 never 类型。
// ...
break;
}
void
void 表示函数的没有返回值。当函数没有 return
语句,或 return
不带值时,就代表函数返回值为 void 类型。当函数没有返回值时,应该使用 void 而不是 any。
ts
function voidFn(name: string): void {
console.log(name)
}
const voidVar = voidFn('a')
voidVar.t // 报错
function anyFn(name: string): any {
console.log(name)
}
const anyVar = anyFn('a')
anyVar.t // 静态检查不会报错
2.4. 函数 Function
函数的类型声明,你可以直接在函数上进行注解,或者通过 interface、type 来进行。
js
// 直接在函数上注解
function addAge(name: string, age: number, step = 1): number {
return age + step
}
// 通过 interface 定义
interface IaddAge {
(name: string, age: number, step?: number): number // 调用签名
}
const iaddAge: IaddAge = (name, age, step = 1) {
return age + step
}
// 通过 type 定义
type TaddAge = {
(name: string, age: number, step?: number): number // 调用签名
new (s: string): { name: string } // 构造函数签名
}
const taddAge: TaddAge = (name, age, step = 1) {
return age + step
}
// name, age 必传,step 可选
addAge('Tom', 22)
addAge('Tom', 22, 2)
在日常使用过程中,值得注意的是,为回调函数定义类型时,这边会涉及到函数赋值类型兼容性问题。
首先让我们来看下 Array.forEach
是怎么使用的:
ts
const fruits = ['apple', 'banana', 'watermelon']
fruits.forEach((fruit) => console.log(fruit))
forEach
的使用很简单,我们会传入一个回调函数,函数中有三个参数 value
、index
、array
,上面例子中我们就用到 value
,如果让我们来写回调函数类型声明,凭直觉我们可能会这么:
ts
type MyArray = {
forEach(callbackFn: (value: number, index?: number, array?: Int8Array) => void): void;
};
因为后两个参数可能不会用到,我们会把它们设置为可选参数,代表使用回调函数的时候,可以不用传。但是情况是,我们会发现在使用 index
和 array
参数时,静态检查会提示我们需要判断 index
和 array
是否为空。
ts
const myArray: MyArray = {
forEach(callbackFn) { callbackFn(1, 0, [1]) }
}
myArray.forEach((value, index, array) => {
// 两个错误提示
// 'array' is possibly 'undefined'
// Type 'undefined' cannot be used as an index type
console.log(array[index])
})
这边就需要考虑到函数赋值兼容性。
函数赋值兼容性可以概括为以下几点(const func = fn
):
- 保证 fn 每一个参数在 func 参数中能找到,即 func 的参数个数大于 fn。
- fn 参数必须是 func 参数的父类型,调用 func 时传参数最后实际是在 fn 中用,为了兼容,func 的参数必须是子类型,fn 为父类型,子类型才可以赋值给父类型。
- fn 的返回值是 func 返回值的子类型,返回值则相反,fn 的返回值要兼容 func 返回值,故 fn 的为子类型。
具体可参考此文:一文了解 TS 函数赋值类型兼容性
故我们在定义 forEach
回调函数时,无需把后两个参数设置为可选,实际使用时的传入回调函数少于等于定义的就能兼容。最后可以看看 Array 的类型定义:
ts
interface Array<T> {
forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
}
除了函数赋值兼容性,泛型函数也需要有一定了解:
ts
function firstEle<T>(arr: T[]): T | undefined {
return arr[0];
}
通过 <T>
来声明泛型,并在参数和返回类型定义中使用。泛型也可以通过 extends
来限制范围:
ts
type numOrString = number | string
function firstEle<T extends numOrString>(arr: T[]): T | undefined {
return arr[0];
}
firstEle(['1', 2]) // 只允许是数字或字符串
最后提下函数重载:
ts
// 最后一个函数负责实现,double 只支持 string 或者 number 的类型入参
function double(x: string): string
function double(x: number): number
function double(x: any) { return x + x }
2.5. 联合类型(Union Types)、交叉类型(Intersection Types)
- 联合类型 是由两个或多个其他类型组成的类型,代表的值可以是这些类型中的任意一个。每种类型之间使用
|
符号表示 - 交叉类型 是表示具有两种或多种类型的所有属性的值的类型。每种类型之间使用
&
符号表示。
ts
let x: string | number;
x = 'hello'; // 有效
x = 123; // 有效
type X = { a: string; };
type Y = { b: string; };
type J = X & Y; // 交集
const j: J = {
a: 'a',
b: 'b',
};
2.6. 接口 Interfaces
JavaScript 中最常见的就是对象类型,接口 Interface 就是用来描述对象的形状,同时接口也支持继承、泛型和声明合并。
基本用法如下:
ts
interface Base {
version: number;
}
interface Product extends Base {
payloadSize: number;
outOfStock?: boolean; // 可选属性
readonly body: string; // 只读属性
update(val: string): void // 方法声明
update(val: boolean): void // 方法声明,支持重载
update: (val: string) => void // 另一种方法声明,不支持重载
(matcher: boolean): { name: string } // 调用签名
(matcher: string): { name: string } // 调用签名支持重载
new (): { name: string } // 构造函数签名
[prop: string]: string; // 索引签名属性,key 支持 string, number, symbol
get size(): number; // getter
set size(value: number | string); // setter
}
Interface 有支持泛型功能,这能让接口的使用更加灵活。
ts
interface User<T> {
data: T
}
// 支持限制泛型的类型
interface User<T extends { status: number }> {
data: T
}
接口还支持接口合并,同名的接口会接口声明合并:
ts
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = { height: 5, width: 6, scale: 10 };
声明合并有些限制,除了函数成员外,如果出现同名字段,类型也必须一致,不然会报错,而同名函数支持合并,效果为重载。
ts
interface Document {
createElement(tagName: any): Element;
}
interface Document {
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
}
interface Document {
createElement(tagName: string): HTMLElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
}
// 合并效果,后面声明的重载集排序在前,如果参数类型是单一的字符串,会被提前。
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}
更多内容可以查看 cheatsheet
2.7. 类型别名 Type Aliases
类型别名,顾名思义,给声明的类型起个别名。类型别名和接口部分功能上类似,不同点在于:
- 接口仅用于描述对象的形状,类型别名功能更加丰富,可以支持基础类型、联合类型、交叉类型等等
- 接口使用
extends
扩展,类型别名使用&
扩展 - 接口支持声明合并,同名的接口属性自动合并,类型别名不允许同名
- 性能方面接口会更好些。
使用哪个取决于自己喜好,官方提供一个启发式建议,优先使用 interface,如果不满足你需求,则使用 type。
类型别名的基础使用方法:
ts
type Product = {
payloadSize: number;
outOfStock?: boolean; // 可选属性
readonly body: string; // 只读属性
update(val: string): void // 方法声明
update(val: boolean): void // 方法声明,支持重载
update: (val: string) => void // 另一种方法声明,不支持重载
(matcher: boolean): { name: string } // 调用签名
(matcher: string): { name: string } // 调用签名支持重载
new (): { name: string } // 构造函数签名
[prop: string]: string; // 索引签名属性,key 支持 string, number, symbol
get size(): number; // getter
set size(value: number | string); // setter
}
可以看到,类型别名描述对象时和接口一致。不过类型别名可以支持更多声明:
ts
// 基础值
type NoFound = 404;
type Input = string;
// 元组
type DATA = [string, number]
// 联合、交叉类型
type Size = 'big' | 'small'
type Rect = { x: number } & { y: number }
// 从其他别名获取类型
type Response = { data: { items: string[] } }
type Data = Response["data"]
// 从具体值获取类型
const obj = { x: 2, y: 'test' }
type Data = typeof obj
更多内容可以查看 cheatsheet
2.8. 类 Class
TypeScript 中的类和 ES6 类使用上差不多,TS 拓展了抽象类、成员可见性(public、private、protected)、泛型等的能力。
有一点可以关注下,class 定义出来的既是一个值,也是一个类型。
ts
class A {
name: string = 'jack'
}
const a = new A() // A 作为正常 class 值使用
const a2:A = { name: 'obj' } // 作为类型使用,TS 是类型结构,可以正常赋值。
更多内容可以查看 cheatsheet
2.9. 类型断言 Type Assertions
有时候 TypeScript 无法获知的值类型信息。例如,如果你使用 document.getElementById
,TypeScript 只知道它会返回某种 HTMLElement
,并不知道具体的是什么 HTML 元素。
这种情况下,你可以使用类型断言来指定更具体的类型:
ts
// 两种语法都行
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");
需要注意的是 TypeScript 只允许把类型转换为更具体或更不具体的版本,例如这种 const x = "hello" as number;
转换是不允许的,不过你也可以绕过他 const x = "hello" as any as number;
2.10. 类型缩小 Narrowing
如果你的函数支持多种类型的入参,在使用参数时就可能需要用到类型缩小来保证正确地使用参数。
ts
function getLen(x: string | number): number {
// Property 'length' does not exist on type 'string | number'.
// Property 'length' does not exist on type 'number'.
return x.length
}
上述 getLen
方法中参数为 string
或 number
,在尝试获取参数的 length
属性时,TS 就会给出报错提示,这时我们就需要类型缩小。
JS 中常用的三种类型判断方式:typeof
, instanceof
, Object.prototype.toString.call
。TS 的类型缩小支持前两种方式,当然除此之外,TS 还有其他的类型缩小判断方式。
ts
// typeof
function getLen(x: string | number): number {
if (typeof x === 'string') { // 使用 typeof 将类型缩小到 string
return x.length
}
return x.toString().length // 剩下的只可能是 number 类型
}
// instanceof
function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString());
} else {
console.log(x.toUpperCase());
}
}
其他类型缩小方式还有:
in
操作符- 全等判断
- 类型谓词
is
- 根据对象某个属性判断
ts
// in 操作符
type Dog = { run: () => void };
type Bird = { fly: () => void };
function move(animal: Dog | Bird) {
if ("run" in animal) {
return animal.run();
}
return animal.fly();
}
// 全等判断
function example(x: string | number, y: string | boolean) {
if (x === y) { // 全等只可能出现在 x 和 y 都是 string 的情况下
x.toUpperCase();
y.toLowerCase();
}
}
// 类型谓词 is,函数返回 true 的时候 pet 为 Dog
function isFish(pet: Dog | Bird): pet is Dog {
return (pet as Dog).run !== undefined;
}
// 根据对象某个属性判断,称为标签联合
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;
}
}
从最后一个标签联合示例来看,我们平时要使用接口的联合而不是联合的接口,接口的联合更有利于做类型缩小。
ts
// 联合的接口,我们仅根据 kind 无法判断是否有 radius 或 sideLength 属性。
type Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
3. 类型操作 Type Manipulation
TS 允许通过组合各种类型操作符来表达复杂的操作和值,这块内容的学习也是开启类型体操的基础。
TS 中内置很多工具类型,你可以查阅 Utility Types 文档 来了解,用的比较多的如 Pick
,Omit
,Exclude
等等,内置的字符串操作有 Uppercase
,Lowercase
,Capitalize
, Uncapitalize
。
3.1. 泛型
泛型为 TS 提供了灵活的类型声明能力,使用时在变量名后边用 <>
标识:
ts
function toArray<T>(arg: T): T[] {
return [arg]
}
toArray(2) // number[]
// 多个泛型参数,也支持默认值
type Func<T, K = string> = {
(arg: T): K
}
// 对泛型类型限制,类型必须有 length 参数
function getLen<T extends { length: number }>(arg: T): number {
return arg.lenth
}
getLen({ length: 3 })
getLen([2])
3.2. 条件类型
条件类型的形式有点像 JavaScript 中的条件表达式 (condition ? trueExpression : falseExpression)
,不过条件类型通过 extends 关键字来处理前面的条件:
ts
// 前面泛型有提到 extends 也可以用来约束泛型的类型
type NameOrId<T extends number | string> = T extends number
? { id: T }
: { label: string };
// 传入的类型是数组,就返回数组元素的类型
type Flatten<T> = T extends any[] ? T[number] : T
上述例子条件判断会根据泛型类型,来决定最终的返回类型,关键点就是通过 extends
来实现。
当泛型实际是联合类型时,extends
会对每个联合类型项依次进行判断,最终也是返回联合类型。
ts
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>; // 返回为 string[] | number[]
// 如果你想要得到 string[] | number[]
type Arr = ToArray<string> | ToArray<number>
// 或者 ToArray 条件判断加个方括号,此时联合类型会作为一个整体,而不是一个个遍历。
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
extends
的常用的几个地方:
- 类继承或者接口扩展
class A extends BaseCalss {}
- 泛型限制:
getLen<T extends { length: number }>
- 条件类型:
T extends number ? true : false
学到这里,你可以试试以下题目:
- 实现 Exclude。回顾:基础类型下,当子类型与父类型组成联合类型时,实际效果等于父类型,
type B = never | string // 等同 type B = string
- 实现 If
3.3. keyof,typeof,类型索引访问
- keyof 接受一个对象声明类型,返回其 key 值的联合。
ts
type Point = { x: number; y: number };
type P = keyof Point; // 'x' | 'y'
const point = { x: 2, y: 2 }
// 只能作用于 TS 声明类型,不支持 JS/TS 具体值。
type P2 = keyof point; // 'point' refers to a value, but is being used as a type here.
type Mapish = { [k: string]: boolean };
// 对象是属性也可以通过数值访问 obj[0] === obj["0"]
type M = keyof Mapish; // string | number
keyof 语法只能接受 TS 类型,对于具体值使用该语法,则会报错。
- typeof 接受一个具体的值,返回其类型结构,它作用是把值转换为类型。JS 中也有 typeof,它接受具体值,返回的也是值,而不是类型。
ts
// 在正常代码中使用 typeof,此时是 JS 原生 typeof,返回的是个值,不可用于类型声明;
const type = typeof "Hello world" // string
let n: type; // 报错 'type' refers to a value, but is being used as a type here
const s = "hello";
// 在类型声明的位置使用,则是 TS typeof,返回的也是一个类型
let n: typeof s; // string
// as const 会推断到具体值
const arr = [1, 2] as const
type c = typeof arr // readonly [1, 2]
上述示例中可以看到 TS 一般会为你推断一个较为宽泛的类型,如果你使用 as const
,这时就会推断到具体值类型。
- 类型索引访问 即通过索引访问声明类型的属性,使用和 JS 对象访问类似,不过类型声明这边只能通过方括号
[]
访问。
ts
type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"]; // number
// 支持联合类型
type I1 = Person["age" | "name"]; // string | number
// 对于数组,我们可以使用 number 关键字来获取其所有类型,数组中有多个类型会返回联合类型
type Arr = [string, number] // 元组
type Second = Arr[1] // number
type ArrType = Arr[number] // string | number
// 使用 typeof 和 number 来获取数组元素的类型
const MyArray = [
{ name: "Alice", age: 15 },
{ name: "Bob", age: 23 },
{ name: "Eve", age: 38 },
];
type Person = typeof MyArray[number]; // { name: string; age: number; }
例子中可以看到类型索引访问,我们不仅可以一个字符串,也可以传入联合类型,同时也可以通过关键字 number
来获取数组所有元素类型的联合。
学到这里,你可以试试以下题目:
3.4. 映射类型
有时一个类型需要基于另一个类型,这时可以通过映射类型语法来实现,映射类型的语法以索引签名为基础。
ts
// keyof 用于获取 Type 对象所有的 key 值,是个联合类型,再通过 in 语法遍历这些 key 值。
type OptionsFlags<Type> = {
[Property in keyof Type]: boolean;
};
type Features = {
darkMode: () => void;
newUserProfile: () => void;
};
type FeatureOptions = OptionsFlags<Features>;
// FeatureOptions = { darkMode: boolean; newUserProfile: boolean }
在这过程你也可以对属性名称增加一些修饰符:
ts
// 通过 readonly 将属性变成只读,Type[Property] 就是前面提到的索引类型访问
type CreateImmutable<Type> = {
readonly [Property in keyof Type]: Type[Property];
};
// 通过 -readonly 移除属性的只读修饰
type CreateMutable<Type> = {
-readonly [Property in keyof Type]: Type[Property];
};
// 通过 ? 将属性变成可选
type Option<Type> = {
[Property in keyof Type]?: Type[Property];
};
// 通过 ?- 移除属性可选
type Concrete<Type> = {
[Property in keyof Type]-?: Type[Property];
};
key 值可以通过 as
来改变:
ts
// 将 Property 调整为 `get${Capitalize<string & Property>}`
type Getters<Type> = {
[Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};
interface Person {
name: string;
age: number;
}
type LazyPerson = Getters<Person>;
// type LazyPerson = { getName: () => string; getAge: () => number; }
Capitalize
为 TS 内置的工具类型,功能为首字母大写。
string & Property
保证入参是 string
,前面提到交叉类型取子类型,不匹配为 never,never 的 key 值会自动过滤。
学到这里,你可以试试以下题目:
3.5. 类型推断
类型推断通过 infer
关键字进行,先看一段代码
ts
type MyReturnType<T> = T extends ((...args: any) => infer R) ? R : never;
type numberPromise = Promise<number>;
type n = numberPromise extends Promise<infer P> ? P : never; // number
上述代码作用是获取函数返回值的类型,通过 infer
来标记返回泛型,infer
必须在 extends
右侧使用 ,推断出的类型 R
在条件语句为 true
的分支中使用,false
分支中不能使用。
学到这里,你可以试试以下题目:
3.6. 数组解构和递归
数组解构的用法和 JS 的解构类似,一般会配合 infer
使用
ts
type FirstType<T> = T extends [infer R, ...infer Rest] ? R : never;
type A = FirstType<[number, string]> // number
递归就是在函数中某个条件下再次调用函数本身,我们基于上述例子来实现获取所有元组类型的声明,当然元组类型获取你可以直接使用 T[number]
。
ts
type GetType<T> = T extends [infer R, ...infer Rest]
? Rest extends [] ? R : (R | GetType<Rest> )
: never;
type A = GetType<[number, string, boolean]> // type A = string | number | boolean
递归的关键在于判断 Rest
是否为空数组,为空情况下返回 R
,不为空就继续调用 GetType
,并将返回值和 R
组成联合类型。
学到这里,你可以试试以下题目:
3.7. 模板字面类型
模板字面类型以字符串字面类型为基础,并能通过联合扩展为多个字符串,语法与 JavaScript 中的模板字面字符串相同,直接看代码。
ts
type Account = "21323" | "56578";
type Email = `${Account}@qq.com`; // "21323@qq.com" | "56578@qq.com"
type EmailSuffix = "qq.com" | "163.com"
// "21323@qq.com" | "56578@qq.com" | "21323@163.com" | "56578@163.com"
type EmailMore = `${Account}@${EmailSuffix}`
看代码即可明白模板字面类型用法,联合类型的每个值都会执行一次,多个联合类型的话就会交叉相乘。
字符串也有类似解构的能力:
ts
// 使用 infer R 代表第一个字母,最后一个 infer 声明的 Rest 代表剩余的所有字母
// 和数组解构不同的是,无需使用 ...
type Rest<T extends string> = T extends `${infer R}${infer Rest}` ? Rest : ''
type a = Rest<'abcd'> // typa a = 'bcd'
// 具体字符解析,唯一个 infer 声明的代表其余字母串
type endA<T extends string> = T extends `${infer Rest}a` ? T : ''
type c = endA<'cba'> // 'cba'
type c1 = endA<'bc'> // ''
学到这里,你可以试试以下题目:
4. 模块 Modules
和 ES6 一样,在 TypeScript 中,任何包含顶级 import
或 export
的文件都被视为模块。相反,没有顶级 import
或 export
就会被当作全局模块 ,所以你的项目有时会有个 global.d.ts
文件可提供全局声明。
如果你当前文件没有任何导入或导出,但你又希望它是一个模块,可以添加一行:
ts
export {};
TS 的导入和导出的使用和 ES6 模块一致,不过额外支持了 import type
类型,让类型引入会更清晰。
ts
// animals.ts
export type Cat = { breed: string; yearOfBirth: number };
export interface Dog {
breeds: string[];
yearOfBirth: number;
}
// app.ts
import { Cat, Dog } from "./animal.js";
type Animals = Cat | Dog;
// app.ts
// 声明导入还支持 import type 语法
import type { createCatName } from "./animal.js"
// ts 4.5 以上可在花括号中加 type
import { createCatName, type Cat, type Dog } from "./animal.js";
在 ES6 之前,JavaScript 并没有官方的模块系统,所以 TypeScript 引入了命名空间和三斜线导入语法。
ts
namespace foo {
export function bar() {} // 通过 export 暴露对象
}
/// <reference path="other.ts />
// 其他文件引入使用
foo.bar()
正常情况下你无需使用这两个语法,应该使用 ES6 模块语法。
5. 编写声明文件
有时,会有一些依赖的第三方库没有提供 TS 声明文件(一般是早期的一些库,现代库基本都有提供 TS 声明文件),这时你就是自己为第三方库写一些类型声明。
编写的声明主要包括变量、函数、对象等,通过关键字 declare
进行。
ts
console.log('welcome', lang)
// 变量通过 declare var 声明
declare var lang: string;
greet("hello, world")
// 函数通过 declare function 声明
declare function greet(greeting: string): void;
let result = myLib.makeGreeting("hello, world");
let count = myLib.numberOfGreetings;
// 对象通过 declare namespace 声明
declare namespace myLib {
function makeGreeting(s: string): string;
let numberOfGreetings: number;
}
// 声明 class
declare class Animal {
constructor(name:string);
eat():void;
sleep():void;
}
如果你的 window 上挂了一些变量,你可以通过类型声明合并来扩展:
ts
interface Window { test: string; }
window.test
如果我们使用 import 导进来使用的第三方库没有类型声明文件,这时可以通过 declare module
的方式为它定义类型。
ts
import { Foo, readFile } from 'moduleA';
declare module 'moduleA' {
// 声明接口
interface Foo {
custom: {
prop1: string;
}
}
// 声明方法
function readFile(filename:string):string;
// 变量
let numberOfGreetings: number;
}
上节模块介绍中 namaspace
会使用 export
导出变量,不过对于 declare namespace
, declare module
来说,加不加 export 关键字都可以。
当你 TS 中 import
导入图片使用时,也需要做个声明:
ts
declare module "*.jpb"
declare module "*.png"
declare module "*.svg"
更多内容可以参考:声明文件
6. TSConfig
TSConfig 的配置项非常多,我们可以先了解一些常用的配置项。
strict
模式
strict
标志可以启用多种类型检查行为,从而更有力地保证程序的正确性。
strict
模式包含的最常见两个配置项为 strictNullChecks
和 noImplicitAny
。一般来说,我们都会开启这两个配置。
该配置项会影响 null
和 undefined
的使用。strictNullChecks
为 false
时,null
和 undefined
可以赋值给任何值,同时静态检查也会忽略它们。这意味着以下代码可以正常通过静态检查的,但是实际代码是不安全的。
ts
const users = [
{ name: "Oby", age: 12 },
{ name: "Heera", age: 32 },
];
const loggedInUsername = 'ts'
const loggedInUser = users.find((u) => u.name === loggedInUsername);
console.log(loggedInUser.age); // loggedInUser 可能为 undefined,这边就会报错
let str = 'strictNullChecks'
str = null // null 可以赋值给任何类型,调用 str.substring 时就会报错
可以看下 strictNullChecks
不同情况下 find
方法的返回值的定义:
ts
// strictNullChecks: true
type Array = {
find(predicate: (value: any, index: number) => boolean): S | undefined;
};
// strictNullChecks: false
type Array = {
find(predicate: (value: any, index: number) => boolean): S;
};
可以看到 strictNullChecks=true
时返回类型为 S | undefined
。故 TSConfig 配置中,strictNullChecks
正常情况下都会设置为 true
。strictNullChecks 体验地址
在某些没有类型注解的情况下,当 TypeScript 无法推断变量的类型时,它会判断为 any 类型。
ts
function fn(s) {
// No error?
console.log(s.subtr(3));
}
fn(42);
如果开启它,TS 将对被隐式推断为 any 的变量发出错误信息。
ts
function fn(s) {
// ts 发出错误提示,s 为隐式的 any。
console.log(s.subtr(3));
}
target
target
属性用于指定 TypeScript 应编译到哪个版本的 JavaScript ECMAScript 版本。对于现代浏览器,ES6是一个不错的选择,对于较旧的浏览器,建议使用 ES5。
编译也只转换语法,不处理 Promise 等 Polyfill,需要使用 corejs。
lib
lib
属性用于指定编译时要包含哪些声明文件,默认会根据 target
设置。
如果你 target
设置成 ES5
,你在使用 Promise
的时候 TS 就会有报错,这时就需要设置 lib: ["ES2015"]
保证有全局的 Promise
声明。
ts
{
"target": "ES5",
"lib": ["DOM", "ES6"],
}
ts
// a.ts
new Promise<boolean>((resolve) => {
resolve(true)
})
// a.js
"use strict";
// 箭头函数被转化,Promise 需要自己做 polyfill
new Promise(function (resolve) {
resolve(true);
});
esModuleInterop
ESM 和 CJS 的模块对于默认 default 有些差异,当 ESM 模块导入 CJS 模块时,TS 需要这么写:import * as React from 'react'
开启 esModuleInterop
配置后,TS 会帮你处理差异,你可以直接使用 import React from 'react'
。
查看更多: esModuleInterop 到底做了什么?
module
指定编译后代码使用的模块化规范,常见有 commonjs
, umd
, es6
, es2020
, esnext
等等。
ES 几个选项的差异:除了 ES2015/ES6 的基本功能外,ES2020 还增加了对动态导入和 import.meta 的支持,而 ES2022 则进一步增加了对顶层 await 的支持。
skipLibCheck
开启后,TS 会跳过对第三方包的类型检查,不过仍会根据这些包提供的类型定义检查您的代码。
files
指定被编译文件的列表,只有需要编译的文件少时才会用到,一般直接用 include 指定文件夹。
ts
{
"files": ["main.ts", "supplemental.ts"]
}
include
用来指定哪些 ts 文件需要被编译,路径是相对于 tsconfig.json 文件的目录解析。
ts
{
"include": ["src/**/*", "tests/**/*"]
}
支持的路径通配符:
*
可匹配零个或多个字符(不包括目录分隔符)?
匹配任何一个字符(不包括目录分隔符)**/
匹配嵌套到任何层级的任何目录
exclude
不需要被编译的目录,也支持路径通配符
7. 总结
对于业务开发来说,TypeScript 的掌握够用就行,要是对自己有更好要求可以去学习更多高级用法,同时有时间的话通读 TypeScript官方文档 是极好的。
文章中内容如有错误,烦请指正。文中 TS 类型体操遇到困难也欢迎讨论。