
TypeScript 这门语言我前前后后学了小半年期间踩了不少坑也走了不少弯路。最开始的三个月我基本处于一种“写了等于白写”的状态——开着 strict 模式却满屏都是any类型检查形同虚设。直到后来接手一个老项目的重构逼着自己去读别人写的.d.ts声明文件去啃interface继承和泛型约束才慢慢摸到门道。这篇笔记不是官方文档的复述而是把我学习过程中最容易卡壳、最容易被忽视的地方整理出来配合实际的代码场景希望能帮你少走一些我走过的冤枉路。文章主要覆盖几个方向一开始最应该纠正的类型思维误区业务建模里最常用的interface继承与组合技巧很多前端都会遇到但未必真会用types文件夹和.d.ts声明文件以及我把 TypeScript 应用到自动化测试Playwright里的实战体会。最后还会聊几个高频面试考点背后真正的原理逻辑——只背答案不够你得真明白它为什么这样设计。1. 学 TypeScript 先别急着写类型先纠正几个惯性思维很多人学 TypeScript 的第一件事是打开官方文档把基础类型、接口、泛型全过一遍然后回到项目里一看——还是会写any。这不怪你因为 TypeScript 光看语法是学不会的你得先扭转对“类型”这件事的认知。1.1 类型不是给变量贴标签而是给数据流做约束我第一次写 TS 的时候脑子里想的是“给每个变量标记一个类型让编辑器提示更聪明”。这种理解不能说错但它会导致一个严重的习惯你只会给显而易见的变量标注比如const name: string abc而对函数参数、API 返回、复杂对象结构这些真正容易出现数据流问题的地方反而用any一带而过。正确的理解应该是类型标注的核心价值在于约束数据流——当数据从函数 A 流向函数 B、从接口响应流向 UI 状态时编译器能在编译阶段就拦住“形状不匹配”的数据而不是等运行时才暴露问题。我后来在做重构时深刻体会到这一点一个强类型的函数签名比任何注释都清楚地说明了“这个函数接受什么、返回什么、会怎样影响你的数据流”。如果你发现自己写的 TS 代码没有任何类型错误先别得意——很可能不是代码写得对而是你满屏any编译器根本无从检查。1.2 any 是类型黑洞一旦引入整个链路都失效any之所以危险不是因为它“没有类型”而是因为它会在类型系统里形成一个黑洞——任何类型的值都可以赋给anyany也可以赋给任何类型类型检查在any所在的位置彻底失效。举个例子function fetchUserData(): any { // 某个业务接口返回结构不稳定 return { id: 1, name: Alice, extraProp: xxx }; } const user fetchUserData(); console.log(user.address.city); // 运行时抛错但 TypeScript 编译完全不会提醒只要fetchUserData的返回类型是any那么user的所有类型检查都没意义了。这就像你在一条完整的道路上挖了个坑所有经过的数据都会掉进去。正确做法是给返回结构定义一个接口interface UserData { id: number; name: string; address?: { city: string; }; } function fetchUserData(): UserData { return { id: 1, name: Alice }; }这样当你访问user.address.city时TypeScript 会提示address可能是undefined——这其实是编译器在帮你提前发现一个潜在 bug而不是给你添麻烦。1.3 依赖推导但不要迷信推导关键边界必须显式标注TypeScript 的类型推导非常强大部分时候不需要你手动标注。但“推导”和“信任边界”是两回事——编译器能推导出函数内部的运算逻辑却无法知道你从外部接到的数据是什么形状。我的经验是三个地方必须显式标注不能偷懒函数参数这是你定义的输入边界不标注类型等于把检查完全丢给调用者函数返回值尤其是需要返回特定稳定结构的数据显式返回类型可以防止你在后续修改中无意破坏契约外部 API 响应从后端拿到的数据是“未知世界”你必须为它定义一个类型来描述它自测一下如果你写过const result await fetch(url); const data await result.json();然后直接使用了data恭喜你这里就是any泛滥的重灾区。因为result.json()的返回类型就是any它一出来后面所有逻辑全都脱离类型系统了。想要治住这个坑必须自己业务层定义类型再配合下面要讲的.d.ts声明和泛型来约束。2. interface 继承与类型组合业务建模时最常用的武器搜索引擎里关于“TypeScript interface 怎么继承”的搜索量一直很高说明这确实是许多人的困惑点。interface 的继承不只是字面量上的“extends”它背后代表的是业务模型之间的父子关系、扩展关系和约束关系。2.1 接口继承的真正含义子类型必须是父类型的超集概念在业务建模中接口继承最常见的场景是“基础实体 扩展字段”的关系。比如订单模块里列表页只需要基础信息详情页需要完整信息interface BaseOrder { id: string; createdAt: number; status: pending | paid | shipped | completed; } interface DetailedOrder extends BaseOrder { items: OrderItem[]; totalAmount: number; shippingAddress?: string; remarks?: string; }这里DetailedOrder是BaseOrder的超集扩展字段但类型系统里它是BaseOrder的子类型——因为它满足所有BaseOrder要求的字段。这个“子类型”概念非常关键当我形成这个认识后再看很多类型相关的报错就豁然开朗了。具体到代码里这种继承关系允许你做类型收窄function printOrderStatus(order: BaseOrder) { console.log(order.id, order.status); } const detail: DetailedOrder { ... }; printOrderStatus(detail); // 允许因为 DetailedOrder 是 BaseOrder 的子类型这与把BaseOrder和DetailedOrder定义成两个互不相干的 interface 完全不同——继承让类型系统自动承认了两个结构的兼容性而不需要你写额外的类型断言。2.2 多重继承与 declaration merging学会用组合思维析出公用结构interface 可以 extends 多个类型这让我们能像搭积木一样组装业务模型interface Timestamped { createdAt: number; updatedAt: number; } interface SoftDeletable { deletedAt?: number; } interface User extends Timestamped, SoftDeletable { id: number; name: string; }用组合而不是单继承会让你的类型定义更贴近现实业务——一个实体往往同时具备多种维度时间维度、可删除维度、权限维度、元数据维度硬套一条继承链反而会越写越别扭。另外一个思路是 interface 的重复声明会自动合并interface WindowWithCustom extends Window { __analytics: AnalyticsTracker; }在项目里经常会有在全局 window 上挂自定义属性的场景比如埋点对象、环境配置对象。正确的做法不是(window as any).__analytics而是新建一个全局类型声明把新增属性安全地合并进Window这才是在团队项目里声明全局变量的正规姿势。这个我在后面第三章详细说明。2.3 交叉类型与 interface 的区别以及什么时候用 type 更合适interface 和 type 在很多场景可以互换但它们之间有个关键差异interface 可以被合并declaration mergingtype 不可以。而type的优势在于可以用交叉类型模拟继承并且更方便表达联合类型、映射类型等更复杂的结构。交叉类型写法type DetailedOrderWithExtras BaseOrder { items: OrderItem[]; totalAmount: number; };功能上和 interface extends 类似但交叉类型有一个“冲突字段”的坑。假如两个类型里有同名属性交叉后两边会融为一体很容易变成 never 或不可用的类型比如type A { id: string }; type B { id: number }; type C A B; // id 推断为 string number实际不可用而 interface extends 遇到同名属性会直接报错反而更容易发现问题。所以我的习惯很明确能表达业务实体关系的时候优先用 interface 继承处理联合类型、工具类型本身、或者需要交叉运算的场景用 type。3. types 文件夹与 .d.ts 声明文件手写声明没那么神秘“typescript types 文件夹的声明文件 如何使用”“.d.ts 怎样编写”这两个热搜词背后是很多人的另一个痛点。项目里的.d.ts就像一张藏宝图平时不需要看可一旦你遇到没有类型定义的第三方库、需要扩展全局变量、或者想给未来的自己留下更明确的类型契约时你就得亲手去写它了。3.1 什么时候需要写声明文件不是所有项目都必须手写.d.ts但下面几种情况你大概率躲不开npm 包本身不提供类型定义有时代码库只发布 JavaScript没有 index.d.ts。你需要自己为它补一份声明文件否则 TypeScript 会把它当成any或者直接报错全局变量和 CDN 脚本项目通过 script 标签引入一些 SDK 或工具库它们是挂到 window 上的全局函数编辑器完全不认识给项目里的常量模块补充类型比如图片导入、CSS Module、配置文件等TypeScript 默认不认识这些模块配置全局扩展比如上一节说的WindowWithCustom extends Window3.2 types 文件夹的标准组织方式目录结构与 tsconfig 配置我比较推荐在项目根目录下建一个专门的types文件夹把所有手写声明集中起来。一个典型的目录结构长这样src/ ... types/ globals.d.ts xx-sdk/ index.d.ts api.d.ts tsconfig.json然后在tsconfig.json里做两件事一是保证types文件夹被包含进来二是设置typeRoots将自定义的types目录加入类型搜索路径{ compilerOptions: { typeRoots: [./node_modules/types, ./types] }, include: [src, types] }注意typeRoots默认是指向node_modules/types的如果你加了自定义文件夹必须要显式把默认路径也写进去否则原来依赖的第三方类型会全部丢。这是我踩过的一个坑——加完typeRoots后项目红了一片原因是默认值被覆盖了改回同时包含两个路径就恢复正常。3.3 .d.ts 的三种核心写法declare module、declare global、declare namespace.d.ts不是普通的业务代码文件它只承载类型信息不会编译成任何 JavaScript 输出。理解这一点后再看它的语法就顺畅多了。第一种声明模块类型declare module如果某个库没有类型定义比如一个老的日期处理库legacy-date-utilsdeclare module legacy-date-utils { export function formatDate(date: Date): string; export function addDays(date: Date, days: number): Date; }这样在你的代码里就可以直接import { formatDate } from legacy-date-utils并获得完整的类型提示了。第二种声明全局类型declare global如果要给 window 扩展自定义属性// types/globals.d.ts interface AnalyticsTracker { track(event: string, payload?: Recordstring, unknown): void; } declare global { interface Window { __analytics: AnalyticsTracker; } } export {};注意两点这个文件里必须有一个export {}或者至少作为一个模块存在declare global才能正常生效同时这样声明的Window会被全局合并项目里任何地方访问window.__analytics都不会再报错。第三种声明命名空间declare namespace如果项目里用了一个全局命名的 SDK比如window.MySDKdeclare namespace MySDK { interface Config { apiKey: string; } function init(config: Config): void; function send(event: string, data?: unknown): void; }这种写法非常接近老式全局脚本库的形态声明之后全局代码可以直接用MySDK.init({ apiKey: xxx })同时这也能在纯业务类型上提供组织结构。3.4 常见误区在 .d.ts 文件里写实现代码或者滥用 export 语法新手特别容易犯的错是在.d.ts里写了具体的函数体和值比如// 错误示范 declare module my-lib { export const formatDate (d: Date) d.toString(); // ❌ 值实现不该写在声明文件 }声明文件里只能有类型声明和签名不能有赋值逻辑。正确写法如上文所示只写export function formatDate(date: Date): string这样的形状描述。另外遇到 CommonJS 风格包时容易用到export 这个语法最好只在万不得已时用因为它和 ES Module 的export default混用时会有一堆兼容性坑。如果你在给团队项目写声明先跟同事确认一下模块规范避免用错。4. Playwright TypeScript类型系统在自动化测试里的实战收益热搜词里出现了“typescript playwright”这恰好是我自己的亲身实践。E2E 自动化测试本身不是新鲜事但用 TypeScript 来做体验和 JavaScript 完全不一样——尤其是当你把测试的页面对象模型和类型声明认真结合起来后测试代码的“可信度”会大幅提升。4.1 为什么 E2E 测试场景特别适合 TypeScript自动化测试本质上是在做大量“从外部世界获取数据、对系统状态进行断言”的工作。这类代码最怕两件事一是把测试数据传错位置二是断言的时候拿到一个空值。用 JS 写测试这些错误往往只有运行到那一行才会暴露但用 TypeScript 写很多低级错误在写代码的那一刻就会被编译器提示。比如定位元素const submitBtn page.locator(button[typesubmit]); await submitBtn.click();如果我把选择器改成button[typesumbit]拼写错误Playwright 运行时会找不到元素整个用例直接挂掉。但是 TS 本身不会检查字符串拼写——所以这点要靠 Getter 和类型化抽象来弥补。更实用的收益在于当你为页面定义了一个 Page Object Model并用类型界定它的方法和返回值时重构页面结构、迁移选择器时编译器会批量告诉你哪些测试代码需要跟着改。这比运行时检测高效太多。4.2 用接口给 Playwright 测试代码建立“页面结构”模型我的做法是为关键页面写一个 Page Object 类并让测试代码调用这些方法而非直接操作pageclass LoginPage { constructor(private page: Page) {} async goTo() { await this.page.goto(https://example.com/login); } async fillUsername(name: string) { await this.page.locator(#username).fill(name); } async fillPassword(password: string) { await this.page.locator(#password).fill(password); } async submit() { await this.page.locator(button[typesubmit]).click(); } get errorMessage() { return this.page.locator(.error-message); } }然后用类型去绑定测试场景type LoginCredentials { username: string; password: string; expectedError?: string; }; test(登录失败提示, async ({ page }) { const login new LoginPage(page); await login.goTo(); await login.fillUsername(wrong); await login.fillPassword(wrong); await login.submit(); await expect(login.errorMessage).toBeVisible(); });这种抽象最关键的价值是把页面内部的选择器和交互实现都隔离在类里测试用例只读方法名就知道在干嘛。如果把接口和断言进一步结合可以构造一套很有表达力的测试 DSL。4.3 与 Playwright 自带的断言类型搭配时的注意点Playwright 的expect(locator).toBeVisible()本身是有泛型参数的但它的类型推断有时候不如预期。比如locator()返回Locator类型expect()则重载了locator、page、response等对象TS 在重载内部做判断时会选择对应分支。这里最需要注意的是 elementHandle 和 locator 的区别。很多人会在旧代码里写const el await page.$(#foo)它返回的是ElementHandleElement | null需要做非空判断才能使用而 Playwright 官方推荐的是page.locator()它返回稳定的Locator而且在自动等待的配合下不容易出现空指针问题。用 TS 严格模式后老 API 的 null 判断会逼着你写得更加严谨这也算是个正向作用。我自己实践中踩过的另一个坑是给test.use()传自定义配置时如果配置对象里有可选字段TS 类型对“字段为 undefined”和“字段缺失”的处理会让你困惑。后来我习惯给每个测试的配置项定义一个 interface并显式标出哪些是可选哪些是必选再配合 Playwright 的test.extend泛型参数来定义 Fixture 类型。这样测试代码哪怕写得很长也从来不会出现“devTools 到底是 string 还是 boolean”这种糊涂账。5. 那些高频面试点我用代码挨个验证过never、unknown、infer、泛型约束网上关于“TypeScript 面试”的资料一抓一大把大多是单选题式地考“any 和 unknown 的区别”“void 和 never 的区别”。这类问题背答案没多大意义关键在于你是否真的在写代码时用过它们、为什么 TypeScript 要设计这些类型。我从自己的实践角度逐一拆一下。5.1 any 与 unknown一个放弃检查一个强制收窄这两个名字看起来只差一个字母但哲学完全不同any告诉编译器“别管我我自己负责”unknown告诉编译器“我不知道它是什么但你必须先证明你用得对”unknown的典型用法是处理外部数据。比如 JSON.parse 的返回类型是any但如果你把它包一层并先收窄成合法模型代码会安全很多interface ParsedConfig { debug: boolean; timeout?: number; } function parseConfig(input: string): ParsedConfig { const raw: unknown JSON.parse(input); if (typeof raw ! object || raw null) { throw new Error(配置格式错误); } const candidate raw as Recordstring, unknown; if (typeof candidate.debug ! boolean) { throw new Error(debug 字段必须为 boolean); } return { debug: candidate.debug, timeout: typeof candidate.timeout number ? candidate.timeout : 3000, }; }用unknown作为中间值每次都要经历一次收窄和校验。虽然比直接as ParsedConfig啰嗦但它保证运行时不会出现某个字段突然不是预期类型。在接口请求响应、配置解析、嵌套 JSON 场景里这套“先 unknown 后收窄”的模式比直接断言安全得多。维度anyunknown赋值给任意类型可以不可以必须先收窄任意类型赋值给它可以可以类型检查完全关闭强制开启典型场景短期内快速迁移代码外部不可信数据入口5.2 void 与 never函数的两种极端返回值void表示函数没有返回值或者返回值我们用不上。但never表示“这个函数永远不会正常返回”——比如抛异常、进入死循环、或者声明一个不可能存在的类型。最经典的例子function throwError(message: string): never { throw new Error(message); } function assertNever(value: never): never { throw new Error(意外值: ${JSON.stringify(value)}); }never在业务代码里最大的价值是用来做穷尽性检查。比如处理一个联合类型type PaymentStatus pending | paid | refunded; function handleStatus(status: PaymentStatus): string { switch (status) { case pending: return 等待支付; case paid: return 已支付; case refunded: return 已退款; default: return assertNever(status); // 如果有人往 PaymentStatus 里加了新值这里会编译报错 } }当联合类型新增成员但 switch 忘改时default 分支就会收到一个联合类型里未被覆盖的never类型编译直接报错。这把“手滑漏改”的风险从运行期提前到了编译期非常实用。5.3 条件类型与 infer理解 ReturnType 的底层逻辑网上教ReturnType的人很多但真正能讲清它的不多。它的核心其实是条件类型从复杂结构里“取出”一个子结构的能力——而这又依赖infer这个关键字。// 标准库简化版 type MyReturnTypeT T extends (...args: any[]) infer R ? R : never;它的逻辑是如果类型T是“接受任意参数返回某类型 R 的函数类型”那么R就被infer捕获作为最终结果。当T不匹配函数结构时返回never。我一开始完全看不懂这段后来在业务里遇到“提取一个函数类型的具体返回值的字段类型”的需求才真正明白它的价值function getUser() { return { id: 1, name: Alice, role: admin as const }; } type User MyReturnTypetypeof getUser; // User 被推导为 { id: number; name: string; role: admin }这样当我修改getUser的返回结构时所有依赖User类型的地方都会自动联动了。这里的重点是typeof结合泛型来提取函数类型——你需要同时理解typeof、条件类型和 infer 三件事缺一不可。5.4 泛型约束与 keyof类型安全的业务函数面试题里“泛型约束”听了无数遍我真正理解是从写了一个按 key 更新对象的函数开始的function updateFieldT extends object, K extends keyof T( obj: T, key: K, value: T[K] ): T { return { ...obj, [key]: value }; }这里有两层约束T extends object说明 T 必须是一个对象K extends keyof T说明 K 必须是 T 的键。这样写后才能保证updateField(user, name, Bob)不报错但updateField(user, email, Bob)在编译阶段就提示错误因为email不是 user 的字段。把它组合到业务场景比如一个表单状态 reducertype FormState { title: string; description: string; category: tech | life | note; }; function setFormFieldK extends keyof FormState( state: FormState, field: K, value: FormState[K] ): FormState { return { ...state, [field]: value }; }这种写法强迫你在设计数据模型阶段就想清楚每个字段的类型然后在后续的几十个地方复用好处是字段改名、类型变化时所有错误都会集中暴露而不是散落在各处的any掩盖问题。一些踩过坑之后才总结出的习惯最后分享几个我在实际项目中沉淀下来的习惯谈不上标准答案但确实帮我省了很多时间第一个习惯是所有接口的返回类型必须有明确 interface不用 any。哪怕一开始你觉得“这个响应结构很乱”也值得先定义一份能覆盖 80% 情况的类型后续发现问题再改。类型是活的不必追求一次到位。第二个习惯是严格模式下遇到 null 和 undefined 不靠自觉判断而是靠类型收窄。写page.locator()没问题但写page.$()时返回的是ElementHandle | null必须if (el)才能用。这套“宁可写啰嗦一点也不要吞错误”的思路就是你在严格模式下真正受益的地方。第三个习惯是把类型声明集中在项目里的 types 文件夹中并且明确区分全局类型和模块类型。全局类型放 globals.d.ts模块类型放相应目录按用途分离。当你发现一个团队项目里所有人都在(window as any).foo时就是一个强烈信号该去补一份.d.ts声明文件了。TypeScript 的优势不在于让你少写代码而在于让你从“运行时猜错误”变成“编译期排错误”。这些能力不是看文档看出来的是你在项目里一遍又一遍重构、排查类型报错的过程中长出来的。希望这篇笔记也能帮你少踩几个我踩过的坑。