《TypeScript编程入门》5.1 interface 定义对象结构

本节从「如何描述一个对象长什么样」这个问题出发,系统讲解 interface 的定义方式与核心能力:必填属性、可选属性、只读属性、方法签名、索引签名以及接口继承。读者将学会用 interface 为真实业务对象建模,理解 excess property check 的触发时机,并掌握常见报错的定位思路。读完本节,你能为 API 响应、配置项、组件 props 等结构化数据写出清晰的类型契约。

本节目标:理解「对象类型」在 TypeScript 中是如何被描述的,掌握 interface 的全部常用语法(必填 / 可选 / 只读 / 方法 / 索引签名 / 继承),并能把一段散落的 any 代码改写成有明确契约的结构化类型。

5.1 interface 定义对象结构

上一节我们学了泛型函数,它解决的是「一份逻辑适配多种类型」的问题。而在真实项目里,更多时候我们面对的是另一种问题:一个对象应该长什么样。比如一个用户对象有哪些字段、一个接口返回的数据结构是什么、一个组件的 props 允许传哪些参数。这一节的主角 interface,就是 TypeScript 给出的最常用答案。

一、为什么需要描述「对象结构」

先看一段没有类型信息的 JavaScript 代码:

function printUser(user) {
  console.log(`${user.name}(${user.age} 岁)来自 ${user.city}`);
}

这段代码在运行时才可能暴露问题:如果调用方传进来的对象没有 city 字段,user.city 就是 undefined,最终打印出 来自 undefined。更糟的是拼写错误——写成 user.nmae 时,JavaScript 不会有任何提示,只会安静地输出 undefined(undefined 岁)。

TypeScript 的思路是:在编译期就把「这个对象应该有哪些字段」写下来,让编辑器与编译器替你检查。这个「写下来」的动作,最常用的工具就是 interface。

interface User {
  name: string;
  age: number;
  city: string;
}

function printUser(user: User): void {
  console.log(`${user.name}(${user.age} 岁)来自 ${user.city}`);
}

现在,如果调用 printUser({ name: 'Ada', age: 36 }),编辑器会立刻报错,而不是等到运行时。这就是「结构描述」带来的第一层价值:把运行时的意外提前到编译期。

二、从对象字面量到 interface

interface 的语法非常直观:interface 名字 { 属性名: 类型; ... }。属性之间用分号(;)或逗号(,)分隔都可以,但工程中更常见的是分号,因为格式化工具(Prettier)默认会统一成一种。

interface Point {
  x: number;
  y: number;
}

// 只要对象满足这个结构,就可以赋值给它
const origin: Point = { x: 0, y: 0 };
const p: Point = { x: 3, y: 4 };

这里有一个关键认知:Point 并不是一个「类」,它不产生任何运行时代码。编译之后,上面这段代码里的 interface Point 会完全消失,只留下 const origin = { x: 0, y: 0 }。类型只存在于编译期,这也是后面 5.3 节要讨论的「结构化类型」的前提。

我们可以用一个表格来对照 interface 与「对象字面量类型」的关系:

写法示例是否可复用是否可继承/合并
内联字面量类型function f(p: { x: number })否否
类型别名type Point = { x: number }是交叉类型(见 5.2)
接口interface Point { x: number }是extends + 声明合并

对于「描述一个对象结构」这一诉求,三者能力高度重叠。初学阶段先记住一句话:能用 interface 就用 interface,遇到联合类型、元组、函数别名等场景再换 type。具体的取舍标准,我们在 5.3 节展开。

三、可选属性与只读属性

现实中的对象往往不是每个字段都必然存在。比如一个用户可能没有填写个人简介,这时候就要用可选属性——在属性名后加一个 ?。

interface UserProfile {
  id: number;
  nickname: string;
  bio?: string;        // 可选:可能不存在
  readonly createdAt: Date;  // 只读:创建后不可修改
}

const u: UserProfile = { id: 1, nickname: 'Ada', createdAt: new Date() };
console.log(u.bio);   // 类型是 string | undefined

u.nickname = 'Ada Lovelace'; // OK
u.createdAt = new Date();    // 报错:Cannot assign to 'createdAt' because it is a read-only property.

两个细节值得强调:

  1. 可选属性不等于「值可以是 undefined」。bio?: string 表示这个字段可以不存在,也可以存在且值为 undefined;而 bio: string | undefined 表示字段必须存在,但值允许是 undefined。在开启 exactOptionalPropertyTypes 严格选项后,这两者的差别会被编译器严格区分。
  2. readonly 只是编译期的约束。它阻止的是「通过这个类型引用去赋值」,并不冻结对象本身。运行时的不可变性需要 Object.freeze 或不可变数据结构来保证。

一个常见组合是「可选 + 只读」,用于描述配置项:

interface RetryOptions {
  readonly maxRetries?: number;
  readonly backoffMs?: number;
}

四、方法签名与函数属性

对象上除了数据字段,还常有行为。在 interface 里描述方法有两种写法,它们在类型层面基本等价,但语义侧重不同。

interface Calculator {
  // 写法一:方法简写(method shorthand)
  add(a: number, b: number): number;
  // 写法二:函数类型属性
  sub: (a: number, b: number) => number;
}

const calc: Calculator = {
  add: (a, b) => a + b,
  sub: (a, b) => a - b,
};
console.log(calc.add(1, 2)); // 3

什么时候必须用函数属性而不是方法简写?当你要把「这个属性本身」当成值来传递、并希望享受严格的函数参数逆变检查时。方法简写形式在参数上是**双变(bivariant)**的,这是为了兼容 JavaScript 中大量「数组回调」式的既有写法而做的历史妥协。对初学者而言,写对象方法用方法简写即可,等遇到回调类型报错时再回来看这一条。

五、索引签名:描述「不确定的键」

有些对象我们事先不知道有哪些键,只知道键与值的类型规律,比如字典、缓存表、环境变量。这时用索引签名。

interface StringMap {
  [key: string]: string;
}

const env: StringMap = {
  NODE_ENV: 'production',
  LOG_LEVEL: 'info',
};
console.log(env.API_URL); // 类型是 string(但运行时可能是 undefined!)

这里藏着初学者最容易踩的坑:索引签名会让所有键都「看起来存在」,即使实际没赋值。上面的 env.API_URL 类型是 string,但运行时是 undefined。如果确实可能缺失,应该把值的类型写成 string | undefined。

另一个坑是索引签名与具名属性冲突:

interface Bad {
  [key: string]: number;
  name: string;   // 报错:Property 'name' of type 'string' is not assignable
                  // to 'string' index type 'number'.
}

因为 name 也是字符串键,它的类型必须能赋值给索引签名的值类型。

六、接口继承与声明合并

interface 相比 type 有两个独有能力:extends 继承与声明合并。

interface BaseEntity {
  id: string;
  createdAt: Date;
}

interface Article extends BaseEntity {
  title: string;
  tags: string[];
}

const a: Article = {
  id: 'a1',
  createdAt: new Date(),
  title: 'TypeScript 入门',
  tags: ['ts'],
};

一个接口可以同时继承多个接口(interface C extends A, B),同名但类型不兼容的属性会报错,这是接口组合的常用手段。到了 6.3 节我们会看到,类也可以用 implements 去实现接口,形成「接口定义契约、类提供实现」的分工。

声明合并指的是:同名 interface 会自动合并成员。

interface Window {
  __APP_VERSION__: string;
}

// 在另一个文件里再次声明同名接口
interface Window {
  __THEME__: 'light' | 'dark';
}

合并后 Window 同时拥有两个字段。这正是 12.3 节「模块扩充与全局类型增强」的技术基础——给第三方库的类型「打补丁」时,靠的就是声明合并。而 type 别名不允许重名,重复声明会直接报错。

七、常见坑:多余属性检查

有一类报错特别容易让人困惑:

interface Point {
  x: number;
  y: number;
}

const p: Point = { x: 1, y: 2, z: 3 };
// 报错:Object literal may only specify known properties,
// and 'z' does not exist in type 'Point'.

这就是 excess property check(多余属性检查)。注意它的触发条件是「直接赋值对象字面量」:

const raw = { x: 1, y: 2, z: 3 };
const p2: Point = raw; // 不报错!因为 raw 不是字面量直赋

为什么会这样?因为结构化类型只要求「至少具备所需成员」,raw 确实具备 x 和 y,所以合法。而直接写字面量时,TypeScript 认为多出来的字段大概率是拼写错误(比如把 y 写成 z),于是主动拦截。理解这条规则,能省下大量「明明结构没错为什么报错」的调试时间。

顺带一提:很多人拿 interface 和 any 做对比,想知道什么时候该放弃类型。可以延伸阅读 any 与 interface 的取舍 。

八、一个真实工程例子:API 响应模型

把本节的知识串起来,为「分页列表接口」建模:

interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

interface PaginationMeta {
  page: number;
  pageSize: number;
  total: number;
}

interface ArticleSummary {
  id: string;
  title: string;
  readonly slug: string;
  tags?: string[];
}

interface ArticleListData {
  list: ArticleSummary[];
  meta: PaginationMeta;
}

type ArticleListResponse = ApiResponse<ArticleListData>;

function renderList(res: ArticleListResponse): string {
  const { list, meta } = res.data;
  return `共 ${meta.total} 篇,本页 ${list.length} 篇`;
}

这里出现了泛型接口 ApiResponse<T>(4.3 节知识的直接应用)、可选属性、只读属性、嵌套接口,以及用类型别名给「泛型实例」起名字。这是工程里最典型的一类建模方式:先定义基础结构,再逐层组合成具体契约。

需要提醒的是,interface 描述的是「我们希望数据长什么样」,它不会在运行时验证数据。后端真的返回了不符合结构的数据时,TypeScript 帮不了你——这属于 13.1 节「类型擦除带来的运行时盲区」的范畴。想在边界处真正校验,需要 Zod 这类运行时校验库,可参考 类型优先的开发方式 与 结构化类型在类型推断中的角色 。

如果你的团队习惯从「先写类型、再写实现」入手,这种风格在社区里被称为 type-first development,本书 13.2 节会给出完整的落地流程。

九、本节要点速查

语法写法用途
必填属性name: string描述必然存在的字段
可选属性bio?: string描述可能缺失的字段
只读属性readonly id: string禁止通过该引用赋值
方法简写add(a: number): number描述对象行为
函数属性sub: (a: number) => number描述可传递的函数值
索引签名[key: string]: string描述字典类结构
接口继承interface A extends B复用并扩展结构
声明合并同名 interface给已有类型打补丁

小结

本节围绕「如何描述对象结构」展开,核心结论有三条:

  1. interface 是描述对象形状的首选工具,语法直观、可复用、可继承、可合并,且不产生任何运行时代码。
  2. 可选属性、只读属性、索引签名各自解决一类建模问题,但都有各自的语义陷阱——可选不等于可为 undefined,索引签名会让所有键「看起来存在」。
  3. interface 只能做编译期检查,运行时数据的真实形状需要额外手段保证。

到这里,我们只掌握了「对象结构」这一种描述能力。当类型需要表达「这个或那个」(联合)、「这个并且那个」(交叉)、或者干脆给元组、函数起个别名时,interface 就力不从心了。下一节 type 别名、联合与交叉 将补齐这块拼图,并给出两者协同使用的工程范式。

阅读导航:上一节:4.3 泛型函数入门 · 下一节:5.2 type 别名、联合与交叉 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes