用TypeScript类型系统驯服AI编程助手:从strict到.d.ts的实战指南
最近总有人问我一句话“AI 编程助手越帮越乱是不是还不如不用”我给的回答通常很直接——可以信但你要先把 TypeScript 的类型系统当成它的安全带。这个判断不是我拍脑袋想出来的。你去看 microsoft/TypeScript 仓库那个 93K Star 的体量再看它背后站着谁Anders HejlsbergTurbo Pascal 作者、C# 首席架构师、TypeScript 之父说他是 TypeScript 圈最硬的那个人恐怕没人反对。他对类型系统的执念恰好就是解决“AI 越帮越乱”的答案。这篇文章不聊虚的只讲怎么在真实项目里用类型把 AI 助手的乱来摁死。无论你用的是 GitHub Copilot、Cursor还是只想在团队里少踩几个类型坑下面这些内容都能直接抄作业。1. AI 编程助手为什么一进 TypeScript 项目就翻车1.1 三个真实的翻车现场我在不少项目里见过同一种尴尬AI 助手很积极但产出的代码让人血压升高。先说我遇到过最典型的三个场景。第一个补全返回类型直接给any。让 Copilot 续写一个获取用户信息的函数它咔咔给你补出Promiseany中间还顺手 import 了一个根本不存在的UserService。你要是图省事点了接受后面所有调用这个函数的代码都会建立在一个错误地基上。更崩溃的是你问 AI 为什么返回any它会一本正经告诉你“这样可以避免类型耦合”。这不是编程这是和稀泥。第二个生成接口继承时自相矛盾。让 AI 重构成“优雅的继承结构”它搞出三层 interface子接口把父接口的字段覆盖成完全不相关的类型。比如父接口里status: active | inactive子接口直接声明status: number。tsc 一跑满屏报错。AI 还坚持这“看起来更灵活”。灵活个鬼这是类型安全的崩塌。第三个用 AI 写 Playwright 测试测试结果是“假绿”。让 AI 写一个登录后跳转的用例它生成的选择器和断言看着都对跑起来也通过但仔细一看断言的元素压根不在页面上或者它等了一个永远不出现的按钮超时前碰巧别的条件满足了。这种假绿比测试失败更可怕因为所有人都会以为功能没问题。这些场景绝不是个例。AI 编程助手的底层是大语言模型它做的是“下一个 token 的概率预测”。它的判断依据是训练语料里“这类代码后面大概率接什么”而不是真正理解你的类型流、数据流和运行时行为。当你的项目类型信息模糊时它的预测空间就无限大预测空间一大幻觉就来了。越帮越乱本质上是你给了它太多自由发挥的余地。1.2 根子在于 LLM 不懂类型你的项目也没给它类型我这里要说得稍微“硬核”一点LLM 不懂类型这件事短期无解但它的优势在于能读。它能读你项目里的.ts文件、node_modules里的.d.ts、以及整个仓库的类型定义。如果你把这些信息摆到它面前它大概率会在概率上倾向生成形状正确的代码。反过来一旦项目里有anyAI 立刻顺着any乱编——因为any等于告诉类型系统“这里不需要检查”也等于告诉 AI“这里随便写”。还有一个隐蔽的问题AI 每次能看到的上下文窗口是有限的。跨文件时它可能根本没看到过那个interface User长什么样只能靠训练语料里的“通用印象”猜。猜对了是运气猜错了是常态。所以你想让 AI 不乱来就得主动把类型地图喂到它的视线里。这也是为什么后面讲.d.ts和类型封装时我会反复强调“让 AI 读得到”这件事。2. 93K Star 背后的答案把类型当成“可执行说明书”2.1 Anders 对类型的一贯立场microsoft/TypeScript 这个仓库的 star 数长期在 93K 这个量级你随便什么时候去看它都在那。它代表的不是某个爆款工具而是一套已经运行了很多年的核心思路类型系统是你在编码时写下的、机器可以验证的文档。Anders 在很多场合表达过类似的观点——类型并不是跟你过不去它在帮你把规则固化下来。人类开发者能读注释、猜意图但编译器不行它只认形状而 AI 编程助手本质上连“耐心的开发者”都算不上它只会飞快地扫一眼现有代码然后猜后面该接什么。这时候类型系统的作用就体现出来了它是人和 AI 都能读的坐标系。你把领域模型、函数签名、参数约束写成 interface、type、泛型相当于给 AI 发了一张标好尺寸的地图。AI 顺着地图走生成出来的代码自然更接近正确答案。没有这张地图它就像进了一个没有路标的迷宫乱走是必然的。2.2 tsc --noEmit给 AI 生成代码装一个质检员很多人没意识到类型检查是 AI 时代最便宜的兜底工具。AI 生成的代码是概率产物它可能 90% 都是对的但剩下 10% 的错误恰恰最致命。你指望 AI 自己发现错误它不会它只会继续生成“看起来像那么回事”的代码。这时候你只需要在 CI 里跑一句tsc --noEmitAI 提交的代码过不了类型检查就合不进去。这就是一道物理护栏。你可能会问ESLint 不也能兜底吗能但粒度不一样。ESLint 查的是风格、未使用变量、明显坏味道类型检查查的是方法是否存在、字段是否缺失、参数类型是否匹配、空值是否被处理、泛型是否收敛。AI 生成的代码里大量表面错误都逃不过类型检查。我自己是把tsc --noEmit放在 CI 的必经环节里的AI 助手生成的代码必须先过了这一关再谈 Code Review。效果非常直接那些“看着能跑一跑就炸”的提交明显变少了。2.3 strict 模式越狠AI 幻觉越少你去看很多项目为了让 AI 写代码更“顺畅”人为关掉了strict甚至关掉noImplicitAny。这种做法我强烈反对。严格模式不是给你的负担而是给 AI 的约束。你开noImplicitAnyAI 想偷懒写个隐式any编译器立刻报警你开noUncheckedIndexedAccessAI 看到arr[0]的类型是T | undefined就会自动补空值判断你开exactOptionalPropertyTypesAI 想把undefined塞进可选属性时就会被打回。这些“麻烦”对你来说是保护对 AI 来说则是明确的规则信号这个项目不允许和稀泥式的代码。我见过太多团队在这件事上走了弯路先关严格模式让 AI 跑起来跑着跑着类型污染越来越严重最后连人都不敢改代码了。千万别学他们。严格模式越狠AI 越不敢乱来。这不是玄学是概率学——当一个选择摆在你面前会触发编译器报错时AI 选它的概率会大幅下降。3. 让 AI 先读 .d.ts声明文件是 API 的坐标3.1 .d.ts 描述的是形状不是实现.d.ts声明文件最容易被新手忽略却是 AI 编程助手最依赖的东西。它只描述类型形状不涉及任何实现逻辑。这就像一本带目录的说明书你不需要知道某个函数内部做了什么只需要知道它接收什么、返回什么。AI 写代码时从node_modules里读到的第三方库类型本质上就靠这些.d.ts。如果你用的第三方库没有类型声明AI 就只能靠训练语料里的“记忆”猜 API 名字猜出来的大概率是错的。比如一个库明明叫extractText()AI 非要写成getText()。你能说它故意捣乱吗不能它只是没有拿到准确的 API 坐标。同理你自己项目内部也可以写.d.ts来定义公共模块的边界。给内部模块写声明等于给自己代码画了一张地图AI 调用时自然按图索骥。3.2 从零手写一个模块声明假设你有个第三方包some-sdk没有官方类型。你可以建一个types/some-sdk.d.tsdeclare module some-sdk { export interface ClientOptions { baseURL: string; timeout?: number; headers?: Recordstring, string; } export interface User { id: string; name: string; email: string; } export class Client { constructor(options: ClientOptions); getUser(id: string): PromiseUser; listUsers(): PromiseUser[]; } export function createClient(options: ClientOptions): Client; }这之后你在业务代码里调用createClient或client.getUser时AI 就能从声明文件里读到准确的入参和返回值。它不会再猜getUser是同步还是异步因为类型里写了PromiseUser它也不会把ClientOptions里的baseURL漏掉因为类型里标了必填。难点在于让这个types文件夹被 TypeScript 正确识别。我常用的配置是下面两种选一个就行{ compilerOptions: { typeRoots: [./node_modules/types, ./types] } }或者直接在include里把types目录加进去{ include: [src, types, tests] }这里注意一个细节typeRoots是给“环境类型包”用的它会把对应目录下的.d.ts当作全局参与者而include是让这些文件参与编译。很多项目两者混着用也能跑通但我建议至少保证include覆盖到types目录这样最不容易踩坑。3.3 types 文件夹如何使用以及如何喂给 AI既然你看到了热搜里“typescript types文件夹的声明文件 如何使用”这个问题我就把三种常见姿势一次讲清楚。第一种全局类型。你把一些通用接口放进types/global.d.tsexport {}; declare global { interface Window { __APP_VERSION__: string; } }这样你可以在任意文件里直接用window.__APP_VERSION__而不需要任何 import。第二种模块声明。就是我刚才写的declare module some-sdk这种。它专门用来给无类型的 JS 模块补类型AI 引用这个模块时就能拿到正确的类型签名。第三种路径映射。如果你不想用declare module也可以把真实类型文件放在types/下然后通过paths让项目里的引用指向它{ compilerOptions: { baseUrl: ., paths: { some-sdk: [./types/some-sdk.d.ts] } } }这些配置做完之后最关键的一步让 AI 助手真的能“吃到”这些类型。我个人的经验是在项目根目录维护一份CONTEXT.md或者AGENTS.md里面写清楚“公共 API 的类型定义见types/目录所有调用必须符合这些类型”。Cursor 这类工具在读取仓库上下文时会优先扫描这类文档然后主动去翻types/下的定义。这个习惯养成之后AI 生成 API 调用代码的错误率会肉眼可见地下降。4. interface 继承和 static 重写把领域模型钉死在类型里4.1 用 interface 继承画出实体关系图很多开发者在 AI 编程时没意识到一件事你对领域模型的描述越精确AI 的生成结果越可控。而interface继承就是描述领域关系最直接的方式。举个例子。你有实体基类interface BaseEntity { id: string; createdAt: Date; updatedAt: Date; } interface User extends BaseEntity { name: string; email: string; role: admin | user; } interface Admin extends User { permissions: string[]; }当 AI 要写一个生成Admin的代码时它顺着继承链能看到Admin必须有id、createdAt、updatedAt、name、email、role还要有permissions。如果它漏掉其中一个字段tsc会直接报错。这比你在注释里写一百遍“注意要带上基础字段”都管用。interface继承的本质是把数据结构的约束变成编译器可以验证的规则。规则一旦可验证AI 就不再靠猜。4.2 多继承、交叉类型与属性冲突的边界interface还能同时继承多个接口。这个特性在组合“横切关注点”时特别有用interface Timestamped { createdAt: Date; updatedAt: Date; } interface SoftDeletable { deletedAt: Date | null; } interface Product extends Timestamped, SoftDeletable { sku: string; price: number; }这里要特别提醒一个坑当两个父接口有同名属性但类型不一致时interface继承会直接报错。interface A { value: string; } interface B { value: number; } // 报错接口 C 无法同时扩展 A 和 B // interface C extends A, B {}你可以改用交叉类型type C A B;但这样一来value的类型会变成string number也就是never——它其实是一个不可能存在的类型。所以如果你确实需要合并同名但不同型的属性更好的做法是用Omit剔除冲突字段后再自己重定义type C OmitA, value OmitB, value { value: boolean };这些边界规则AI 是不知道的。它只会照着“继承”的词面意思顺手写然后让tsc替你收拾残局。所以我在项目里会把这种冲突场景写进类型注释里让 AI 看到类似“如果有属性冲突用 Omit 合并”的说明。它虽然不能完全理解但会照着提示去生成更稳妥的结构。4.3 static 继承与重写AI 最容易翻车的角落相比接口继承static继承和重写是 AI 更常翻车的地方。你要知道在 TypeScript 里一个类的类型世界是分成两半的实例侧的MyClass和静态/构造侧的typeof MyClass。AI 经常把静态方法当成实例方法调用或者重写继承来的静态方法时忘了加static关键字。看个例子。我们用BaseRepository定义静态方法class BaseRepository { static tableName: string base; static listT extends typeof BaseRepository(this: T): PromiseInstanceTypeT[] { return fetch(/api/${this.tableName}).then((res) res.json()); } } class UserRepository extends BaseRepository { static override tableName users; }这里有两个点值得展开。第一static override是 TypeScript 4.3 之后支持的重写标记它的作用是明确告诉编译器“我在重写父类静态成员”。如果拼写错了或父类没有这个静态成员编译器会直接报错。你可以在tsconfig里开启noImplicitOverride强制所有重写都显式写override。这对 AI 尤其重要——它写tableName users时如果没写override编译器也不至于报错但一旦开了noImplicitOverrideAI 就会被迫把重写的意图写清楚。第二list方法的泛型用了T extends typeof BaseRepository这让UserRepository.list()的返回类型变成PromiseInstanceTypetypeof UserRepository[]也就是PromiseUserRepository[]。如果你写成普通的方法this指向运行时调用它的类但返回类型不会自动跟着变。这个多态this技巧能保证 AI 调用子类的静态工厂方法时拿到的类型是子类而不是基类。我建议在项目里给所有静态方法都注明“静态方法只通过类调用不通过实例调用”并配一个类型测试。AI 在生成代码时遇到这种明确的类型约束就不会再犯“用实例调 static”的低级错误。5. Playwright TypeScript把 AI 生成的测试拉回正道5.1 为什么 AI 生成的测试总是“看起来绿”AI 写测试代码有个致命问题它太想讨好你了。你让它写一个登录测试它会尽力生成一段“能通过”的代码而不是“能验证正确行为”的代码。于是它可能故意忽略等待时机或者把断言写在一个无关紧要的元素上比如await expect(page.locator(body)).toBeVisible()。这种断言任何时候都能过但它什么都没验证。还有更隐蔽的情况AI 使用page.waitForTimeout(1000)这种写死的时间等待跑起来绿换个环境就红。它即便知道 Flaky 测试不好也没有能力分析真实的异步触发条件。类型系统能在两个层面帮到我们。第一把页面操作封装成强类型方法AI 只能通过这些方法走流程第二让expect的断言目标必须是具体的可观测结果而不是一个所有页面都有的body。5.2 把页面操作封装成带类型的地图我来演示一个典型做法。先定义一个LoginPage接口把可执行的操作收敛起来import type { Page } from playwright/test; export type LoginPage { visit: () Promisevoid; fillCredentials: (username: string, password: string) Promisevoid; submit: () Promisevoid; }; export function createLoginPage(page: Page): LoginPage { return { visit: async () { await page.goto(/login); }, fillCredentials: async (username, password) { await page.getByLabel(用户名).fill(username); await page.getByLabel(密码).fill(password); }, submit: async () { await page.getByRole(button, { name: 登录 }).click(); }, }; }这之后AI 生成的测试代码里fillCredentials的参数类型是明确的字符串它不会突然传一个数字进去visit返回Promisevoid它就必须await。这些约束会自动传导到 AI 的生成过程里。再看断言部分。你要习惯写“针对结果”的断言test(登录成功跳转到首页, async ({ page }) { const login createLoginPage(page); await login.visit(); await login.fillCredentials(admin, secret); await login.submit(); await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByRole(heading, { name: 仪表盘 })).toBeVisible(); });这些断言不再是“页面有个 body”这种泛泛而谈而是具体的跳转结果和关键内容。类型系统并不能阻止 AI 写弱断言但它能帮编译器检查断言的目标是否真实存在的选择器。配合tsc --noEmitAI 生成测试后必须过类型检查很多低级错误在提交前就已经被打回。5.3 fixture 类型注入前置状态Playwright 的 fixture 机制是用来封装“前置状态”的。它能跟 TypeScript 类型结合得很好。比如登录状态是每个测试都需要的前置条件import { test as base, type Page } from playwright/test; import { createLoginPage, type LoginPage } from ./login-page; type AppFixtures { loginPage: LoginPage; }; export const test base.extendAppFixtures({ loginPage: async ({ page }, use) { const login createLoginPage(page); await login.visit(); await login.fillCredentials(admin, secret); await login.submit(); await use(login); }, });这样测试用例里可以直接说“我只要一个已经是登录态的页面”而不用重复写登录流程test(登录后可访问个人资料, async ({ loginPage, page }) { await page.goto(/profile); await expect(page.getByRole(heading, { name: 个人资料 })).toBeVisible(); });AI 在生成用例时通过类型定义能知道loginPage已经帮你完成了登录流程就不会重复调用fillCredentials和submit。这既减少了测试代码冗余也让 AI 生成出的测试路径符合你的真实业务场景。我实测下来这套封装对 AI 写 E2E 测试的帮助非常明显——它从“乱写选择器”变成“按你提供的操作地图走流程”。6. 建议用“类型驯服 AI”的落地清单从 tsconfig 到 CI下面是我自己项目里真正执行过的操作按优先级排了序你可以直接照抄。打开strict这是前提没有严格模式后面都是空谈。尤其noImplicitAny必须开。清理公共 API 的any把返回值改成精确类型对于“不确定是啥”的场景用unknown代替any然后在使用处收窄。给项目中所有无类型或类型不全的第三方模块补.d.ts放到types/目录并确保include覆盖到它。用interface继承表达领域模型让 AI 从继承链上读到完整的字段约束。静态方法和参数用了泛型约束typeof的务必写清楚项目里开启noImplicitOverride强制 AI 改写静态成员时带上override。测试代码用 Playwright TypeScript 做页面封装把可操作行为收敛成带类型的接口。在 CI 里加入tsc --noEmit让 AI 生成的代码无法跳过类型检查。配合CONTEXT.md告诉 AI 先读types/目录再写代码。这几条不是一次性能做完的。我自己是先从tsconfig下手把所有any清掉然后给最核心的 SDK 补了.d.ts最后才把 Playwright 测试的页面封装统一。大概用了一个迭代周期效果很直接AI 助手补全出来的代码我点“接受”的次数明显变多返工从“每次都要改”变成“偶尔微调”。类型系统就是那个跟 AI 把规矩讲清楚的人。你把规矩立到位它就不敢乱来也就不会越帮越乱了。