如果你已经跟着上篇把 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 面试里的经典送分题,但很多人的答案只停留在“都能描述对象类型”。实际选型时,我更倾向于按场景分:
| 对比维度 | interface | type |
|---|---|---|
| 描述对象类型 | 支持 | 支持 |
| 联合类型 / 交叉类型 | 不支持 | 支持 |
| 字符串字面量联合 | 不支持 | 支持 |
| 映射类型 / 条件类型 | 不支持 | 支持 |
| 声明合并 | 支持 | 不支持 |
| 在大多数场景的类型提示 | 错误信息更友好 | 错误信息稍复杂 |
| 社区主流工具库 | 多数优先 interface | 复杂类型常用 type |
拿一个例子说明联合类型的场景:某个状态字段可能的值是"loading" | "success" | "error",只能用 type 定义:
type Status = "loading" | "success" | "error"; interface ApiResponse<T> { status: Status; data: T; }再比如交叉类型,把两个对象类型合并,通常写type Combined = A & B,interface 没有对应的“合并”语法(虽然 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 first<T>(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 ApiResult<T> { code: number; message: string; data: T; } // 使用的时候传入具体类型 const userResult: ApiResult<User> = { code: 200, message: "ok", data: { name: "张三", age: 25, email: "zhangsan@example.com", }, };类泛型比较常见的场景是封装集合类。比如自己实现一个简单栈:
class Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } } const numberStack = new Stack<number>(); numberStack.push(1); // numberStack.push("hello"); // 报错:类型 'string' 不匹配 'number'类泛型把“类与某种数据类型的绑定关系”推迟到实例化时确定,既保证了类型安全,又让类具备通用性。如果你写过 C++ 的模板类或者 Java 的泛型类,会觉得这套逻辑很熟悉,TS 只是把它放进了 JS 运行时之外的类型层面。
3.3 泛型约束与 keyof:让“宽进”变“严出”
泛型的自由度有时候会太大。你写一个函数,想获取某个对象的属性值,如果泛型没有任何限制,写出来的类型会很宽,容易误用。这里需要给泛型加约束。
TS 用extends关键字表示“这个类型参数至少得满足某个结构”。比如我想写一个“只能传入带 length 属性参数”的函数:
function getLength<T extends { length: number }>(value: T): number { return value.length; } getLength([1, 2, 3]); // OK getLength("hello"); // OK // getLength(123); // 报错:number 类型没有 length 属性另一个高频伙伴是keyof操作符,它能把对象的键提取成联合类型。配合泛型约束,就能写出类型安全的取属性函数:
function getProperty<T, 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 MyReturnType<T 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 包裹的内部类型。
日常开发中我真正高频使用的是下面这几个工具类型,你不用全部手写,但要知道它们存在:
| 工具类型 | 作用 | 示例 |
|---|---|---|
| Partial<T> | 所有属性变为可选 | Partial<User> |
| Required<T> | 所有属性变为必填 | Required<User> |
| Pick<T, K> | 从 T 中选取部分键 | Pick<User, "name" | "age"> |
| Omit<T, K> | 从 T 中排除部分键 | Omit<User, "id"> |
| Record<K, V> | 构造一个键类型为 K、值类型为 V 的对象 | Record<string, number> |
| Exclude<T, U> | 从联合类型 T 中排除 U 中的类型 | Exclude<<"a" | "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: "x@example.com" }; const user: User = extra; // OK,多出来的 email 没关系但对象字面量赋值走的是“多余属性检查”,不是同一个规则:
const user: User = { name: "张三", age: 25, email: "x@example.com" }; // 报错:对象字面量只能指定已知属性,'email' 不在类型 'User' 中这个差异非常容易让人困惑。同一段代码,变量声明和对象字面量注入得到不同结果。理解了背后的逻辑就顺了:对象字面量“临时”创建,开发者大概率是写错了;变量“长期”存在,可能是从某个接口拼出来的,多字段很正常。TS 在这里做了实用性取舍。
排查这类报错的技巧:先把报错信息里的“期望类型”和“实际类型”拆开看,再根据是“少字段”还是“多字段”判断是缺了必填项还是触发了多余属性检查。少字段就补字段,多字段就改成变量声明或者用展开符显式赋值。
4.4 面试高频题速查表
把这次涉及的内容压缩成一张速查表,方便你面试前快速过一遍:
| 面试题 | 回答要点 |
|---|---|
| interface 和 type 的区别 | 都能描述对象类型;type 支持联合/交叉/映射,interface 支持声明合并;工具库中两者选型看社区规范 |
| 什么是泛型约束 | 用extends限制泛型必须满足的结构,例:T extends { length: number } |
| keyof 和 typeof 的区别 | keyof 取对象类型的键联合;typeof 在类型上下文取变量或模块的类型 |
| 什么是条件类型、infer | T extends U ? X : Y;infer 用于提取函数参数、返回值等位置的类型,是内置工具类型的实现基础 |
| public/private/protected 有什么区别 | 可见性层级;private 仅编译期限制,运行时需用 # 私有字段 |
| 抽象类与接口怎么选 | 需要共享实现用抽象类,纯契约用接口 |
| TS 相比 JS 多了什么 | 静态类型系统、接口/泛型/枚举、编译期错误检查、更好的 IDE 推断 |
说到底,接口、类、泛型不是三个孤立的知识点,它们共同构成了 TypeScript 的类型设计体系。接口定义形状,类承载行为,泛型连接类型关系。当你写业务代码时,能自然判断“这里该用接口还是抽象类”“这个函数要不要泛型化”,说明你真的吃透了。
最后再分享一个我自己的习惯:每隔一段时间,我会把项目里最常用的一批接口、工具类型拿出来重新审视一遍。类型定义很能反映代码设计的健康度——如果一个接口的字段超过十来个、一个泛型函数的约束写得含糊,那代码结构大概率也该重构了。类型系统不只是给编译器看的,更是给下一个读代码的人看的,这也许是 TypeScript 给我们最值钱的馈赠。