第二章:TypeScript 的类型系统
第 11 条 认识到额外属性检查的局限
使用对象字面量赋值时,TypeScript 会进行额外属性检查(Excess Property Checking):
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
const r: Room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present',
// ~~~~~~~~~~~~~~~~~~ Object literal may only specify known properties,
// and 'elephant' does not exist in type 'Room'
};
这不太符合 TypeScript 结构化类型的表现,但是如果定义一个中间变量,再进行赋值,则不会报错:
const obj = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present',
};
const r: Room = obj; // OK
因为 obj
被推导为如下类型,它是 Room
的子类型。
const obj: {
numDoors: number;
ceilingHeightFt: number;
elephant: string;
}
再看个例子体会一下:
interface Options {
title: string;
darkMode?: boolean;
}
function createWindow(options: Options) {
if (options.darkMode) {
// setDarkMode();
}
// ...
}
createWindow({
title: 'Spider Solitaire',
darkmode: true
// ~~~~~~~~~~~~~ Object literal may only specify known properties, but
// 'darkmode' does not exist in type 'Options'.
// Did you mean to write 'darkMode'?
});
const o1: Options = document; // OK
const o2: Options = new HTMLAnchorElement; // OK
const o: Options = { darkmode: true, title: 'Ski Free' };
// ~~~~~~~~ 'darkmode' does not exist in type 'Options'...
const intermediate = { darkmode: true, title: 'Ski Free' };
const o: Options = intermediate; // OK
const o = { darkmode: true, title: 'Ski Free' } as Options; // OK
额外属性检查可以避免开发者打错字,在检查的过程中需要遍历属性,有一定的性能开销,所以 TypeScript 只检查字面量不检查中间变量。利用这一点可以绕过 TypeScript 的属性检查,除此以外还可以使用类型断言绕开检查。
第 12 条 尽可能将类型应用到整个函数表达式
如果多个函数的函数签名和返回值类型一致,可以抽出公共的类型声明,简化代码。
function add(a: number, b: number) { return a + b; }
function sub(a: number, b: number) { return a - b; }
function mul(a: number, b: number) { return a * b; }
function div(a: number, b: number) { return a / b; }
可以简化为:
type BinaryFn = (a: number, b: number) => number;
const add: BinaryFn = (a, b) => a + b;
const sub: BinaryFn = (a, b) => a - b;
const mul: BinaryFn = (a, b) => a * b;
const div: BinaryFn = (a, b) => a / b;
再比如自定义一个带状态检查的 fetch
函数,除了检查 response
,它的入参和返回值和原生的 fetch
一致:
async function checkedFetch(input: RequestInfo, init?: RequestInit) {
const response = await fetch(input, init);
if (!response.ok) {
// Converted to a rejected Promise in an async function
throw new Error('Request failed: ' + response.status);
}
return response;
}
其中的 RequestInfo
、RequestInit
有点冗余,可以简化为:
const checkedFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error('Request failed: ' + response.status);
}
return response;
}
使用函数表达式(Function Expression)代替函数语句(Function Statement),可以更方便地复用函数的类型声明。
第 13 条 知道 type 和 interface 的区别
type
和 interface
在大部分场景下可以混用,它们有很多的相同点也有一些不同之处。
interface IStateWithPop extends TState {
population: number;
}
type TStateWithPop = IState & { population: number };
相同点
- 都可以表示对象
type TState = {
name: string;
capital: string;
};
interface IState {
name: string;
capital: string;
}
- 都可以表示函数
type TFn = (x: number) => string;
interface IFn {
(x: number): string;
}
- 都可以使用泛型
type TPair<T> = {
first: T;
second: T;
};
interface IPair<T> {
first: T;
second: T;
}
不同点
- 表示联合类型(Union Types)和交叉类型(Intersection Types),只能用
type
type AorB = 'a' | 'b';
type ColorfulCircle = { color: string } & { radius: number };
在“第 7 条 将类型看作是值的集合”提过,交叉类型可以换个写法用 extends
表示:
interface Circle {
radius: number;
}
interface ColorfulCircle extends Circle {
color: string;
}
- 表示条件类型(Conditional Types),只能用
type
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
type ToArray<Type> = Type extends any ? Type[] : never;
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
- 表示元组和数组,只能用
type
type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];
- 表示别名(Alias),只能用
type
type Second = number;
type UserInputSanitizedString = string;
在 TypeScript 文档中 type
被称为 Type Alias,实际上所谓的使用 type
关键字定义类型,其实是给等号右侧的类型、类型表达式起别名。
interface
支持声明合并(Declaration Merging),type
不支持
interface State {
name: string;
capital: string;
}
interface State {
population: number;
}
type State {
name: string;
capital: string;
}
type State {
population: number;
}
// ~~~~~~~~ Duplicate identifier 'State'.
第 14 条 使用类型操作和范型来避免重复
- 抽出公共的类型
function distance(a: { x: number; y: number }, b: { x: number; y: number }) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
简化为:
interface Point2D {
x: number;
y: number;
}
function distance(a: Point2D, b: Point2D) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
- 复用已有类型
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
interface TopNavState {
userId: string;
pageTitle: string;
recentFiles: string[];
}
简化为:
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
type TopNavState = {
[k in 'userId' | 'pageTitle' | 'recentFiles']: State[k];
};
// 可以直接使用内置的 `Pick`
type TopNavState2 = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;
interface SaveAction {
type: 'save';
// ...
}
interface LoadAction {
type: 'load';
// ...
}
type ActionType = 'save' | 'load';
简化为:
type ActionType = Action['type'];
- 灵活使用内置的类型操作符
const INIT_OPTIONS = {
width: 640,
height: 480,
color: '#00FF00',
label: 'VGA',
};
interface Options {
width: number;
height: number;
color: string;
label: string;
}
可以简化为:
type Options = typeof INIT_OPTIONS;
对于如下函数:
function getUserInfo(userId: string) {
// ...
return {
userId,
name,
age,
height,
weight,
favoriteColor,
};
}
要获得它的返回值类型,可以借助 ReturnType
免去手动定义。
type UserInfo = ReturnType<typeof getUserInfo>;
第 15 条 动态数据使用索引签名
TypeScript 中描述对象类型时可以使用索引签名(Index Signature)简化定义。
比如以下对象:
const rocket = {
name: 'Falcon 9',
variant: 'v1.0',
thrust: '4,940 kN',
};
可以定义为:
type Rocket = { [property: string]: string };
索引签名的典型场景是用于处理动态数据,比如解析 CSV 数据:
function parseCSV(input: string): { [columnName: string]: string }[] {
const lines = input.trim().split('\n');
const [header, ...rows] = lines;
const headerKeys = header.split(',');
return rows.map(rowStr => {
const row: { [columnName: string]: string } = {};
rowStr.split(',').forEach((cell, i) => {
if (i < headerKeys.length) {
row[headerKeys[i]] = cell;
}
});
return row;
});
}
第 16 条 使用 Array, Tuple, ArrayLike 时偏向于使用数字作为索引签名
在 TypeScript 的类型定义中,数组被表示为以下形式,其中每一项索引都是数字。
interface Array<T> {
length: number;
[index: number]: T;
}
访问数组元素时,只能使用数字索引,不能使用数字字符串:
const xs = [1, 2, 3];
const x0 = xs[0]; // OK
const x1 = xs['1'];
// ~~~ Element implicitly has an 'any' type
// because index expression is not of type 'number'
但这不代表使用 for in
循环或者 Object.keys()
获取到的索引类型是 number
:
const xs = [1, 2, 3];
for (const index in xs) {
index; // Type is string
}
const keys = Object.keys(xs); // Type is string[]
数组归根结底是对象,所以它们的键名是字符串而不是数字,这和 JavaScript 一致。
第 17 条 使用 readonly 来避免数据变化相关的错误
如果编写的函数不会修改它的入参,可以在声明入参的时候加上 readonly
,避免实现时不经意改动参数。
比如以下求和函数:
function arraySum(arr: number[]) {
let sum = 0, num;
while ((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
}
在实现时没有考虑数据不可变,修改了入参,调用完原数组会被清空。如果加上 readonly
可以及时发现错误:
function arraySum(arr: readonly number[]) {
let sum = 0, num;
while ((num = arr.pop()) !== undefined) {
// ~~~ 'pop' does not exist on type 'readonly number[]'
sum += num;
}
return sum;
}
值得注意的是 readonly
的限制是浅层的(shallow),下面的代码中,readonly
只能限制修改数组不能限制修改元素:
const dates: readonly Date[] = [new Date()];
dates.push(new Date());
// ~~~~ Property 'push' does not exist on type 'readonly Date[]'
dates[0].setFullYear(2037); // OK
这种限制同样适用于 Readonly
泛型:
interface Outer {
inner: {
x: number;
};
}
const o: Readonly<Outer> = { inner: { x: 0 } };
o.inner = { x: 1 };
// ~~~~ Cannot assign to 'inner' because it is a read-only property
o.inner.x = 1; // OK
第 18 条 使用映射类型来保持值同步
假设现在要实现一个散点图组件,它有着如下属性:
interface ScatterProps {
// The data
xs: number[];
ys: number[];
// Display
xRange: [number, number];
yRange: [number, number];
color: string;
// Events
onClick: (x: number, y: number, index: number) => void;
}
为了优化性能,同时实现了一个 shouldUpdate
函数用于减少不必要的重绘,例如:
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) {
let k: keyof ScatterProps;
for (k in oldProps) {
if (oldProps[k] !== newProps[k]) {
if (k !== 'onClick') return true;
}
}
return false;
}
或者:
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) {
return (
oldProps.xs !== newProps.xs ||
oldProps.ys !== newProps.ys ||
oldProps.xRange !== newProps.xRange ||
oldProps.yRange !== newProps.yRange ||
oldProps.color !== newProps.color
// (no check for onClick)
);
}
前者判断 onClick
,后者判断 onClick
之外的剩余属性。如果给散点图增加了新的属性,前者即使是事件监听器绑定、解绑也会导致重绘,后者无论什么新属性变更都会忽略。总之如果添加了新属性,必须记得维护 shouldUpdate
的判断条件,不维护不会被察觉。
我们可以借助映射类型(Mapped Types)来解决这个两难的问题:
const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
xs: true,
ys: true,
xRange: true,
yRange: true,
color: true,
onClick: false,
};
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) {
let k: keyof ScatterProps;
for (k in oldProps) {
if (oldProps[k] !== newProps[k] && REQUIRES_UPDATE[k]) {
return true;
}
}
return false;
}
如果 ScatterProps
增加了一个新属性,但没有同步修改 REQUIRES_UPDATE
,会直接报错:
interface ScatterProps {
// ...
onDoubleClick: () => void;
}
const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
// ~~~~~~~~~~~~~~~ Property 'onDoubleClick' is missing in type
// ...
};