首页 / TypeScript 入门教程 / 对象属性的进阶

TypeScript 入门教程

对象属性的进阶

本教程共 80 篇 · 第 26 篇 · 更新于 2026-08-10 · 约 9 分钟阅读

TypeScriptTypeScript 入门教程readonly索引签名映射类型

本节目标:理解 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 支持两种索引键:numberstring。但两者同时存在时有一个约束:

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 方法(如 toStringhasOwnProperty)返回类型通常能兼容常见索引签名,所以大多数时候不会出问题。但如果你用 [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)。