《Effective TypeScript》读书笔记(三)

第二章: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;
}

其中的 RequestInfoRequestInit 有点冗余,可以简化为:

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 的区别

typeinterface 在大部分场景下可以混用,它们有很多的相同点也有一些不同之处。

interface IStateWithPop extends TState {
  population: number;
}

type TStateWithPop = IState & { population: number };

相同点

  1. 都可以表示对象
type TState = {
  name: string;
  capital: string;
};

interface IState {
  name: string;
  capital: string;
}
  1. 都可以表示函数
type TFn = (x: number) => string;

interface IFn {
  (x: number): string;
}
  1. 都可以使用泛型
type TPair<T> = {
  first: T;
  second: T;
};

interface IPair<T> {
  first: T;
  second: T;
}

不同点

  1. 表示联合类型(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;
}
  1. 表示条件类型(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;
  1. 表示元组和数组,只能用 type
type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];
  1. 表示别名(Alias),只能用 type
type Second = number;
type UserInputSanitizedString = string;

在 TypeScript 文档中 type 被称为 Type Alias,实际上所谓的使用 type 关键字定义类型,其实是给等号右侧的类型、类型表达式起别名。

  1. 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 条 使用类型操作和范型来避免重复

  1. 抽出公共的类型
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));
}
  1. 复用已有类型
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'];
  1. 灵活使用内置的类型操作符
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
  // ...
};