本节目标:理解「对象类型」在 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.
两个细节值得强调:
- 可选属性不等于「值可以是 undefined」。
bio?: string表示这个字段可以不存在,也可以存在且值为undefined;而bio: string | undefined表示字段必须存在,但值允许是undefined。在开启exactOptionalPropertyTypes严格选项后,这两者的差别会被编译器严格区分。 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 | 给已有类型打补丁 |
小结
本节围绕「如何描述对象结构」展开,核心结论有三条:
interface是描述对象形状的首选工具,语法直观、可复用、可继承、可合并,且不产生任何运行时代码。- 可选属性、只读属性、索引签名各自解决一类建模问题,但都有各自的语义陷阱——可选不等于可为
undefined,索引签名会让所有键「看起来存在」。 interface只能做编译期检查,运行时数据的真实形状需要额外手段保证。
到这里,我们只掌握了「对象结构」这一种描述能力。当类型需要表达「这个或那个」(联合)、「这个并且那个」(交叉)、或者干脆给元组、函数起个别名时,interface 就力不从心了。下一节 type 别名、联合与交叉
将补齐这块拼图,并给出两者协同使用的工程范式。
阅读导航:上一节:4.3 泛型函数入门 · 下一节:5.2 type 别名、联合与交叉 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。