Effective TypeScript

使用 readonly 避免与突变相关的错误

核心建议是利用 readonly 修饰符来防止对象和数组的意外突变,突变是 JavaScript 中许多难以追踪的 bug 的常见根源。

突变带来的问题

JavaScript 中的数组和对象在默认情况下都是可变的。当一个函数修改了其参数时,如果调用者没有预料到这种副作用,就会导致程序逻辑错误。

例如,一个求和函数可能会意外地清空数组,导致调用它的外部函数产生错误行为。

function arraySum(arr: number[]) {
  let sum = 0, num;
  // 此处使用了 pop(),它会修改原始数组
  while ((num = arr.pop()) !== undefined) { 
    sum += num;
  }
  return sum; 
}

function printTriangles(n: number) {
  const nums = [];
  for (let i = 0; i < n; i++) {
    nums.push(i);
    // 第一次调用 arraySum 后,nums 就会被清空
    console.log(arraySum(nums)); 
  }
}
// 尽管 printTriangles(5) 的预期输出是 0, 1, 3, 6, 10,但由于突变,它会输出 0, 1, 2, 3, 4。

使用 readonly 避免突变

readonly 关键字可以帮助我们在类型系统层面明确契约,防止对类型进行未预期的修改。

  • 原始值: JavaScript 中的原始值(如 string, number, boolean)本身是不可变的,其方法不会导致突变。
  • 对象属性:readonly 放置在对象属性上,可以防止对该属性进行重新赋值。
interface PartlyMutableName {
  readonly first: string;
  last: string;
}

const jackie: PartlyMutableName = { first: 'Jacqueline', last: 'Kennedy' };
jackie.last = 'Onassis';  // OK
// @ts-expect-error
jackie.first = 'Jacky'; // 错误:无法分配到 'first',因为它是一个只读属性
  • 工具类型 Readonly<T> TypeScript 提供了泛型工具类型 Readonly<T>,用于将类型 T 的所有属性设置为只读。
interface FullyMutableName {
  first: string;
  last: string;
}

type FullyImmutableName = Readonly<FullyMutableName>;
// 结果类型中的 first 和 last 都是只读的。

Readonly 的局限性

  • readonly 是浅层的: 类似于 const 声明,readonly 只防止属性本身被重新赋值,但不会阻止该属性指向的内部对象发生突变。

    interface Outer { inner: { x: number; } }
    const obj: Readonly<Outer> = { inner: { x: 0 }};
    
    // @ts-expect-error
    obj.inner = { x: 1 }; // 错误:inner 属性是只读的
    obj.inner.x = 1;      // OK:内部对象可以被突变
  • 不影响方法: Readonly 只影响属性,但对可能改变底层数据的对象方法无效。例如,对 Readonly<Date> 类型的对象调用 setFullYear() 仍然是允许的,但会导致对象突变。

数组和 ReadonlyArray

标准库中提供了 Array<T>(可变数组)和 ReadonlyArray<T>(只读数组)两个接口来应对突变问题。

  • ReadonlyArray<T> 移除了所有突变方法(如 popshift)。

  • 它的 length 属性和索引签名([n: number]: T)都带有 readonly 修饰符,从而防止调整数组大小或修改元素。

  • 子类型关系: T[]readonly T[] 的子类型,因为它具有更多的功能(突变能力),因此可变数组可以安全地赋值给只读数组。

    const a: number[] =;
    const b: readonly number[] = a; // OK
    
    // @ts-expect-error
    const c: number[] = b; // 错误:不能将只读类型分配给可变类型
  • 修复求和函数: 通过将参数类型更改为 readonly number[],我们可以强制 arraySum 成为一个非突变函数。

    function arraySum(arr: readonly number[]) {
      let sum = 0;
      // 如果尝试使用 pop() 或 shift(),会得到类型错误。
      for (const num of arr) { 
        sum += num; // 使用非突变循环
      }
      return sum;
    }
    // 现在 printTriangles 调用 arraySum 时,类型检查器将确保 arr 不被修改。

使用 Readonly 的实践建议

  • 明确函数契约: 如果您的函数不修改其参数,应将其参数声明为 readonly(针对数组)或 Readonly(针对对象类型)。

    • 这使函数的契约更清晰。
    • 这会阻止在函数实现中发生不经意的突变。
    • 这允许调用者传入 readonly 数组或 Readonly 对象,拓宽了函数可接受的参数类型集合(遵循鲁棒性原则)。
  • constreadonly 的区别:

    • const 阻止变量重新赋值
    • readonly 阻止对象的属性或数组突变
    • 函数参数即使被声明为 readonly,仍然可以在函数体内部被重新赋值(像 let 变量一样),因为这种重新赋值对调用者不可见。
  • readonly 的传染性: readonly 倾向于具有传染性,一旦开始使用,许多调用该函数的上游函数可能都需要使用 readonly 来保持类型安全。这通常是一件好事,因为它带来了更清晰的契约和更好的类型安全。

在 GitHub 上编辑

上次更新于