TypeScript 对象建模:对象类型、索引签名与只读数据

系列导航:TypeScript 7 现代开发指南
上一篇:类型收窄与安全边界
下一篇:类型组合

对象是 JavaScript 应用最常见的数据载体:组件参数、接口响应、配置项和领域模型最终都要落到属性上。TypeScript 7.0.2 能从对象字面量推断结构,也允许我们用可选属性、只读属性、索引签名和 Record 明确约束。真正重要的不是把每个对象写得越复杂越好,而是准确表达“哪些属性固定、哪些键动态、谁可以修改数据”。

一、从对象字面量开始理解类型推断

TypeScript 会根据初始化值推断对象的属性名和属性类型:

const draft = {
  title: 'TypeScript 对象建模',
  published: false,
  views: 0,
}

// 推断为 string、boolean 和 number
draft.title = '对象类型实战'
draft.published = true

这里的 const 只禁止给变量 draft 重新赋值,并不会让对象属性自动变成只读,也不会把 title 永远限制为最初的字符串字面量。

当对象是公开 API、函数返回值或领域数据时,显式声明契约通常更清晰:

interface ArticleDraft {
  title: string
  published: boolean
  tags: string[]
}

const draft: ArticleDraft = {
  title: 'TypeScript 对象建模',
  published: false,
  tags: ['TypeScript'],
}

如果希望检查对象是否满足某个类型,同时尽量保留表达式自身的推断结果,可以使用 satisfies

interface BuildOptions {
  mode: 'development' | 'production'
  minify: boolean
}

const options = {
  mode: 'production',
  minify: true,
} satisfies BuildOptions

// options.mode 保留为更具体的字面量类型 'production'

类型注解强调“这个变量按某个类型使用”,satisfies 强调“检查这个表达式满足某个类型”。两者都比无依据的类型断言更可靠。

二、必选、可选与只读属性

对象类型默认要求声明过的属性全部存在。属性名后的 ? 表示该属性可以缺省,readonly 表示通过这个类型观察对象时不能重新赋值:

interface UserProfile {
  readonly id: string
  name: string
  avatarUrl?: string
}

const user: UserProfile = {
  id: 'u_001',
  name: '小林',
}

user.name = '林同学'
// user.id = 'u_002' // 错误:id 是只读属性

读取可选属性时,必须考虑它不存在的情况:

function avatarText(profile: UserProfile): string {
  return profile.avatarUrl?.toUpperCase() ?? 'NO AVATAR'
}

在推荐的严格配置下,还可以开启 exactOptionalPropertyTypes,区分“属性没有出现”和“属性存在但值为 undefined”:

{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true
  }
}

开启后,avatarUrl?: string 允许省略 avatarUrl,但不自动允许显式写入 avatarUrl: undefined。如果业务确实需要后一种状态,应明确写成 avatarUrl?: string | undefined

需要注意,readonly 是赋值检查,不是运行时冻结机制;它也只作用于当前属性这一层。更深层的只读数据将在本文后面单独讨论。

三、额外属性检查不是“精确对象类型”

把新鲜的对象字面量直接交给目标类型时,TypeScript 会检查多写的属性。这能很好地捕获拼写错误:

interface ConnectionOptions {
  endpoint: string
  timeout?: number
}

function connect(options: ConnectionOptions) {
  console.log(options.endpoint)
}

connect({
  endpoint: '/api',
  tiemout: 3000,
  // 错误:对象字面量包含未知属性 tiemout
})

但额外属性检查不是一般意义上的“对象只能有这些键”。如果值先存入变量,只要它至少具备目标类型要求的结构,额外属性通常不会阻止赋值:

const options = {
  endpoint: '/api',
  tiemout: 3000,
}

connect(options) // 结构上满足 ConnectionOptions,拼写错误可能被漏掉

配置对象适合在声明处使用 satisfies,让错误尽早暴露:

const safeOptions = {
  endpoint: '/api',
  timeout: 3000,
} satisfies ConnectionOptions

不要用 as ConnectionOptions 压掉检查。断言表达的是“开发者掌握了编译器不知道的信息”,不是修复对象结构错误的工具。

四、索引签名:为动态键设置边界

当属性名来自用户输入、环境变量或后端字段时,无法提前枚举所有键,可以使用索引签名:

interface Environment {
  [key: string]: string | number | boolean | undefined
  MODE: 'development' | 'production'
  PORT?: number
}

const env: Environment = {
  MODE: 'production',
  PORT: 8080,
  FEATURE_SEARCH: true,
}

字符串索引签名表示:所有字符串键对应的值都必须落在声明的值类型中。因此,显式属性也必须兼容索引签名:

interface BrokenEnvironment {
  [key: string]: string | number
  // enabled: boolean // 错误:boolean 不属于索引签名的值类型
}

不要为了省事让索引签名返回 any,这会使任意属性访问失去检查。数据真正未知时,使用 unknown 并在读取后收窄:

type Metadata = Record<string, unknown>

function readString(meta: Metadata, key: string): string | undefined {
  const value = meta[key]
  return typeof value === 'string' ? value : undefined
}

如果动态值只可能来自有限集合,就把集合精确写出来:

type FieldValue = string | number | boolean | null

interface FormValues {
  [field: string]: FieldValue
}

对于不保证键一定存在的字典,建议同时开启 noUncheckedIndexedAccess。这样读取索引签名时,类型会把可能缺失的 undefined 计算在内。

五、用 Record 表达键和值的映射

Record<Keys, Value> 是描述映射对象的常用工具类型。键集合已知时,它可以要求每个键都存在:

type Permission = 'read' | 'write' | 'delete'

const permissions: Record<Permission, boolean> = {
  read: true,
  write: true,
  delete: false,
}

漏掉 delete 或多写未知键,都会在对象字面量处得到提示。若只保存部分键,可以组合 Partial

const overrides: Partial<Record<Permission, boolean>> = {
  delete: true,
}

键完全动态时,也可以使用 Record<string, Value>

type PriceTable = Record<string, number>

const prices: PriceTable = {
  apple: 8,
  banana: 5,
}

Record<string, number> 不代表任意字符串在运行时都真的存在。开启 noUncheckedIndexedAccess 后,prices[name] 会得到更符合现实的 number | undefined

选择方式可以归纳为:

场景 推荐写法
固定属性,各属性类型可能不同 interface 或对象类型别名
有固定属性,也允许动态属性 显式属性 + 索引签名
有限键集合到同一种值的映射 Record<KeyUnion, Value>
有限键集合,但只出现一部分 Partial<Record<KeyUnion, Value>>
值来自不可信边界 Record<string, unknown>,读取后收窄

六、只读对象与只读数组

除了在单个属性上写 readonly,还可以使用 Readonly<T> 创建浅只读视图:

interface Settings {
  theme: 'light' | 'dark'
  editor: {
    fontSize: number
  }
}

const settings: Readonly<Settings> = {
  theme: 'dark',
  editor: { fontSize: 16 },
}

// settings.theme = 'light' // 错误
settings.editor.fontSize = 18 // 允许:Readonly 只处理第一层

数组可使用 readonly T[]ReadonlyArray<T>,表明函数不会修改调用者的数据:

function firstTag(tags: readonly string[]): string | undefined {
  return tags[0]
}

const tags: ReadonlyArray<string> = ['TypeScript', 'JavaScript']
// tags.push('Web') // 错误:只读数组没有可变更方法

这类只读约束仍然只存在于类型系统。需要运行时阻止修改时,可以根据业务使用 Object.freeze();它默认同样是浅冻结。深只读类型和深冻结都要处理数组、函数、集合以及循环引用,不应把一个简单递归类型当成万能方案。

只读尤其适合输入参数和共享状态快照,因为它能直接表达函数的数据所有权:

function renderProfile(profile: Readonly<UserProfile>) {
  return `${profile.id}: ${profile.name}`
}

七、结构类型:看能力,而不是看名字

TypeScript 的对象类型主要采用结构化类型系统。只要属性结构兼容,类型名称和创建方式可以不同:

interface Point {
  x: number
  y: number
}

function distanceFromOrigin(point: Point): number {
  return Math.hypot(point.x, point.y)
}

const pixel = {
  x: 3,
  y: 4,
  color: '#0057b8',
}

distanceFromOrigin(pixel) // 5

这让普通对象、类实例和第三方数据可以自然协作,也解释了为什么变量中的额外属性通常不会造成错误。类型兼容关注调用方真正需要的成员,而不是要求两个对象由同一个声明创建。

结构化不等于“所有对象都能互换”。属性类型、可选性、只读视图和函数签名仍需兼容;类的 privateprotected 以及 JavaScript 私有字段还会对兼容性施加额外限制,后续“类与契约”一篇会继续展开。

八、实战:设计可扩展的更新请求

下面的更新请求同时使用只读标识、有限值联合和动态字段:

type ProfileFieldValue = string | number | boolean | null

interface ProfileUpdate {
  readonly userId: string
  fields: Record<string, ProfileFieldValue>
}

function createUpdate(
  userId: string,
  fields: Readonly<Record<string, ProfileFieldValue>>,
): ProfileUpdate {
  return {
    userId,
    fields: { ...fields },
  }
}

const update = createUpdate('u_001', {
  displayName: '小林',
  age: 20,
  newsletter: true,
})

这里没有使用 any:调用方只能提交协议允许的值;函数用只读输入声明自己不会改写调用者的对象,再复制一份数据交给返回结果。若字段名本身也是固定集合,可以把 Record<string, ...> 进一步收紧为 Partial<Record<ProfileField, ...>>

真实 API 响应仍应先作为 unknown 进入系统,并通过运行时校验后再成为 ProfileUpdate。TypeScript 类型不会自动验证网络数据。

九、常见陷阱

1. 把 objectObject{} 当作业务模型

这些类型范围过宽,不能表达对象有哪些可用属性。业务数据应声明具体结构;未知键值映射优先用 Record<string, unknown>

2. 用 any 索引签名掩盖不确定性

any 会把一次动态读取传播成不受检查的值。优先使用精确联合;确实未知时使用 unknown 并收窄。

3. 误以为额外属性检查会封闭对象

它主要针对新鲜对象字面量,TypeScript 的一般赋值规则仍是结构兼容。需要验证配置声明时,使用类型注解或 satisfies

4. 误以为 readonly 会冻结数据

readonly 默认是浅层、编译期约束。它既不递归,也不会自动生成 Object.freeze()

5. 滥用类型断言

as SomeType 可以绕过本应暴露的拼写和缺失属性错误。先修正数据或添加运行时校验,只在确有额外事实时使用断言。

小结

对象建模的核心是让类型贴近数据所有权和运行时事实:让推断处理局部实现,用显式结构描述公开契约;用可选属性表达缺省,用 readonly 表达不可重新赋值;用索引签名或 Record 描述动态映射,并以 unknown 或精确联合代替 any。同时要记住,额外属性检查不是精确对象类型,TypeScript 的常规兼容规则仍然以结构为中心。

官方资料

系列导航:目录 · 上一篇 · 下一篇