对象属性的进阶
本教程共 80 篇 · 第 26 篇 · 更新于 2026-08-10 · 约 9 分钟阅读
本节目标:理解 readonly 只能保护第一层的原因和应对方式,掌握索引签名的高级约束,建立数字索引和字符串索引的协作直觉。
前两章我们学了接口的基础和继承,但有几个细节值得深入聊聊。这些细节在日常开发中经常碰到,搞不清楚有时候会写出”以为有类型保护实际上没有”的代码。
readonly 的深度限制
我们在第 24 章提过一嘴:readonly 只约束第一层。现在来仔细看这个问题。
interface Settings {
readonly theme: {
mode: string;
fontSize: number;
};
readonly tags: string[];
}
const s: Settings = {
theme: { mode: "dark", fontSize: 14 },
tags: ["ts", "frontend"],
};
// ❌ 不能替换整个 theme 对象
// s.theme = { mode: "light", fontSize: 16 };
// ✅ 但是可以修改它内部的属性!
s.theme.mode = "light";
s.theme.fontSize = 18;
// ❌ 不能替换整个 tags 数组
// s.tags = ["js"];
// ✅ 但是可以修改数组内容!
s.tags.push("react");
s.tags[0] = "javascript";
readonly 在 TypeScript 里是一个”浅层”约束——它只保证引用不被替换,不保证引用指向的内容不被修改。这跟 const 的行为是一致的:const 变量不能被重新赋值,但如果它是对象,内部的属性可以随便改。
真正的深度只读
如果你需要”连嵌套属性也不能改”的效果,TypeScript 内置了一个工具类型 Readonly<T>:
interface Settings {
theme: {
mode: string;
fontSize: number;
};
tags: string[];
}
// Readonly 也是浅层的...
type ShallowReadonlySettings = Readonly<Settings>;
// 需要深度只读的话,自己递归套一层(或用第三方库提供的 DeepReadonly)
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type ImmutableSettings = DeepReadonly<Settings>;
const s: ImmutableSettings = {
theme: { mode: "dark", fontSize: 14 },
tags: ["ts"],
};
// s.theme.mode = "light"; // ❌ 现在连嵌套属性也不能改了
// s.tags.push("react"); // ❌ 数组方法也受限了
Tip
DeepReadonly是映射类型的经典用法。详细的映射类型语法会在后面的章节讲解,这里你只需要知道”readonly 是浅层的,深度只读需要额外工具”就够了。
实际开发里,大多数场景用浅层 readonly 就够了——你通常只需要防止”不小心把整个对象替换掉”,不会在深处偷偷改属性。真有深度不可变的需求,才考虑 DeepReadonly。
索引签名的类型约束
第 24 章介绍了索引签名的基本写法,这里补充几个在实际使用中容易踩的坑。
固定属性必须兼容索引签名
前面提过,这里再强调一次——因为这个规则被触发的频率不低:
// ❌ 这样写会报错
interface Bad {
name: string; // ❌ name 类型是 string
age: number; // ❌ age 类型是 number
[key: string]: string; // 索引签名要求所有值都是 string
}
// 报错:属性 "age" 的类型 "number" 不能赋值给字符串索引类型 "string"
// ✅ 正确的写法:让索引签名足够宽
interface Good {
name: string;
age: number;
[key: string]: string | number;
}
理解方式很简单:当你写了 [key: string]: string,TypeScript 会认为”通过任何字符串键拿到的值都是 string”。那么 Good.name 是通过 "name" 这个字符串键拿到的,自然也必须是 string。age 同样通过 "age" 拿到,也得是 string——所以 number 就不行。
解决办法就是让索引签名的值类型足够宽,能覆盖所有固定属性的类型。
数字索引 vs 字符串索引
TypeScript 支持两种索引键:number 和 string。但两者同时存在时有一个约束:
interface MixedIndex {
[index: number]: Dog; // 数字索引 → 值类型是 Dog
[key: string]: Animal; // 字符串索引 → 值类型是 Animal
// ✅ 要求:Dog 必须能赋值给 Animal
}
class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
规则是:数字索引返回的类型,必须能赋值给字符串索引返回的类型。 这是因为 JavaScript 里 obj[0] 实际上会被转成 obj["0"]——数字索引本质上是字符串索引的一个子集。所以数字索引的值类型必须是字符串索引值类型的子类型。
如果你不需要依赖这个特性,最简单的做法是只用字符串索引,因为它的覆盖面已经包含了数字键。
索引签名与已知方法的冲突
定义索引签名时,别忘了 Object.prototype 上的方法也会参与类型检查:
interface Dictionary {
[key: string]: string;
// toString: () => string; // ✅ 本来就存在,返回 string,没问题
// valueOf: () => number; // ❌ 如果 valueOf 返回 number 就会冲突
}
TypeScript 内置的 Object 方法(如 toString、hasOwnProperty)返回类型通常能兼容常见索引签名,所以大多数时候不会出问题。但如果你用 [key: string]: number,那 toString() 返回的 string 就会冲突——这是个需要知道的潜在坑点。
映射索引(Mapped Index)
[key in KeyType] 的语法叫映射类型(Mapped Types),它是 TypeScript 类型体操的核心武器之一。这里先打个照面,完整的内容留给后面的映射类型章节。
// 把 User 的所有属性变成只读
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
// 把 User 的所有属性变成可选
type PartialUser = {
[K in keyof User]?: User[K];
};
和普通索引签名 [key: string]: T 不同的是,映射索引遍历的是一个已知的键的集合(通过 keyof 拿到),而不是允许任意键。所以它在保持类型精确的同时,能批量修改属性的修饰符(readonly、可选等)。
映射索引只能用 type 定义,不能用 interface——这是 type 相比 interface 的一个显著优势。我们会在第 27 章和第 28 章详细对比。
小结
readonly是浅层约束——只保护引用不被替换,不保护内部属性。- 需要深度只读可用
DeepReadonly<T>映射类型。 - 索引签名的值类型必须能覆盖所有固定属性的类型。
- 数字索引的值类型必须是字符串索引值类型的子类型。
- 映射索引
[K in keyof T]遍历已知键集合并修改修饰符,只能用type定义。
这些细节看起来零碎,但它们是理解 TypeScript 类型系统”为什么这样设计”的关键拼图。下一章我们来看接口之外的另一个重要角色——类型别名(type)。