先说一个我早年间接手的真实项目。前后端联调时,后端返回的字段一会儿叫userId,一会儿叫user_id,前端某个模块拿到的值是字符串,另一个模块却当数字在用。那时候我还在写纯 JavaScript,这种问题只能靠人肉 review 和“约法三章”,但约法三章这东西,在赶工期的时候基本没人记得——谁也没时间边联调边翻接口文档。后来切到 TypeScript,第一次用interface约束后端返回的数据结构,编译期就把这类问题全拦了下来。那一刻我才意识到,接口(interface)在工程里的价值,远不止“给对象加个类型”这么简单,它本质上是代码和代码之间的一份契约。
这篇文章会从接口要解决的问题讲起,拆开最基本的语法、属性修饰符、函数类型接口、可索引接口、类实现接口这些内容,最后聊一聊我在真实项目里整理接口的一些习惯。这一篇定位是“基本使用(一)”,所以重点是讲透最常用的部分,适合正在学 TypeScript 的初学者,也适合写过一段时间但一直靠type走天下的朋友——把接口补上,你会发现自己的类型体系清晰不少。
1. 接口到底解决了什么问题:从一次真实的重构经历说起
很多人第一次看 TypeScript 文档,上来就是interface User { name: string },看完觉得“哦,就是给对象加个类型嘛”,然后就划走了。这其实把接口最核心的价值给忽略了——它解决的问题不是“给变量标个类型”,而是描述一整套数据结构的形状,并且把这个形状变成代码里的公共约定。
1.1 JavaScript 的“自由”带来的连锁反应
纯 JavaScript 里,对象就是键值对的集合,爱怎么加字段就怎么加:
// 一个创建用户的函数,没有任何约束 function createUser(name, age) { return { name: name, age: age, // 手一抖,把 are 拼成了 aer aer: 18 }; } const user = createUser("张三", 25); console.log(user.age); // 25 console.log(user.aer); // 18,直到这行才发现拼错了单个拼写错误在 10 行代码里很容易发现,但放到真实项目里就不是这回事了。一个用户对象可能有十几个字段,分布在七八个模块里被引用,某天后端调整了字段名,前端所有用到的地方全部静默失败——不是报错,而是拿到undefined,然后页面某块区域空白,排查半天找不到原因。
TypeScript 接口解决的就是这种“结构性约束”的缺失。它让你在编译阶段就明确:这个对象有哪些字段、字段分别是什么类型、哪些字段必须存在、哪些可选。一旦有人传错了类型、拼错了字段名,编译器直接红牌。
1.2 接口不是“类型标注”,而是“数据契约”
我习惯把接口看成一份契约书,而不是单纯的技术语法。这份契约约定了三件事:
- 结构契约:对象必须长什么样——有哪些字段、嵌套层级如何。
- 类型契约:每个字段的类型是什么——字符串、数字、还是另一个接口。
- 协作契约:前后端联调、多人并行开发时,照着同一份接口定义写代码。
实际开发里最常见的场景,是前后端约定接口返回值。后端返回的 JSON 结构往往有好几层嵌套,比如分页数据,如果不在前端用接口描述清楚,你会发现每个调用方都自己猜返回结构,猜错了就地 debug:
interface PageResult<T> { list: T[]; total: number; page: number; pageSize: number; } interface UserInfo { id: number; name: string; email: string; isActive: boolean; } // 一个函数返回分页用户列表 async function fetchUsers(page: number): Promise<PageResult<UserInfo>> { const res = await fetch(`/api/users?page=${page}`); return res.json(); }看到Promise<PageResult<UserInfo>>这个返回类型,调用方不用去看接口文档,也大概猜到返回结构是“列表 + 总数 + 页码 + 每页条数”,每一条又是“id、name、email、isActive”。这就是接口作为“契约”带来的沟通效率——代码本身就在描述数据长什么样。
1.3 一个接口定义,全局受益
接口的另一个厉害之处在于,它定义一次可以到处引用。比如一个表单组件,接收的值是一个用户对象,我可以直接写成:
function UserForm({ user }: { user: UserInfo }) { // 这里的 user 有完整的类型提示 }组件内部敲user.时,编辑器会把所有字段列出来,拼错字段名立刻标红。同样的结构在列表页、详情页、编辑页被反复引用,只要字段定义改一处,所有用到的地方全部同步更新类型检查。这份“单一事实来源”的价值,在项目越来越大之后,会越发明显。
2. 接口的核心语法:从最简单的对象约束开始
理解了接口是“数据契约”,接下来就看它怎么写。这一节我会从最基础的写法讲起,逐步加入嵌套结构、数组、组合类型这些实际中马上会用到的东西。别急着一口气把语法全记住,后面每个小节都会配上场景。
2.1 内联类型标注与接口:两种写法的取舍
约束一个对象,其实有两种常见写法,很多新手一开始会把它们混着用。第一种是内联类型标注:
function printUser(user: { name: string; age: number }): void { console.log(`${user.name} is ${user.age} years old`); }这种写法适合一次性的简单场景:函数只在这个文件里用,对象结构也简单,没必要单独定义一个类型名字。第二种就是用接口:
interface User { name: string; age: number; } function printUser(user: User): void { console.log(`${user.name} is ${user.age} years old`); }区别一眼就能看出来:接口给了这个结构一个名字User,可以在多个地方重复引用。把接口当成一个“形状模板”,你会发现三口五口不需要它,真正多人协作的项目里,几乎所有被共享的对象结构都应该抽成接口,否则同一个结构会在十几个地方被重复内联描述,维护成本翻倍。
2.2 基础类型、嵌套结构与数组字段
接口描述的不只是“字符串字段”,它支持所有 TypeScript 类型——基本类型、数组、元组、嵌套接口、联合类型都可以直接往里放。直接看一个更贴近真实业务的例子:
interface Address { province: string; city: string; detail: string; } interface OrderItem { productId: number; productName: string; price: number; quantity: number; } interface Order { orderId: string; createdAt: Date; customer: { name: string; phone: string; }; items: OrderItem[]; // 数组字段 address: Address; // 嵌套接口 status: "pending" | "paid" | "shipped" | "completed"; // 字面量联合类型 remark?: string; // 可选字段 }这里有几个值得注意的点:
- 嵌套接口:
address字段本身就是Address接口,说明接口之间可以互相引用,复杂数据结构可以通过这种方式逐层拆解。 - 数组字段:
OrderItem[]表示“元素类型为 OrderItem 的数组”,这在列表场景里几乎是每天都会遇到的形态。 - 字面量联合类型:
status字段只能是这四个字符串之一,相当于把字段的“合法取值”也定义死了,比单纯写string严谨得多。 - 可选字段:
remark?后面加一个问号,表示该字段可以不存在,比如订单没有备注时后端就不返回这个字段。
可以看到,接口描述数据的能力非常强,复杂的数据结构经过逐层拆解,会变得非常清晰。每次定义接口时,我会把顺序固定下来:先直接写标量字段,再写嵌套对象,最后写数组——阅读起来会顺很多。
2.3 接口的复用:同一形状,多处生效
接口定义完,不只是“装饰”一个函数,它可以直接被变量、函数参数、函数返回值、类、泛型等各类场景引用。举个典型的组合示例:
interface Product { id: number; name: string; price: number; } // 1. 变量标注 const defaultProduct: Product = { id: 1, name: "默认商品", price: 0 }; // 2. 函数参数 function getProductName(product: Product): string { return product.name; } // 3. 函数返回值 function createProduct(name: string, price: number): Product { return { id: Date.now(), name, price }; } // 4. 接口数组 const products: Product[] = [ { id: 2, name: "苹果", price: 5.5 }, { id: 3, name: "香蕉", price: 3.2 } ];同一个Product接口,变量、参数、返回值、数组四处都在用,一旦价格字段要改成price: number配合 currency 使用,只需要改接口定义,编译器就会把所有不符合新结构的地方标出来。这就是接口的杠杆效应——改一处,全项目受益;漏一个,编译器帮你查。
3. 属性修饰符:可选、只读和那些“明明没错却报错”的时刻
接口的基本结构定义了对象长什么样,但真实项目里对象的字段不可能永远是“必须存在、可修改”这么简单。于是 TypeScript 提供了几个属性修饰符,其中最常碰到的就是可选属性?和只读属性readonly。另外,很多人被“额外属性检查”折磨过——对象明明“没问题”,编译器却标红,这一节我专门讲清楚原因。
3.1 可选属性:表示“这个字段不一定在”
后端接口的数据,经常有“有时返回、有时不返回”的字段。比如用户的头像,可能在注册时没设置,后端就不返回这个字段。这种场景用可选属性表达:
interface UserProfile { id: number; username: string; // 头像可能没有 avatar?: string; } function showAvatar(user: UserProfile) { // 可选字段使用时必须做存在性检查 if (user.avatar) { return `<img src="${user.avatar}" />`; } return "<div>暂无头像</div>"; }这里关键的一点是:可选字段的类型其实是“原类型 | undefined”,也就是说avatar?: string等价于avatar: string | undefined。所以使用时不能直接当成字符串用,必须先判断存在与否。新手容易踩的坑就在这里——以为可选字段是“可以不给,给了就是字符串”,直接user.avatar.toUpperCase(),运行时就会炸。
提示:可选属性表示的是“字段可以不存在”,而不是“字段值可以传 null”。如果后端返回的字段是 null,类型得写成
avatar?: string | null,否则类型检查依然过不去。
3.2 readonly:定义初始化后不可修改的字段
有些字段在业务语义上是“创建后就不变了”,比如订单 ID、创建时间、用户 ID。这些字段可以标记为readonly,在任何地方尝试重新赋值,编译器都会报错:
interface Article { readonly id: number; title: string; content: string; readonly publishedAt: Date; } const article: Article = { id: 1, title: "你好", content: "正文", publishedAt: new Date() }; // 这样写会报错:Cannot assign to 'id' because it is a read-only property. // article.id = 2; // 普通字段可以修改 article.title = "修改后的标题";我最常把readonly用于后端返回数据的领域对象(domain model)上——前端拿到这份数据只是用来展示,不应该去篡改 id 这类关键标识。另一个用法是在构造函数里赋值、之后绝不变化的配置对象。需要注意的是,readonly是编译期的约束,不是运行时冻结对象,它只限制 TypeScript 的类型检查,并不会真正阻止 JS 层面的赋值。
3.3 额外属性检查:为什么对象字面量会被“严格执法”
这是新手一定会遇到、而且经常觉得莫名其妙的一个特性。看例子:
interface User { name: string; age: number; } // 场景 1:对象字面量直接赋值 const user: User = { name: "张三", age: 25, // 多了这个字段,编译器直接报错 email: "zhangsan@example.com" };明明 JavaScript 里给对象多塞一个字段是再正常不过的操作,TypeScript 凭什么报错?这就是额外属性检查(Excess Property Check)。当使用对象字面量直接赋值时,TypeScript 会检查“这个字面量是否有目标类型之外的属性”,只要发现多余字段,就会报错:
Object literal may only specify known properties, and 'email' does not exist in type 'User'.为什么设计成这样?因为对象字面量直接赋值时,出现多余字段往往是拼写错误、或用了不该传的数据结构的信号。比如你本意是email,但接口字段叫emial,编译器帮你抓出来,这比运行时炸一句“undefined”要好得多。
但这也带来一个常见困惑:有时候确实需要传一个“额外的字段”。比如从一个函数返回的复杂对象里挑几个字段传给另一个函数,对象变量本身含有接口定义之外的属性。区别在于,当这个对象是通过变量引用而不是字面量直接传入时,TypeScript 就不会做额外属性检查:
const userWithEmail = { name: "张三", age: 25, email: "zhangsan@example.com" }; // 变量引用赋值:不会报错,因为 TypeScript 只检查“包含必需属性” const user2: User = userWithEmail;这里userWithEmail含有多余字段email,但作为变量整体赋给user2时,TypeScript 采用的是结构性类型检查:只要这个对象包含User接口要求的name和age字段,赋值为User类型就成立。那为什么字面量不行?因为编译器想拦住“你可能写错了字段名”这种错误。这是一对看似矛盾、实则互补的设计,理解了它的目的,你就不会觉得 TypeScript 在“故意找茬”了。
4. 接口不只是描述对象:函数类型、数组、类实现
如果说前两节是接口的“基本款用法”,那这一节是接口真正的发力点。接口能描述一切具有“结构形状”的事物——不只是普通对象,还有函数本身、数组和字典,甚至类。先用生活中例子类比:普通对象接口就像一张员工信息表,描述了人的名字、年龄、职位;但“接口描述函数”就好比规定“所有维修师傅上门时必须先出示工牌、再开始维修”的流程——你关心的是这个函数“长什么样、能怎么被调用”,而不是它内部代码怎么写。
4.1 函数类型接口:给函数本身也定规矩
用接口描述函数签名,最直接的写法是:
interface SearchFunc { (source: string, subString: string): boolean; } const search: SearchFunc = function(source, subString) { return source.includes(subString); };这里SearchFunc描述了一个接受两个字符串参数、返回布尔值的函数。任何被声明为SearchFunc类型的变量,都必须符合这个签名。这个写法最大的好处在于,在回调函数、事件处理器这类场景中,接口规定了“函数必须长什么样”,调用时就能拿到完整的参数提示。
实战里我更常用定义回调集合的场景。比如封装一个工具库,对外暴露的 API 可以接受不同的回调函数:
interface Validator { // 参数是一个任意值,返回 boolean 表示是否通过校验 (value: unknown): boolean; } const validators: Record<string, Validator> = { email: (value) => typeof value === "string" && value.includes("@"), phone: (value) => typeof value === "string" && /^1\d{10}$/.test(value) };validators.email就可以直接被当成返回 boolean 的函数使用,参数和返回值都有类型保障——比随便写一个Function类型严谨很多,因为Function类型完全不限制参数个数和返回类型。
4.2 可索引接口:描述数组和字典对象
另一种“形状”是可以通过索引访问的数据结构,典型的就是数组和字典。可索引接口用[index: string]或[index: number]这类签名描述:
interface StringArray { [index: number]: string; } const names: StringArray = ["张三", "李四"]; // 数字索引取出来的值一定是字符串 const firstName: string = names[0];同样,字典对象(key-value 结构)也可以用可索引接口描述,这在描述后端返回的映射数据时特别实用:
interface ErrorMessages { // 键是字符串,值也是字符串 [field: string]: string; } const errors: ErrorMessages = { username: "用户名不能为空", email: "邮箱格式不正确" };可索引接口的核心价值在于:当数据结构的“形状”不是固定的字段,而是一组同类型的键值对时,用可索引接口比列出每一个字段更合适。比如后端返回一个Record<string, number>表示各商品的库存数量,商品种类随时在变,没法预知所有字段名,这就是可索引接口的主场。
4.3 implements:让类遵循接口契约
接口用于约束“类的实例该有哪些成员”时,用implements关键字:
interface Animal { name: string; speak(): void; } class Dog implements Animal { name: string; constructor(name: string) { this.name = name; } speak(): void { console.log(`${this.name} 汪汪叫`); } } const dog = new Dog("旺财"); dog.speak();这里Dog类承诺了Animal接口的结构——必须有name属性和speak方法,类里一旦漏写或写错签名,编译器马上报错。这在依赖倒置、面向接口编程时很有用:多个类实现同一个接口,调用方只依赖接口而非具体类,就能统一处理不同实现。
interface Logger { log(message: string): void; error(message: string): void; } class ConsoleLogger implements Logger { log(message: string): void { console.log(`[INFO] ${message}`); } error(message: string): void { console.error(`[ERROR] ${message}`); } } class FileLogger implements Logger { log(message: string): void { // 写入日志文件 } error(message: string): void { // 写入错误日志文件 } }两个不同的Logger实现,对外暴露的方法签名完全一致——这样依赖Logger接口的代码就不用关心具体是控制台还是文件,只需要“调用log和error”。这就是接口在类设计层面最重要的用途:**解耦”。
5. 接口 vs 类型别名:动手写代码前先想清楚用哪个
TypeScript 里另一个经常混淆的概念是type(类型别名)。两个都能描述对象结构,新手常常问“到底用哪个”。老实说,这个问题没有绝对答案,但有一个决策框架,我在项目里用了挺久,基本稳。
5.1 两者都能做对象结构:先看能力对比
先看对比表格,这里梳理的是最常见的差异:
| 对比点 | interface | type |
|---|---|---|
| 描述对象结构 | 支持 | 支持 |
| 继承/扩展 | 用extends | 用&(交叉类型) |
| 合并声明 | 支持(同名合并) | 不支持 |
| 描述基本类型别名 | 不支持 | 支持 |
| 描述元组 | 不支持直接描述 | 支持 |
| 描述联合类型 | 不支持 | 支持 |
| 性能 | 无显著差异 | 无显著差异 |
举个类型别名做交叉类型和联合类型的例子:
type ID = string | number; // 联合类型 type Point = { x: number } & { y: number }; // 交叉类型 type DataPair = [string, number]; // 元组这些用interface都不容易直接表达。所以当目标不只是“对象结构”,而是要定义联合类型、元组、基本类型别名时,type是更合适的工具。
5.2 我的选型习惯:公共契约用 interface,复杂计算用 type
我的经验可以缩成一句话:用来做数据契约、跨文件共享、描述领域模型时优先 interface;需要联合类型、元组、工具类型转换时用 type。
理由也很简单:
interface天然适合描述“这个对象有这些字段”,语义更清晰,extends的继承关系可读性比type的交叉符号好。interface支持声明合并(declaration merging),也就是同名接口会自动合并。这在扩展第三方库类型、给全局对象补充类型时特别有用,type做不到。type在处理联合/交叉/元组时更灵活,如果对象结构本身很复杂、需要通过交叉组合多个类型,type的表达更直接。
举一个综合场景说明决策过程:用户可能有个人账号或企业账号两种,用联合类型表达比接口更清爽:
interface PersonalAccount { type: "personal"; name: string; personalId: string; } interface EnterpriseAccount { type: "enterprise"; companyName: string; creditCode: string; } // 联合类型:账户要么是个人,要么是企业 type Account = PersonalAccount | EnterpriseAccount; function getAccountLabel(account: Account): string { if (account.type === "personal") { return `个人账户:${account.name}`; } return `企业账户:${account.companyName}`; }这里用type联合两个interface完全没问题,因为每个账户类型的“形状”依然由interface描述,联合的职责交给type。两者是组合关系,不是替代关系。
5.3 一个常见坑:同名接口的声明合并
interface声明合并(declaration merging)是个隐藏能力:同名接口会在同一作用域里自动合并成员。
interface WindowConfig { theme: string; } // 在另一个文件里再声明一次同名接口 interface WindowConfig { language: string; } // 最终的 WindowConfig 同时拥有 theme 和 language const config: WindowConfig = { theme: "dark", language: "zh-CN" };这个机制在扩展全局类型、第三方库类型时很强大,但它也带来隐患:误声明同名接口时不会报错,而是静默合并。所以在团队规范里,我一般会建议不要在图方便时随意给接口重名,尤其是在多人协作的全局类型声明文件里——一个变量名多个出处,排查起来比较痛苦。
6. 我在真实项目里整理接口的三条实战经验
语法部分到这里基本覆盖了“基本使用(一)”的核心内容。最后一节分享三个我在真实项目中总结的经验,不是文档里会写的东西,但挺实用。
6.1 接口命名与文件组织:按领域模型聚合,别按页面拆
很多新手会把接口按“页面”组织:HomePageTypes.ts、UserPageTypes.ts……结果同一个用户对象在多个页面重复定义,改一处漏一处。我的习惯是按领域模型组织:user.ts、order.ts、product.ts。每个文件导出该领域的核心接口,页面代码直接订阅这些接口。
// src/types/user.ts export interface UserProfile { id: number; username: string; avatar?: string; } export interface UserListParams { page: number; pageSize: number; keyword?: string; } export interface UserListResult { list: UserProfile[]; total: number; }命名规范上,接口名不加I前缀(IUser这种老 Java 风格已经过时),直接叫User、Order、Product。这样做的好处是代码读起来自然,且不会和类名混淆。
6.2 接口与后端数据结构对齐:让后端定义成为唯一事实来源
前端接口的类型定义,最好严格对齐后端返回的 JSON 结构,不要自己“美化”字段名。后端用user_id,前端类型里就叫user_id: number,而不是翻译成userId——否则后端一变名,前端又得全局改一遍。
当然更推荐的做法是:如果团队有条件,直接基于 OpenAPI(Swagger) 自动生成前端接口类型。很多工具能从后端的 API 文档生成 TypeScript 类型定义,这样前后端天然共享同一个结构。没有自动化条件的项目,至少要保证手工维护的接口定义和后端文档同步,建议在代码 review 时把接口定义和后端变更一并核对。
6.3 别过度抽象:接口也不是越多越好
接口虽好,也不是非要处处使用。一个只有 20 行代码的工具函数,参数只有一个简单对象,直接内联类型标注就行,不用抽接口;一个只在此模块内部使用的临时结构体,也可以用type直接定义一个别名。接口是为“共享”而生的,如果一个类型只在一个文件的一个函数里用一次,那么它不该占用公共接口文件里的位置。
判断标准其实很简单:这个结构是否会被两个以上地方用到?是否用于描述领域模型?是否会跟随接口文档变化?这三个问题至少两个回答“是”,才值得抽成接口。
提示:我在团队里立了一个不成文的规矩——定义接口先看目录里有没有同领域的类型文件,有就往里加,没有就新建,绝不随手塞进业务页面代码里。前三个月大家觉得麻烦,半年后接口类型文件百来行,却没有一个人喊乱。
这篇先聊到这。接口的继承、泛型接口、面向接口编程的进阶玩法,会放在“基本使用(二)”里继续展开。如果你在项目里有自己的接口整理习惯,或者踩到过什么接口相关的坑,欢迎在评论里聊聊——写 TypeScript 这事,真的是互相换踩坑报告才能进步。