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 做两件事,递归作用于所有嵌套层级:
- 所有属性/元素变成
readonly - 所有值收窄到字面量类型
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}`>;
as const 不提供运行时保护as const 和 readonly 都是纯编译期的。编译后 readonly 完全消失,as const 也只剩普通对象:
1const config = { mode: "dark" };
运行时改 config.mode 完全有效。要真正的运行时不可变用 Object.freeze(),但注意它也是浅层的。
相关
- 类型基础 ——
readonly的基础语法 - 数组与元组 —— 只读数组与只读元组
- satisfies 与 as const —— 组合用法
- 映射类型 —— 用映射类型实现
DeepReadonly