只读与不可变

2026-09-09 00:00    #typescript  

readonly 和 as const 都表达"不可修改",但作用范围差别很大:readonly 是浅层的,as const 是递归的。搞错这一点会写出"以为保护了其实没保护"的类型。

readonly 是浅层的

 1// readonly 的深浅边界
 2
 3interface Config {
 4  readonly name: string;          // 顶层只读
 5  nested: {
 6    readonly value: number;       // 需要显式标记
 7  };
 8  list: readonly number[];        // 只读数组
 9}
10
11const c: Config = { name: "a", nested: { value: 1 }, list: [1, 2] };
12
13// @ts-expect-error TS2540: 顶层只读
14c.name = "b";
15// @ts-expect-error TS2540: 嵌套里显式标记了 readonly
16c.nested.value = 2;
17// @ts-expect-error TS2339: 只读数组没有 push
18c.list.push(3);
19
20// 但只读是浅层的:没标记的地方可以改
21c.nested = { value: 3 };         // 允许!nested 本身不是 readonly
22// 只读数组的元素是只读的,但数组引用本身(list)不是
23
24// ---- Readonly<T> 也是浅层的 ----
25type ShallowReadonly = Readonly<{ a: { b: number } }>;
26const s: ShallowReadonly = { a: { b: 1 } };
27// @ts-expect-error TS2540: 顶层只读
28s.a = { b: 2 };
29s.a.b = 3;                       // 允许!浅层 Readonly 不影响嵌套
30
31// ---- 深度只读需要自己写 ----
32type DeepReadonly<T> = {
33  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
34};
35
36type Deep = DeepReadonly<{ a: { b: { c: number } } }>;
37const d: Deep = { a: { b: { c: 1 } } };
38// @ts-expect-error TS2540: 深层只读
39d.a.b.c = 2;
40
41// ---- readonly 与可变类型的赋值方向 ----
42const mutable: number[] = [1, 2];
43const readonlyArr: readonly number[] = mutable;    // ✓ 可变 → 只读
44// @ts-expect-error TS4104: 只读不能赋给可变
45const back: number[] = readonlyArr;
46
47// ---- 函数参数的 readonly 表达"我不修改它" ----
48function sum(nums: readonly number[]): number {
49  return nums.reduce((a, b) => a + b, 0);
50}
51sum(mutable);
52sum(readonlyArr);
53
54console.log(c.nested.value, c.list, s.a.b, d.a.b.c, sum(mutable), sum(readonlyArr));
13 [ 1, 2, 3 ] 3 2 3 3

三种用法:

1interface Config {
2  readonly name: string;          // 属性只读
3  nested: {
4    readonly value: number;       // 需要显式标记,不会自动继承
5  };
6  list: readonly number[];        // 只读数组
7}

关键在于每一层都要单独标记:

1// @ts-expect-error TS2540: 顶层只读
2c.name = "b";
3// @ts-expect-error TS2540: 嵌套里显式标记了
4c.nested.value = 2;
5// @ts-expect-error TS2339: 只读数组没有 push
6c.list.push(3);
7
8c.nested = { value: 3 };     // 允许!nested 本身不是 readonly

Readonly<T> 工具类型同样是浅层的:

1type ShallowReadonly = Readonly<{ a: { b: number } }>;
2
3const s: ShallowReadonly = { a: { b: 1 } };
4s.a = { b: 2 };    // 错误:顶层只读
5s.a.b = 3;         // 允许!嵌套不受影响

深度只读要自己写递归映射类型:

1type DeepReadonly<T> = {
2  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
3};

赋值方向:可变 → 只读可以,反之不行

1const mutable: number[] = [1, 2];
2const readonlyArr: readonly number[] = mutable;    // ✓
3
4const back: number[] = readonlyArr;
5// error TS4104: The type 'readonly number[]' is 'readonly' and cannot be
6// assigned to the mutable type 'number[]'.

这个方向限制让 readonly 参数有了实用价值——表达"我不会修改这个入参":

1function sum(nums: readonly number[]): number {
2  return nums.reduce((a, b) => a + b, 0);   // 编译期保证不修改
3}
4
5sum([1, 2]);        // 字面量可以传
6sum(mutable);       // 可变数组也可以传

as const 是递归的

 1// as const 的深度:递归应用于所有嵌套
 2
 3const config = {
 4  mode: "dark",
 5  level: 1,
 6  tags: ["a", "b"],
 7  nested: { deep: { value: 1 } },
 8} as const;
 9
10// 所有属性变成只读字面量类型
11const mode: "dark" = config.mode;
12const level: 1 = config.level;
13const tag: "a" = config.tags[0];        // 元组,不是数组
14const deep: 1 = config.nested.deep.value;
15
16// @ts-expect-error TS2540: 全部只读
17config.mode = "light";
18// @ts-expect-error TS2339: 数组也是 readonly 元组
19config.tags.push("c");
20
21// as const 让数组变成只读元组
22type Tags = typeof config.tags;         // readonly ["a", "b"]
23const tagsCheck: readonly ["a", "b"] = config.tags;
24
25// ---- 对比:不用 as const ----
26const loose = {
27  mode: "dark",
28  tags: ["a", "b"],
29};
30// mode 的类型是 string,不是 "dark"
31const looseMode: string = loose.mode;
32// tags 的类型是 string[],可以 push
33loose.tags.push("c");
34
35// ---- as const 与显式 readonly 的区别 ----
36// as const:递归 + 字面量类型
37const a1 = { x: 1 } as const;
38// 显式 readonly:只作用于标记的那一层,且不改变类型宽度
39type RO = { readonly x: number };
40const a2: RO = { x: 1 };
41// a2.x 是 number(不是 1),a1.x 是 1
42
43// ---- 从 as const 的对象推导联合类型 ----
44const DIRECTIONS = ["up", "down", "left", "right"] as const;
45type Direction = (typeof DIRECTIONS)[number];    // "up" | "down" | "left" | "right"
46
47const d1: Direction = "up";
48// @ts-expect-error TS2322: 不在联合里
49const d2: Direction = "sideways";
50
51// ---- as const 与 satisfies 组合 ----
52// as const 保证只读和字面量,satisfies 保证结构检查
53const ROUTES = {
54  home: "/",
55  about: "/about",
56} as const satisfies Record<string, `/${string}`>;
57
58type RouteKey = keyof typeof ROUTES;    // "home" | "about"
59
60console.log(mode, level, tag, deep, tagsCheck, looseMode, loose.tags, a1.x, a2.x, d1, d2);
61console.log(ROUTES.home, ROUTES.about);
62const routeKey: RouteKey = "home";
63console.log(routeKey);
1dark 1 a 1 [ 'a', 'b', 'c' ] dark [ 'a', 'b', 'c' ] 1 1 up sideways
2/ /about
3home

as const 做两件事,递归作用于所有嵌套层级:

  1. 所有属性/元素变成 readonly
  2. 所有值收窄到字面量类型
1const config = {
2  mode: "dark",
3  tags: ["a", "b"],
4  nested: { deep: { value: 1 } },
5} as const;
6
7const mode: "dark" = config.mode;          // 字面量类型,不是 string
8const tag: "a" = config.tags[0];           // 数组变成只读元组
9const deep: 1 = config.nested.deep.value;  // 深层也是字面量

数组会变成只读元组:

1type Tags = typeof config.tags;    // readonly ["a", "b"]

两者的对比

readonly / Readonly<T>as const
作用范围浅层(标记的那一层)递归(所有嵌套)
改变类型宽度否是(收窄为字面量)
数组结果readonly T[]readonly [a, b] 元组
用途声明接口时标记只读字段定义常量、推导联合类型
1const a1 = { x: 1 } as const;      // a1.x 类型是 1,只读
2type RO = { readonly x: number };
3const a2: RO = { x: 1 };           // a2.x 类型是 number,只读

常用套路:从常量推导联合类型

这是 as const 最有价值的用法:

1const DIRECTIONS = ["up", "down", "left", "right"] as const;
2type Direction = (typeof DIRECTIONS)[number];
3// "up" | "down" | "left" | "right"

好处是单一数据源:数组和类型不会不一致。加一个方向只需改数组。

as const satisfies 组合可以同时获得只读、字面量和结构检查:

1const ROUTES = {
2  home: "/",
3  about: "/about",
4} as const satisfies Record<string, `/${string}`>;

详见 satisfies 与 as const。

as const 不提供运行时保护

as const 和 readonly 都是纯编译期的。编译后 readonly 完全消失,as const 也只剩普通对象:

1const config = { mode: "dark" };

运行时改 config.mode 完全有效。要真正的运行时不可变用 Object.freeze(),但注意它也是浅层的。

相关