
最近好几个读者私信我说 TypeScript 入门的时候看文档能看懂一到自己写项目就不知道该在哪儿用接口、哪儿用类、泛型到底解决什么问题。之前写的那篇 TypeScript 环境搭建和基础类型算是把地基打好了这篇中篇就专门啃最难啃也最值钱的骨头接口、类、泛型。只要你把这三个概念吃透基本就能看懂市面上绝大多数 TS 项目源码写起来也不会再是“JS 加类型标注”那种感觉。这三大概念确实是 TypeScript 类型系统的核心骨架。接口负责约定数据结构的长相类负责把数据和操作它的人绑在一起泛型则让类型能像函数参数一样被复用和推导。理解它们不是背语法而是要搞明白每个特性解决的是什么真实痛点。这篇文章不打算从零讲语法手册而是按我用 TS 做项目的实际思路来讲先讲为什么需要它们再讲怎么落地到业务代码最后聊一聊高频报错和面试那些坑。1. 先从整体看为什么接口、类、泛型是 TS 的三大支柱1.1 TS 和 JS 的本质差别不在语法而在类型思维很多从 JS 转 TS 的人会陷入一个误区——把 TS 当成“带类型的 JS”写起来还是在想着值、对象、函数只是顺手加个类型注解。但 TypeScript 真正改变的是设计顺序在写代码之前你得先想清楚数据结构是什么、函数输入输出是什么、哪些属性允许变动、哪些不能为空。这种“先定义契约再实现细节”的方式才是 TS 的核心价值。接口就是你对外部世界承诺的契约。比如你现在要接一个用户列表的 API接完数据以后要用在前端页面上如果你用 JS 写你可能拿到的数据直接res.data.list然后往页面里塞。用 TS 写第一步一定是定义一个接口约定id是数字、name是字符串、avatar是可选字符串、tags是字符串数组。这个接口就是前端和后端之间的协议后端一旦返回了不符合这个协议的数据TS 会在编译期就给你标红而不是等你页面渲染出undefined再排查半天。类则解决的是内聚的问题。当你要处理的数据带有明显的状态和行为比如一个购物车、一个播放器、一个编辑器缓存区你用类把这些状态和行为封装在一起比散落一堆函数要清晰得多。TS 的类在 ES6 类的基础之上增加了类型修饰符、抽象类、接口实现等能力让类不仅是代码组织工具更是类型系统中的一等公民。泛型就更有意思了它解决的是“复用”的终极问题。你写一个数组去重函数不管数组里的元素是数字、字符串还是对象去重的逻辑其实是一样的。如果没有泛型你可能要写any或者每个类型写一遍。泛型允许你在定义函数时不指定具体类型等调用的时候再由调用方传入或由 TS 自动推导这样一段代码就能服务多种类型同时还能保住类型安全。1.2 三个概念各自管什么、怎么配合这三个概念不是孤立的实际项目里几乎都是配合使用。最常见的模式就是用接口定义一个数据类型或函数结构用类去实现这个接口并封装具体逻辑当某个函数要处理“符合某种结构的一类数据”时再用泛型让它通用起来。我自己做项目时总结了一个简单判断标准如果你要描述一个对象的“形状”用接口如果你要把数据和操作它的方法绑定在一起并且可能有多个实例用类如果你写的是一个通用工具函数或者一个组件要接收多种类型的 props用泛型。拿一个表格组件举例Column接口定义了每一列的标题、字段名、宽度TableStore类管理排序状态、筛选状态、分页状态而渲染每一行数据的 render 函数用T泛型让这个表格组件既能渲染用户列表也能渲染订单列表不需要复制两份代码。这里还要强调一个 JS 和 TS 类的重要区别。JS 的类是纯运行时概念ES2022 才有的#私有字段也只是运行时层面的隐藏。TS 的类在类型层面增加了private、protected、public、readonly这些修饰符它们在编译后会被擦除运行时并不存在真正的限制。也就是说 TS 的私有修饰符约束的是“你写的代码”不是“运行时的别人”理解这一点后面查私有属性相关的坑会好定位很多。2. 接口Interface——先定契约再写代码2.1 接口的本质只要你长得像你就是理解 TS 接口最关键的一点是跳出 Java 或 C# 的“接口实现”思维。TS 的接口走的是结构化类型系统也叫鸭子类型只要一个对象的结构满足接口定义的形状它就兼容这个接口不需要显式地写implements。interface User { id: number; name: string; email?: string; } // 没有写 implements但结构完全符合 const userA { id: 1, name: 张三, email: zhangsanexample.com, }; const userB { id: 2, name: 李四, }; function printUser(user: User) { console.log(user.name); } printUser(userA); // 正常 printUser(userB); // 也正常因为 email 是可选属性这个特性对前端项目太重要了。后端接口返回的数据、第三方库传入的配置对象、组件接收的 props绝大多数情况你都不可能要求它们显式实现你的接口但只要结构对得上TS 就认。这也是 TS 能非常好地和 JS 生态共存的原因——你要接一个老 JS 库只要给它定义一个结构上的类型描述就能获得提示和检查。2.2 接口的基础语法可选、只读、索引签名、函数类型先用一个相对完整的例子把接口常用的语法点串起来。interface PaginationResponseT { code: number; message: string; data: T[]; // 可选属性分页参数可能没有 pageInfo?: { page: number; pageSize: number; total: number; }; // 只读属性订单号、创建时间这类从服务端返回后不应该被改动 readonly requestId: string; } interface ApiError { [key: string]: unknown; message: string; } // 接口描述函数类型 interface StringFormatter { (source: string, length: number): string; } const truncate: StringFormatter (source, length) source.length length ? source.slice(0, length) ... : source;这里有几个细节要特别注意。readonly是在编译期做检查和const不是一回事。const保证变量引用不能被重新赋值readonly保证对象属性不能被重新赋值。如果你有一个配置对象加载后不应该被修改用readonly比约定“大家别改”要可靠得多。索引签名[key: string]: unknown适合描述动态对象但要注意一旦加了索引签名所有已声明的属性类型必须符合索引签名类型的约束。可选属性和undefined联合类型也要区分。email?: string表示这个属性可以不存在访问的时候 TS 会提示你可能是undefinedemail: string | undefined表示这个属性必须存在但值可能是undefined。前者适合描述“服务端不一定返回这个字段”后者更适合描述“字段一定有但可能为空值”比如deletedAt: Date | null。2.3 接口的继承与合并以及和 type 怎么选接口可以继承接口多个接口可以合并成一个大接口这在拆分领域模型的时候特别好用。基础接口定义公共字段扩展接口加自己的字段避免每个接口都重复写一遍公共属性。interface BaseEntity { id: number; createdAt: string; updatedAt: string; } interface User extends BaseEntity { nickname: string; email: string; } interface Admin extends User { permissions: string[]; }另外一个容易忽略的点是接口的同名合并。TS 会把两个同名接口的成员自动合并这个特性在给第三方库补齐类型定义、或者按功能模块给某个全局对象添加类型时有奇效。但也因为这种行为如果有人无意中在多个文件声明了同名接口成员会被悄悄合并可能覆盖或增加你没想到的字段。现在还有个高频问题接口和 type 别名到底怎么选。我的经验是描述对象形状、需要继承或自动合并、写类要实现的结构优先用接口联合类型、交叉类型、映射条件类型必须用 type。interface 没有办法表达string | number这种联合也没法做Pick、Record这类工具类型的运算这些场景只能 type。实际项目里我会先默认用 interface遇到联合、交叉、条件类型再切到 type这个策略踩坑最少。2.4 接口实战用接口封装 API 响应结构说个我自己的业务例子。接手过一个管理后台后端接口返回格式五花八门有的成功是{ code: 0, data: ... }有的成功是{ code: 200, data: ... }还有直接把数据挂在顶层。后来统一抽象了一个接口层所有请求都走一个 request 函数响应结构在一个地方用接口定义清楚后续开发再也不用每接一个接口就猜一次字段。// 统一响应结构 interface ApiResponseT unknown { code: number; data: T; message: string; traceId?: string; } // 分页结构 interface PageResultT { list: T[]; total: number; page: number; pageSize: number; } // 用户信息 interface UserInfo { id: number; name: string; roles: string[]; }这样封装之后所有接口返回数据在进入业务代码之前已经在 request 层被 TS 检查过了。后端的同事改了字段名前端编译直接爆红不用等联调才发现。3. 类Class——类型化的面向对象不只是语法糖3.1 TS 类的基础属性声明、修饰符、参数属性JS 的类可以不给属性做声明用this.xxx xxx就直接赋值了。TS 里不行属性必须先声明类型或者通过构造函数参数属性简写。基础写法是这样class UserAccount { readonly id: number; name: string; private _password: string; protected loginCount: number; constructor(id: number, name: string, password: string) { this.id id; this.name name; this._password password; this.loginCount 0; } incrementLogin() { this.loginCount 1; } checkPassword(input: string) { return this._password input; } }private _password用下划线前缀是社区常见约定表示“内部实现细节外部别碰”。TS 的private在编译后没有真正运行时保护代码里直接user._password在 TS 编译阶段就会报错但如果你用any绕过类型检查运行时还是能拿到值。真正要在运行时也强制私有得用 ES 的#私有字段class UserAccountWithHash { #password: string; constructor(password: string) { this.#password password; } checkPassword(input: string) { return this.#password input; } }#私有字段是 JS 语言层面的实力保护编译后依然存在。做类库开发、需要防止外部访问内部状态时我更推荐用#因为它的保护是真实有效的。TS 还有一个很常用的简写叫“参数属性”在构造函数参数前面加修饰符TS 会自动帮你声明同名属性并赋值class Product { constructor( public readonly id: number, public name: string, private price: number, ) {} } const p new Product(1, 键盘, 199); p.name 鼠标; // p.price 直接访问会报错这样代码能少写很多重复声明但要注意可读性。参数一多别人看构造函数签名时不容易分辨哪些参数只是传参、哪些会变成实例属性。我的习惯是超过三个参数就不再用参数属性而是显式声明协作时别人看代码更直观。3.2 抽象类和普通类的区别什么时候必须用抽象类抽象类是面试高频题也是实际项目里很有价值的工具。普通类可以直接实例化抽象类不行抽象类只能被继承。抽象类可以有完整实现的方法也可以定义只有签名没有实现的抽象方法子类必须实现这些抽象方法。abstract class Logger { abstract log(message: string): void; info(message: string) { this.log([INFO] ${message}); } error(message: string) { this.log([ERROR] ${message}); } } class ConsoleLogger extends Logger { log(message: string) { console.log(message); } } class FileLogger extends Logger { private logs: string[] []; log(message: string) { this.logs.push(message); // 省略写文件逻辑 } getLogs() { return this.logs; } }抽象类这个设计解决的是这样一类问题多个类共享同一套流程骨架但流程中的某个步骤各有各的实现。上面的 Logger 例子info和error方法把日志格式化逻辑统一写在抽象类里子类只需要实现最底层的log方法。如果你用普通类做基类的话别人可以直接new Logger()这既没有意义也容易产生半初始化状态。抽象类在编译层面阻止了这种误用。和接口相比抽象类能提供默认实现接口完全不行。所以当你既需要“统一的行为约定”又需要“部分公共逻辑直接复用”时抽象类是比接口更合适的方案。相应地如果你的类没有共享实现只是需要形状约束那用接口更轻量。3.3 类实现接口多态才能优雅起来接口和类配合最典型的是接口定义能力类提供实现上层代码面向接口编程。以一个支付场景为例interface PaymentGateway { createPayment(orderId: string, amount: number): Promisestring; refund(paymentId: string): Promiseboolean; } class WechatPay implements PaymentGateway { async createPayment(orderId: string, amount: number) { // 调用微信支付 API return wx_ orderId; } async refund(paymentId: string) { return true; } } class Alipay implements PaymentGateway { async createPayment(orderId: string, amount: number) { // 调用支付宝 API return ali_ orderId; } async refund(paymentId: string) { return true; } } async function checkout( gateway: PaymentGateway, orderId: string, amount: number, ) { const paymentId await gateway.createPayment(orderId, amount); return paymentId; }checkout函数只依赖PaymentGateway接口不关心具体是微信还是支付宝。以后要接银联新建一个类实现接口就行checkout 函数一行都不用改。这种面向接口编程的做法在替换实现、写单元测试mock 一个 fake gateway的时候优势非常大。3.4 类实战一个带状态的业务模型说了这么多理论我把一个简单购物车的类写出来。它包含状态、方法、类型约束是一个比较典型的 TS 类实践interface CartItem { skuId: string; name: string; price: number; quantity: number; } class ShoppingCart { private items: CartItem[] []; private couponRate: number 1; addItem(item: CartItem) { const existing this.items.find((i) i.skuId item.skuId); if (existing) { existing.quantity item.quantity; } else { this.items.push({ ...item }); } } removeItem(skuId: string) { this.items this.items.filter((i) i.skuId ! skuId); } setCouponRate(rate: number) { if (rate 0 || rate 1) { throw new Error(折扣率必须在 0 到 1 之间); } this.couponRate rate; } get totalPrice() { return this.items.reduce( (sum, item) sum item.price * item.quantity, 0, ); } get checkoutPrice() { return Math.round(this.totalPrice * this.couponRate * 100) / 100; } }这里用find方法处理相同商品合并逻辑用filter做删除getter计算总价打折价外部无法直接修改items内部数组。类不是银弹但当你看到这类有状态、有内部逻辑的模型用类封装会比重重散落的高阶函数好维护得多。4. 泛型Generics——让类型像参数一样自由4.1 没有泛型时你可能会陷入三种尴尬写 TS 的时候你不一定马上意识到泛型的价值直到你遇到这三种情况第一种是从接口拿数据比如后端返回的列表可能是用户、订单、商品封装一个requestList(url)函数如果没有泛型返回值只能写成Promiseany[]拿到数据后所有字段都没有提示。第二种是工具函数const first arr arr[0]想写出类型但没有泛型要么any要么写死成某一种类型。第三种是组件/工具类一个 LocalStorage 封装可能存字符串、存数字、存对象没有泛型你就得为每种类型写一套同样的逻辑。泛型就是专门解决“类型也要参数化”这个问题的。4.2 泛型函数、泛型接口、泛型类的写法与推导最简单的泛型函数function identityT(value: T): T { return value; } const a identitystring(hello); const b identity(123); // 类型参数可以省略TS 自动推导为 123数组方法的类型推断是理解泛型最好的入口。map、filter、reduce都是泛型方法const numbers [1, 2, 3]; const doubled numbers.map((n) n * 2); // number[] const labels numbers.map((n) 数字 ${n}); // string[]map让你从T[]映射到U[]这就是泛型的威力。你调用的时候根本不需要手写mapnumber, stringTS 根据回调函数的返回值自动推导出U。实际项目里手写泛型的场景不多大部分时候都是“类型参数从参数中推导”但理解推导机制遇到推导失败的时候才好排查。泛型接口可以约束一组相关的函数签名比如一个通用的响应处理函数interface ApiFetcherT { (url: string): PromiseT; } const fetchUser: ApiFetcherUserInfo async (url) { const res await fetch(url); return res.json(); };泛型类最典型的例子是数据结构封装class StackT { private elements: T[] []; push(element: T) { this.elements.push(element); } pop(): T | undefined { return this.elements.pop(); } } const numberStack new Stacknumber(); numberStack.push(1); const top numberStack.pop(); // number | undefined4.3 泛型约束用 extends 给类型边界套上缰绳泛型不是越泛越好。你写一个函数要取数组最后一个元素但必须保证传入的对象有id字段用来做日志记录这时候就要用extends约束泛型参数interface HasId { id: number | string; } function getLastWithIdT extends HasId(arr: T[]): T | undefined { return arr[arr.length - 1]; } const users: UserInfo[] []; const lastUser getLastWithId(users); // 这样不行number 没有 id 属性 // getLastWithId([1, 2, 3]);T extends HasId的含义是T 必须是 HasId 的兼容类型也就是必须有id属性。约束的边界就是“别让泛型变成另一个 any”既保留泛型的复用能力又保证函数体内能安全地访问id。keyof操作符和泛型结合能写出非常安全的类型查询函数function getPropertyT, K extends keyof T(obj: T, key: K): T[K] { return obj[key]; } const user { name: 张三, age: 30 }; const name getProperty(user, name); // string // const invalid getProperty(user, email); // 编译报错email 不在 keyof 类型中K extends keyof T保证了第二个参数只能是第一个参数对象已有的键这样返回类型就能精确到T[K]。这种写法的价值在配置读取、表单校验这类场景中非常明显。4.4 内置工具类型Partial、Pick、Record、ReturnTypeTS 内置的泛型工具类型是把泛型用到极致的结果。我把日常最高频的四个拿出来说PartialT把 T 的所有属性变成可选适合做“部分更新”的接口入参。比如编辑用户信息时前端只需要传修改过的字段interface UserSettings { theme: string; fontSize: number; notifications: boolean; } type PartialSettings PartialUserSettings; // { theme?: string; fontSize?: number; notifications?: boolean }PickT, K从 T 中挑选一组属性形成新类型适合把大接口裁剪成子集避免把整个实体传给不必要的函数type UserSummary PickUserInfo, id | name;RecordK, V生成一个键为 K 类型、值为 V 类型的对象类型用来定义一个枚举映射表非常顺手type Role admin | editor | viewer; type RolePermissions RecordRole, string[]; const permissions: RolePermissions { admin: [create, update, delete], editor: [create, update], viewer: [view], };这样写的好处是如果以后新增一个角色TS 会强制你补上它的权限数组漏掉直接报错。ReturnTypeT可以提取函数的返回类型。在项目里经常用于“从某个函数的返回值反推类型”比如 redux 的 rootReducer、或者第三方库暴露的函数function fetchUserConfig() { return { language: zh-CN, timezone: Asia/Shanghai, theme: dark, }; } type UserConfig ReturnTypetypeof fetchUserConfig; // { language: string; timezone: string; theme: string }掌握了这四个工具类型你就已经超过相当一部分“能跑起来就行”的 TS 开发者了。真正复杂的类型体操不是日常必需但工具类型的用法是每天都离不开的。5. 三者合体实战封装一个类型安全的请求模块前面分开讲了接口、类、泛型各自怎么用这一节把它们放到一个真实场景里封装一个带泛型和类的请求模块。这是我个人在项目里用得最多的组合方式也是面试官很喜欢问的“你项目里 TS 到底怎么用”的答案。5.1 先用接口定义 API 契约第一步永远是定契约。定义一个通用响应结构再定义各个业务实体的接口interface ApiResponseT { code: number; message: string; data: T; } interface UserProfile { id: number; name: string; avatar: string; } interface OrderRecord { orderId: string; totalAmount: number; status: pending | paid | shipped | completed; }这一步极其重要。所有请求返回值都必须挂在ApiResponseT的data上用code字段判断业务成功或失败而不是靠 HTTP 状态码。这能让业务代码里对返回数据的处理方式完全统一。5.2 用泛型写一个带约束的 request 函数核心 request 函数用泛型接收一个“附加值类型”把 Promise 的类型精确到业务实体class HttpError extends Error { constructor( public status: number, message: string, ) { super(message); this.name HttpError; } } async function requestT( url: string, options?: RequestInit, ): PromiseT { const response await fetch(url, { headers: { Content-Type: application/json, ...(options?.headers || {}), }, ...options, }); if (!response.ok) { throw new HttpError(response.status, 请求失败: ${response.statusText}); } const result (await response.json()) as ApiResponseT; if (result.code ! 0) { throw new Error(result.message || 业务错误); } return result.data; } // 调用时获得精确类型 async function loadUser(id: number): PromiseUserProfile { return requestUserProfile(/api/users/${id}); } async function loadOrder(orderId: string): PromiseOrderRecord { return requestOrderRecord(/api/orders/${orderId}); }as ApiResponseT这里做了类型断言因为response.json()的返回类型是Promiseany如果不做断言result.data就是any泛型就白写了。实际项目里我通常会在这一层再嵌套响应拦截器做 401 跳转、错误统一弹窗之类的逻辑但核心的类型设计就是上面这样。5.3 用类管理 API 模块接口约束实现把相同领域的请求方法放到一个类中实现一个接口约束的方法集合。上层页面只需要面向这个类实例不接触fetch也不接触 URL 字符串interface UserApi { getUser(id: number): PromiseUserProfile; updateUser(id: number, data: PartialUserProfile): PromiseUserProfile; } class UserApiService implements UserApi { async getUser(id: number) { return requestUserProfile(/api/users/${id}); } async updateUser(id: number, data: PartialUserProfile) { return requestUserProfile(/api/users/${id}, { method: PATCH, body: JSON.stringify(data), }); } } const userApi new UserApiService(); const profile await userApi.getUser(1);接口UserApi规定了一个用户模块应该有哪些请求能力类UserApiService去实现。测试的时候你可以写一个MockUserApi implements UserApi页面代码完全感知不到差异。这种分层在项目膨胀之后特别好用API 的变动被收敛在一个文件里业务代码不会到处都是 URL。6. 高频报错与面试加分点自查清单6.1 实际开发里最常见的四个报错场景报错一对象字面量“多余属性”报错const user { id: 1, name: 张三, age: 30, // 多余属性 }; function greet(u: { id: number; name: string }) { console.log(u.name); } greet(user); // 不报错因为变量是引用传递 greet({ id: 1, name: 张三, age: 30 }); // 报错对象字面量多余属性检查TS 对直接传入对象字面量会做“多余属性检查”防止你把拼错的属性名当成合法属性。解决办法是不要从对象字面量里传多余属性或者给类型定义里加上可选属性。报错二泛型函数调用时类型参数推导成了never或unknown出现这种问题绝大多数是因为传入的参数类型就是不明确的。排查思路是在函数定义处给泛型参数加合理约束比如T extends object、T extends string | number或者检查调用处是不是漏了一个as断言。报错三接口可选属性访问时提示“可能为 undefined”interface Config { timeout?: number; } function run(config: Config) { // config.timeout 是 number | undefined const ms config.timeout ?? 3000; // 用空值合并运算符给默认值 }处理方式是用??给默认值或者用if判断后收窄类型。不建议用!非空断言除非你非常确定运行时一定存在。报错四类私有属性在单元测试里访问不到很多人在测试时想直接读对象内部状态结果 TS 报private不能访问。我的处理方式是尽量不要为测试专门开any而是通过公共方法暴露可观察的状态比如加一个getState()方法。如果确实需要访问内部考虑把private改成protected或提供getter。6.2 面试高频题这些点你能答出几层关于 TS 和 JS 的区别最核心的答案是“TS 是静态类型系统 编译期工具JS 是运行时语言”。TS 的类型在编译后全部擦除不产生额外运行时成本。接着可以展开讲接口、类型别名、泛型让代码成为“可自文档化的代码”IDE 提示能让你跳到定义重构时代价极小。这些都是 JS 做不到的。interface和type的区别要分层次答。第一层是语法能力不同interface 能声明合并和 extendstype 能做联合、交叉、映射类型。第二层是使用场景不同优先 interface 描述对象形状遇到类型运算用 type。第三层可以加点个人体会比如“我在项目里默认用 interface除非需要写联合类型或条件类型。”泛型约束就是extends。面试官问“泛型为什么要用 extends”你要答出来一是为了在泛型函数内部访问类型的属性时保证类型安全二是为了缩小泛型的接受范围避免任意类型都能传入。讲到类和接口的关系可以提一嘴 SOLID 原则里的依赖倒置高层模块不依赖低层模块两者都依赖抽象。接口就是那个抽象类实现接口后就实现了多态替换。6.3 我的避坑清单和总结心得写 TypeScript 这几年踩过的坑不少最后总结几条最痛的教训不要在项目里到处any。any会把 TS 变成带噪音的 JS而且一旦一个接口返回any它所有下游的类型都会跟着丢失。如果确实遇到不好搞的类型先用unknown兜底再在拿到具体数据的那一刻用类型守卫收窄。泛型不是越多越好。有人喜欢写一堆工具类型做“类型体操”结果类型层比业务还复杂维护成本极高。我的原则是泛型用在边界处API 层、通用组件、通用工具函数业务代码里尽量少用复杂泛型保持可读性优先。区分编译时和运行时是理解一切的钥匙。TS 的类型检查、接口、泛型都活在编译时运行时的一切还是 JS 那套。所以像instanceof、typeof这些运行时类型检查和 TS 的类型系统是互补关系不是替代关系。关于接口、类、泛型的介绍就到这里。这三个概念看着多实际上只要多写几个真实项目你会发现它们就是 TypeScript 日常写代码的基本词汇。聊天中提到的方法和代码都是可以直接抄到自己项目里改造的。动手把项目里一个包含请求的模块按上面的思路重构一遍你对 TS 的感受会完全不一样。