中文

利用 TypeScript 的 readonly 类型释放不可变数据结构的力量。学习如何通过防止意外的数据突变来创建更可预测、可维护且稳健的应用程序。

TypeScript Readonly 类型:掌握不可变数据结构

在瞬息万变的软件开发领域,追求健壮、可预测和可维护的代码是一项持续的努力。TypeScript 凭借其强大的类型系统,为实现这些目标提供了有力的工具。在这些工具中,readonly 类型作为强制实现不可变性(immutability)的关键机制脱颖而出,而不可变性是函数式编程的基石,也是构建更可靠应用程序的关键。

什么是不可变性?它为何如此重要?

不可变性的核心思想是,一旦一个对象被创建,它的状态就不能被改变。这个简单的概念对代码质量和可维护性有着深远的影响。

TypeScript 中的 Readonly 类型:你的不可变性武器库

TypeScript 提供了多种使用 readonly 关键字来强制实现不可变性的方法。让我们来探讨这些不同的技术以及如何在实践中应用它们。

1. 接口和类型上的只读属性

将属性声明为只读最直接的方法是在接口或类型定义中直接使用 readonly 关键字。


interface Person {
  readonly id: string;
  name: string;
  age: number;
}

const person: Person = {
  id: "unique-id-123",
  name: "Alice",
  age: 30,
};

// person.id = "new-id"; // 错误:无法分配到 'id',因为它是一个只读属性。
person.name = "Bob"; // 这是允许的

在此示例中,id 属性被声明为 readonly。TypeScript 将阻止在对象创建后任何试图修改它的行为。而缺少 readonly 修饰符的 nameage 属性则可以自由修改。

2. Readonly 工具类型

TypeScript 提供了一个强大的工具类型叫做 Readonly<T>。这个泛型类型接收一个现有类型 T,并通过将其所有属性变为 readonly 来对其进行转换。


interface Point {
  x: number;
  y: number;
}

const point: Readonly<Point> = {
  x: 10,
  y: 20,
};

// point.x = 30; // 错误:无法分配到 'x',因为它是一个只读属性。

Readonly<Point> 类型创建了一个新的类型,其中 xy 都是 readonly。这是一种快速使现有类型变为不可变的便捷方法。

3. 只读数组 (ReadonlyArray<T>) 和 readonly T[]

JavaScript 中的数组本质上是可变的。TypeScript 提供了一种使用 ReadonlyArray<T> 类型或其简写形式 readonly T[] 来创建只读数组的方法。这可以防止数组内容被修改。


const numbers: ReadonlyArray<number> = [1, 2, 3, 4, 5];
// numbers.push(6); // 错误:属性 'push' 在类型 'readonly number[]' 上不存在。
// numbers[0] = 10; // 错误:类型 'readonly number[]' 中的索引签名只允许读取。

const moreNumbers: readonly number[] = [6, 7, 8, 9, 10]; // 等同于 ReadonlyArray
// moreNumbers.push(11); // 错误:属性 'push' 在类型 'readonly number[]' 上不存在。

任何试图使用会修改数组的方法(如 pushpopsplice)或直接对索引进行赋值的操作,都会导致 TypeScript 错误。

4. constreadonly:理解差异

区分 constreadonly 非常重要。const 防止变量本身被重新赋值,而 readonly 防止对象的属性被修改。它们服务于不同的目的,并且可以一起使用以实现最大程度的不可变性。


const immutableNumber = 42;
// immutableNumber = 43; // 错误:无法重新分配给 const 变量 'immutableNumber'。

const mutableObject = { value: 10 };
mutableObject.value = 20; // 这是允许的,因为*对象*不是 const,只有变量是。

const readonlyObject: Readonly<{ value: number }> = { value: 30 };
// readonlyObject.value = 40; // 错误:无法分配到 'value',因为它是一个只读属性。

const constReadonlyObject: Readonly<{ value: number }> = { value: 50 };
// constReadonlyObject = { value: 60 }; // 错误:无法重新分配给 const 变量 'constReadonlyObject'。
// constReadonlyObject.value = 60; // 错误:无法分配到 'value',因为它是一个只读属性。

如上所示,const 确保变量始终指向内存中的同一个对象,而 readonly 则保证对象的内部状态保持不变。

实践案例:在真实场景中应用 Readonly 类型

让我们来探讨一些在各种场景中如何使用 readonly 类型来提高代码质量和可维护性的实践案例。

1. 管理配置数据

配置数据通常在应用程序启动时加载一次,并且在运行时不应被修改。使用 readonly 类型可以确保这些数据保持一致并防止意外修改。


interface AppConfig {
  readonly apiUrl: string;
  readonly timeout: number;
  readonly features: readonly string[];
}

const config: AppConfig = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  features: ["featureA", "featureB"],
};

function fetchData(url: string, config: Readonly<AppConfig>) {
    // ... 安全地使用 config.timeout 和 config.apiUrl,知道它们不会改变
}

fetchData("/data", config);

2. 实现类 Redux 的状态管理

在像 Redux 这样的状态管理库中,不可变性是一个核心原则。Readonly 类型可用于确保状态保持不可变,并且 reducer 只返回新的状态对象,而不是修改现有的对象。


interface State {
  readonly count: number;
  readonly items: readonly string[];
}

const initialState: State = {
  count: 0,
  items: [],
};

function reducer(state: Readonly<State>, action: { type: string; payload?: any }): State {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 }; // 返回一个新的状态对象
    case "ADD_ITEM":
      return { ...state, items: [...state.items, action.payload] }; // 返回一个带有更新后 items 的新状态对象
    default:
      return state;
  }
}

3. 处理 API 响应

从 API 获取数据时,通常希望将响应数据视为不可变的,尤其是在您使用它来渲染 UI 组件时。Readonly 类型可以帮助防止对 API 数据的意外修改。


interface ApiResponse {
  readonly userId: number;
  readonly id: number;
  readonly title: string;
  readonly completed: boolean;
}

async function fetchTodo(id: number): Promise<Readonly<ApiResponse>> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  const data: ApiResponse = await response.json();
  return data;
}

fetchTodo(1).then(todo => {
  console.log(todo.title);
  // todo.completed = true; // 错误:无法分配到 'completed',因为它是一个只读属性。
});

4. 建模地理数据(国际化示例)

考虑表示地理坐标。一旦坐标被设定,理想情况下它应该保持不变。这确保了数据的完整性,尤其是在处理敏感应用(如跨不同地理区域运行的地图或导航系统,例如,横跨北美、欧洲和亚洲的快递服务的 GPS 坐标)时。


interface GeoCoordinates {
 readonly latitude: number;
 readonly longitude: number;
}

const tokyoCoordinates: GeoCoordinates = {
 latitude: 35.6895,
 longitude: 139.6917
};

const newYorkCoordinates: GeoCoordinates = {
 latitude: 40.7128,
 longitude: -74.0060
};


function calculateDistance(coord1: Readonly<GeoCoordinates>, coord2: Readonly<GeoCoordinates>): number {
 // 想象使用纬度和经度进行复杂计算
 // 为简化起见,返回占位符值
 return 1000; 
}

const distance = calculateDistance(tokyoCoordinates, newYorkCoordinates);
console.log("Distance between Tokyo and New York (placeholder):", distance);

// tokyoCoordinates.latitude = 36.0; // 错误:无法分配到 'latitude',因为它是一个只读属性。

深度只读类型:处理嵌套对象

Readonly<T> 工具类型只使对象的直接属性变为 readonly。如果一个对象包含嵌套的对象或数组,那些嵌套的结构仍然是可变的。为了实现真正的深度不可变性,您需要递归地将 Readonly<T> 应用于所有嵌套属性。

以下是如何创建一个深度只读类型的示例:


type DeepReadonly<T> = T extends (infer R)[]
  ? DeepReadonlyArray<R>
  : T extends object
  ? DeepReadonlyObject<T>
  : T;

interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}

type DeepReadonlyObject<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
};

interface Company {
  name: string;
  address: {
    street: string;
    city: string;
    country: string;
  };
  employees: string[];
}

const company: DeepReadonly<Company> = {
  name: "Example Corp",
  address: {
    street: "123 Main St",
    city: "Anytown",
    country: "USA",
  },
  employees: ["Alice", "Bob"],
};

// company.name = "New Corp"; // 错误
// company.address.city = "New City"; // 错误
// company.employees.push("Charlie"); // 错误

这个 DeepReadonly<T> 类型递归地将 Readonly<T> 应用于所有嵌套属性,确保整个对象结构都是不可变的。

考量与权衡

虽然不可变性提供了显著的好处,但了解其潜在的权衡也很重要。

不可变数据结构库

有几个库可以简化在 TypeScript 中使用不可变数据结构的工作:

使用 Readonly 类型的最佳实践

为了在您的 TypeScript 项目中有效地利用 readonly 类型,请遵循以下最佳实践:

结论:通过 TypeScript Readonly 类型拥抱不可变性

TypeScript 的 readonly 类型是构建更可预测、可维护和健壮应用程序的强大工具。通过拥抱不可变性,您可以减少错误风险、简化调试并提高代码的整体质量。虽然需要考虑一些权衡,但不可变性的好处通常超过了成本,尤其是在复杂和长期维护的项目中。在您的 TypeScript 旅程中,将 readonly 类型作为开发工作流程的核心部分,以释放不可变性的全部潜力,并构建真正可靠的软件。