如果你已经跟着上篇把 TypeScript 环境跑通能顺利写出带基础类型标注的变量和函数那恭喜你真正决定 TypeScript 水平的分水岭来了接口、类、泛型。这三个概念几乎承包了日常业务开发里 80% 的类型设计问题也是面试官最爱追问的“三连击”。我见过不少朋友基础类型学得挺顺一进入抽象概念就开始懵。原因很简单接口、类、泛型不是孤立的知识点它们共同回答的是“我该怎么描述我的数据结构”和“我该怎么让代码在保持灵活的同时又不丢类型信息”。这篇就把这三块彻底拆开讲明白从“是什么”到“为什么”再到实战中那些容易被忽略的细节尽量一次说透。这篇文章适合三类读者已经会写 TS 基础代码但总觉得类型设计混乱的前端开发封装过公共组件或工具库、想进一步收敛类型的进阶玩家以及准备 TS 面试、想系统过一遍核心知识点的求职者。上篇偏“认识工具”这篇偏“搭建体系”读完你会有一种“原来类型可以这样设计”的通透感。1. 接口类型世界的形状契约1.1 先理解“结构类型”这个心智模型很多人第一次接触 interface 时容易把它和“面向对象里的接口”混在一起觉得必须是 class 才能 implements 它。其实 TypeScript 的接口最核心的作用就一个描述一个对象“长什么样”。比如你要写一个用户相关的功能后端返回的用户数据里有 name、age、email 三个字段你可以随手定一个接口interface User { name: string; age: number; email: string; } function greet(user: User) { return 你好${user.name}今年 ${user.age} 岁; }这里的 User 不关心你从哪来、用什么类实例化的只要对象结构里包含这些字段、类型对得上就能通过类型检查。这就是 TypeScript 的“结构类型系统”也叫鸭子类型长得像鸭子、叫声像鸭子那就是鸭子。理解这一点很重要因为很多人写 TS 时脑子里还是 Java 或 C# 那套“名义类型系统”的思维总觉得“必须显式声明我实现了谁”结果写出了大量无意义的接口继承和 class 包装。在 TS 里你定义一个对象字面量直接传给函数只要结构匹配编译器就认。我经常跟团队里新同学说接口是“形状”不是“身份”。1.2 接口的四个实用能力可选、只读、索引、函数签名实际开发中接口很少只有几个固定字段那么简单。最常见的四个扩展能力我一个个说清楚。第一可选属性。用?表示某个字段可能不存在比如用户头像在注册初期是空的interface User { name: string; age: number; email: string; avatar?: string; }第二只读属性。用readonly修饰字段只能在对象创建时赋值之后不允许修改。比如配置类对象初始化后就不该被篡改interface AppConfig { readonly apiBaseUrl: string; readonly version: string; } const config: AppConfig { apiBaseUrl: https://api.example.com, version: 1.0.0, }; // config.apiBaseUrl xxx; // 报错无法分配到 apiBaseUrl因为它是只读属性第三索引签名。当你不确定对象有哪些具体属性名但确定属性值的类型时可以用[key: string]: 类型来描述。典型场景是字典、映射表interface HttpHeaders { [key: string]: string; } const headers: HttpHeaders { Content-Type: application/json, Authorization: Bearer xxx, }; // headers[X-Custom-Header] value; // OK第四函数签名。函数本身也是一种可以被描述的对象接口可以直接定义函数类型interface FormatFunc { (value: number, locale: string): string; } const formatNumber: FormatFunc (value, locale) new Intl.NumberFormat(locale).format(value);这四个能力覆盖了绝大多数对象类型描述场景而且彼此可以自由组合。你定义一个接口时先想清楚哪些字段是业务刚需、哪些可能缺省、哪些需要防篡改再决定用哪些修饰符。1.3 同名接口会自动合并这是 type 做不到的TypeScript 里有个很容易被忽略的机制同名接口会进行声明合并。比如你定义了一个接口又在另一个文件里追加字段它们最终会合并成一个interface Window { title: string; } interface Window { appName: string; } // 等价于 // interface Window { // title: string; // appName: string; // }这个特性在实际项目中非常有用。最常见的是给第三方库补充类型库自带的类型定义里没有某个全局方法你可以用同名接口“打补丁”而不需要修改 node_modules 里的声明文件。Vue 项目里扩展Window、给ProcessEnv加自定义环境变量都是这个套路。注意一点type别名不支持这种声明合并。如果你用type Window { ... }再定义第二个type Window { ... }编译器会直接报错“标识符重复”。所以当你有“扩展已有类型”的需求时优先考虑 interface。1.4 接口和 type 到底怎么选“interface 和 type 有什么区别”是 TS 面试里的经典送分题但很多人的答案只停留在“都能描述对象类型”。实际选型时我更倾向于按场景分对比维度interfacetype描述对象类型支持支持联合类型 / 交叉类型不支持支持字符串字面量联合不支持支持映射类型 / 条件类型不支持支持声明合并支持不支持在大多数场景的类型提示错误信息更友好错误信息稍复杂社区主流工具库多数优先 interface复杂类型常用 type拿一个例子说明联合类型的场景某个状态字段可能的值是loading | success | error只能用 type 定义type Status loading | success | error; interface ApiResponseT { status: Status; data: T; }再比如交叉类型把两个对象类型合并通常写type Combined A Binterface 没有对应的“合并”语法虽然 extends 也能实现类似效果但语义不太一样。我的个人建议是描述对象结构、需要扩展合并、要给类提供契约时优先用 interface需要联合类型、交叉类型、工具类型推导时用 type。一个项目里两套可以混用但保持风格一致。2. 类把面向对象写进类型系统2.1 先从属性声明和参数属性说起TypeScript 的类不是凭空发明的它是在 ES6 class 基础上加了类型层。最大的区别是TS 要求你在类里先声明属性的类型并且要满足严格的属性初始化检查strictPropertyInitialization。class Person { name: string; age: number; constructor(name: string, age: number) { this.name name; this.age age; } }如果你开了 strict 模式现在新建项目默认开不赋初值或者不在构造函数里赋值编译器会报“属性没有初始化器”。这一点对习惯 JavaScript 的开发者来说有点烦但它是为了帮你减少“忘记初始化”的运行时错误。实际写的时候很多属性赋值声明是多余的。TS 提供了一种更简洁的写法参数属性。直接在构造函数参数前加修饰符声明和赋值一步完成class Person { constructor( public name: string, public age: number, private id: string ) {} getInfo() { return ${this.name}年龄 ${this.age}编号 ${this.id}; } } const p new Person(张三, 25, A001); // p.id; // 报错属性 id 是私有属性这个写法等价于上面“声明 赋值”两段式但代码量少了一半。团队里看到这种写法都知道是把属性“收进构造函数”语义非常清晰。2.2 修饰符的作用边界private 不等于运行时私有类里有四个常用的修饰符我先用表格列出它们的可见性修饰符类内部子类类外部说明public可见可见可见默认值protected可见可见不可见常用于基类内部逻辑private可见不可见不可见编译期限制运行时无保护readonly可见可见可见只读只能初始化时赋值关键的坑在 private 这里它只是编译期的访问限制不是运行时真正意义上的私有。编译成 JavaScript 后TS 的private声明会被抹掉外部照样能访问到。如果你需要真正意义上的运行时私有字段用 ES2022 的#私有字段语法class Wallet { #balance: number 0; deposit(amount: number) { this.#balance amount; } getBalance() { return this.#balance; } } const wallet new Wallet(); // wallet.#balance; // 语法错误外部无法访问protected则是给继承用的。比如基类里有个protected seed属性子类可以读取使用但外部实例拿不到。这种设计适合把“内部公共逻辑”和“外部公共 API”区分开的场景。2.3 抽象类、普通类、接口的三者取舍类有“有没有具体实现”的差别。普通类可以直接实例化抽象类不能直接实例化只能被继承。抽象类里可以同时存在抽象方法只有签名没有实现和普通方法带完整实现。abstract class BaseLogger { abstract log(message: string): void; info(message: string) { this.log([INFO] ${message}); } } class ConsoleLogger extends BaseLogger { log(message: string) { console.log(message); } } // new BaseLogger(); // 报错无法创建抽象类的实例 const logger new ConsoleLogger(); logger.info(hello);抽象类的价值在于把公共逻辑比如 info 方法的格式化放在基类实现把需要子类定制的能力log 方法留成抽象方法强制子类补齐。这比普通类更“强制”比接口更“具体”。那抽象类和接口该怎么选我的判断标准是看你要不要共享实现。如果只是约定“必须有这个方法”用接口如果还希望子类复用一段公共逻辑用抽象类。接口是抽象能力的骨架抽象类是骨架 默认实现。一个实际例子业务里有很多通知渠道邮件通知和短信通知都有“发送”这个动作interface Notifier { send(message: string): void; } class EmailNotifier implements Notifier { send(message: string) { // 发送邮件 } } class SmsNotifier implements Notifier { send(message: string) { // 发送短信 } }如果所有通知渠道都需要做“日志记录”“重试机制”那就可以把这些逻辑转移到抽象类里避免每个实现类重复写一遍。2.4 用接口拆分类的“上帝模式”面向对象的类设计里最糟糕的情况是出现一个“上帝类”——所有方法都往一个类里塞字段七八个方法二三十个改一处崩三处。接口在这里能发挥一种少有人提的作用作为类的“能力切片”。比如你有一个 UserManager 类里面既有用户信息的增删改查又有权限校验逻辑还有登录状态管理。直觉写法是全部塞进去但更推荐的做法是拆出多个接口每个接口代表一类职责interface UserRepository { findById(id: number): User | undefined; save(user: User): void; } interface UserAuthenticator { verifyLogin(username: string, password: string): boolean; } class UserManager implements UserRepository, UserAuthenticator { findById(id: number) { // ... } save(user: User) { // ... } verifyLogin(username: string, password: string) { // ... } }这样调用方可以按需约束类型某个函数只需要 UserRepository 能力就把参数类型写成 UserRepository而不是整个 UserManager。这既保证了类的完整性又避免调用方拿到一堆用不到的方法。这也是“面向接口编程”在实际项目里的落地手法。3. 泛型一套逻辑服务任意类型3.1 没有泛型时我们是怎么被折磨的先看一个最简单的场景写一个函数返回数组的第一个元素。没有泛型的时候你会面临两难。方案一写死类型只能处理 number 数组function firstNumber(arr: number[]): number | undefined { return arr[0]; }数组换成 string、对象数组就得再写一个函数代码重复看着都累。方案二用 any灵活是灵活了类型信息全丢function firstAny(arr: any[]): any { return arr[0]; } const num firstAny([1, 2, 3]); // num 的类型是 any后续调用字符串方法也不会报错但运行时可能炸泛型就是为解决这个矛盾而生的让我保留“数组内容是什么类型”这个信息并且让返回值跟随数组内容类型自动变化。function firstT(arr: T[]): T | undefined { return arr[0]; } const n first([1, 2, 3]); // n 类型是 number const s first([a, b]); // s 类型是 stringT 是类型参数调用时由 TypeScript 根据实参自动推断。程序员读代码时T 就像数学里的变量代表“某种类型但具体是什么用的时候才确定”。3.2 泛型在函数、接口、类里的落点泛型不止能用在函数上接口和类同样可以带类型参数三种用法各有奥妙。函数泛型示例已经写过这里说接口泛型和类泛型。接口泛型最典型的例子是后端接口返回结构统一包装interface ApiResultT { code: number; message: string; data: T; } // 使用的时候传入具体类型 const userResult: ApiResultUser { code: 200, message: ok, data: { name: 张三, age: 25, email: zhangsanexample.com, }, };类泛型比较常见的场景是封装集合类。比如自己实现一个简单栈class StackT { private items: T[] []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } } const numberStack new Stacknumber(); numberStack.push(1); // numberStack.push(hello); // 报错类型 string 不匹配 number类泛型把“类与某种数据类型的绑定关系”推迟到实例化时确定既保证了类型安全又让类具备通用性。如果你写过 C 的模板类或者 Java 的泛型类会觉得这套逻辑很熟悉TS 只是把它放进了 JS 运行时之外的类型层面。3.3 泛型约束与 keyof让“宽进”变“严出”泛型的自由度有时候会太大。你写一个函数想获取某个对象的属性值如果泛型没有任何限制写出来的类型会很宽容易误用。这里需要给泛型加约束。TS 用extends关键字表示“这个类型参数至少得满足某个结构”。比如我想写一个“只能传入带 length 属性参数”的函数function getLengthT extends { length: number }(value: T): number { return value.length; } getLength([1, 2, 3]); // OK getLength(hello); // OK // getLength(123); // 报错number 类型没有 length 属性另一个高频伙伴是keyof操作符它能把对象的键提取成联合类型。配合泛型约束就能写出类型安全的取属性函数function getPropertyT, K extends keyof T(obj: T, key: K): T[K] { return obj[key]; } const user { name: 张三, age: 25 }; const name getProperty(user, name); // name: string const age getProperty(user, age); // age: number // getProperty(user, address); // 报错address 不在 name | age 中这个组合是我日常用得最多的类型工具之一。无论是表单提交时取字段值还是状态管理里读取某个命名空间的数据都能在编译阶段就避免“写错属性名”的低级错误。3.4 条件类型、infer 和内置工具类型泛型的进阶是组合出工具类型。这部分看起来有点“类型体操”但真用起来非常值钱。条件类型的语法是T extends U ? X : Y表达“如果 T 满足 U 的结构就取 X否则取 Y”。infer 则是在条件类型里“抓取”某个位置的类型。比如实现一个自己的 ReturnType拿到函数返回值的类型type MyReturnTypeT extends (...args: any) any T extends ( ...args: any ) infer R ? R : never; type Num MyReturnType() number; // number type Str MyReturnType(x: string) string; // string这里infer R就像一个“待填充的类型占位符”从函数类型签名中把返回值部分提取出来。内置工具类型里很多都是这个套路比如 Parameters 提取参数类型、PromiseType 提取 Promise 包裹的内部类型。日常开发中我真正高频使用的是下面这几个工具类型你不用全部手写但要知道它们存在工具类型作用示例PartialT所有属性变为可选PartialUserRequiredT所有属性变为必填RequiredUserPickT, K从 T 中选取部分键PickUser, name | ageOmitT, K从 T 中排除部分键OmitUser, idRecordK, V构造一个键类型为 K、值类型为 V 的对象Recordstring, numberExcludeT, U从联合类型 T 中排除 U 中的类型Excludea | b | c, a这些工具类型的实现并不复杂核心就是条件类型加映射类型。把它们的底层逻辑看一遍你对 TS 类型系统的理解会再上一个台阶。面试官问“内置工具类型怎么实现的”时也能讲出原理而不是只背用法。4. 常见坑位与面试高频点4.1 TypeScript 和 JavaScript 的“边界问题”TS 是 JS 的超集但类型是纯编译期的概念编译完成之后类型信息全部抹掉运行时还是纯 JS。这个边界感不建立起来很容易踩坑。最典型的是“类型上做了保护但运行时不保护”。比如interface User { name: string; age: number; } function printAge(user: User) { console.log(user.age.toFixed(2)); } // 如果有一个来源不明的数据类型上说是 User但运行时 age 可能是字符串 const raw JSON.parse({name:张三,age:25}) as User; printAge(raw); // 运行时直接报错user.age.toFixed is not a function这时候as User只是告诉编译器“相信我它就是 User”但运行时数据真实结构并不受控。处理这种外部数据接口返回、JSON.parse 结果更稳妥的做法是先用类型守卫或者类似 zod 的库做运行时校验再当成可信类型使用。另一个常见边界问题是 any 和 unknown 的区分。any 会完全关闭类型检查等于把变量踢出了类型系统unknown 表示“我不知道它是什么类型但我会在用它之前做检查”。能用 unknown 就不要用 any这是 TS 代码风格里很基础也很重要的一条。4.2 泛型推断丢失的场景排查泛型在简单场景下推断很顺畅但一复杂就容易“丢类型”。我遇到过最多的是 Promise.all 和数组回调混用时泛型推断变成联合类型或 unknown。看个例子async function getData() { return { id: 1, name: a }; } async function main() { const results await Promise.all([ getData(), getData(), ]); // results 的类型是 [{ id: number; name: string }, { id: number; name: string }] // 一般没问题但如果数组元素类型不一致推断结果会变成联合类型 }当数组里的元素来自不同函数、不同分支时Promise.all 推断出的元素类型可能变成A | B如果你希望统一成一个类型得显式标注const results await Promise.all[User, User]([ fetchUser(), fetchUser(), ]);另一个容易丢类型的场景是“函数返回泛型对象但某个内部方法丢失了泛型关系”。排查思路很明确先看泛型参数有没有在函数签名位置参数、返回类型出现如果只在函数内部用编译器推断不出来就手动标注泛型参数或者给函数加约束。泛型的本质是“类型关系”丢掉关系就丢掉了保护所以写泛型时一定要检查调用方能不能从上下文推出 T推出了之后类型是否沿着预期流动。4.3 类型兼容、多余属性检查与“看起来没错却报错”TS 的类型检查有两个容易被新手忽视的机制结构兼容性和多余属性检查。结构兼容性说的是只要目标类型需要的字段源类型都有类型就兼容哪怕源类型多了字段interface User { name: string; age: number; } const extra { name: 张三, age: 25, email: xexample.com }; const user: User extra; // OK多出来的 email 没关系但对象字面量赋值走的是“多余属性检查”不是同一个规则const user: User { name: 张三, age: 25, email: xexample.com }; // 报错对象字面量只能指定已知属性email 不在类型 User 中这个差异非常容易让人困惑。同一段代码变量声明和对象字面量注入得到不同结果。理解了背后的逻辑就顺了对象字面量“临时”创建开发者大概率是写错了变量“长期”存在可能是从某个接口拼出来的多字段很正常。TS 在这里做了实用性取舍。排查这类报错的技巧先把报错信息里的“期望类型”和“实际类型”拆开看再根据是“少字段”还是“多字段”判断是缺了必填项还是触发了多余属性检查。少字段就补字段多字段就改成变量声明或者用展开符显式赋值。4.4 面试高频题速查表把这次涉及的内容压缩成一张速查表方便你面试前快速过一遍面试题回答要点interface 和 type 的区别都能描述对象类型type 支持联合/交叉/映射interface 支持声明合并工具库中两者选型看社区规范什么是泛型约束用extends限制泛型必须满足的结构例T extends { length: number }keyof 和 typeof 的区别keyof 取对象类型的键联合typeof 在类型上下文取变量或模块的类型什么是条件类型、inferT extends U ? X : Yinfer 用于提取函数参数、返回值等位置的类型是内置工具类型的实现基础public/private/protected 有什么区别可见性层级private 仅编译期限制运行时需用 # 私有字段抽象类与接口怎么选需要共享实现用抽象类纯契约用接口TS 相比 JS 多了什么静态类型系统、接口/泛型/枚举、编译期错误检查、更好的 IDE 推断说到底接口、类、泛型不是三个孤立的知识点它们共同构成了 TypeScript 的类型设计体系。接口定义形状类承载行为泛型连接类型关系。当你写业务代码时能自然判断“这里该用接口还是抽象类”“这个函数要不要泛型化”说明你真的吃透了。最后再分享一个我自己的习惯每隔一段时间我会把项目里最常用的一批接口、工具类型拿出来重新审视一遍。类型定义很能反映代码设计的健康度——如果一个接口的字段超过十来个、一个泛型函数的约束写得含糊那代码结构大概率也该重构了。类型系统不只是给编译器看的更是给下一个读代码的人看的这也许是 TypeScript 给我们最值钱的馈赠。