any 实战手册:5 种武器和真实案例lhx-kit 在
packages/*整个范围内禁止any与as any。这不是教条主义,是因为我们对外发包,每一处any都会渗透到下游用户的 IDE——他们调用我们的 API,看到的提示、自动补全、类型推导都被这一处缝隙污染。本篇把"如何替代any"的工具箱讲透,并配上仓库里 2026-05 那次大扫除的真实改造案例。
很多教程说"any 是逃生通道,少用就好"。在内部业务代码里这话没错;但库代码完全是另一回事:
| 场景 | 对外暴露 any 的代价 |
|---|---|
| 用户 import 你的函数 | 参数和返回值的智能提示直接消失 |
| 用户基于你的类型再做泛型扩展 | 整条类型链路退化成 any |
| 用户接入 strict 模式后审查 | any 触发 noImplicitAny 报错,但根源在你的库 |
| 半年后的你回来重构 | 编译器不会告诉你哪里依赖了"被 any 吃掉的契约" |
核心观点:在库的公共面,
any比"没有类型"更糟糕——前者会让用户误以为有类型,后者至少能引起警觉。
具体到 lhx-kit:8 个发包都是别人会 import 的对象,包括 @lhx-kit/cli(执行命令的命令行参数解析)、@lhx-kit/runtime(用户 axios-like 实例的类型基础)、@lhx-kit/renderer(schema 类型推导)。每一处 any 都会让这些公共面变模糊。
遇到 any 时按这个顺序试。越靠前越好。
最朴素也最强。已知字段就把它们写下来。
反例:
正例:
什么时候用:调用方传的形状你能枚举(哪怕只是部分字段)。永远先尝试这条。
当函数的"输入类型"和"输出类型"有依赖关系,用泛型保留信息流。
反例:
正例:
什么时候用:函数像一根"管道"——输入决定输出形状。
unknown + 类型守卫当你接收的是真正未知的形状(比如解析 JSON、接收第三方 callback 的数据),用 unknown 强制调用方先做 narrowing。
反例:
正例:
或用 zod / typia / 自定义 type guard 函数让 narrowing 更优雅:
unknown 与 any 的本质区别:
any 不要求 narrowing,对所有操作都"放行"——风险静默unknown 强制 narrowing 才能用,编译器逼你处理"我不知道"的事实——风险显式z.infer<typeof Schema>当数据形状由 zod schema 定义时,永远用 z.infer 推导,不要重复声明 interface。
反例:
正例:
好处:
.parse)和编译时类型保持一致,不会出现 schema 增加了字段、类型却忘加的情况lhx-kit 实战:@lhx-kit/config 的 defineProjectConfig、defineOfflineConfig 全部走这条路径——用户写的 project.config.ts 由 zod schema 校验后再以推导出的类型暴露。没有任何一处 any。
as unknown as <SpecificType>(最后手段)当你确实需要绕过类型系统——通常是和第三方类型缺失/冲突的角落里——优先用 as unknown as <Specific> 而不是 as any。
为什么 as unknown as <T> 比 as any 好:
| 维度 | as any |
as unknown as <T> |
|---|---|---|
| 后续读取的类型提示 | any(IDE 提示废了) |
<T>(IDE 知道形状) |
| 后续传给其他函数 | 静默接受任意签名 | 必须形状匹配 |
| 拦截误用 | 拦不住 | 拦得住 |
| 表达"我知道我在做什么" | 弱 | 强 |
关键约束:用了之后要写注释说明为什么——是 typing gap、是 vendor lib 没更新、还是真的有理由 escape。
下面三个改造来自 commit 2747a93,是把 packages/* 全部 any / as any 清空那一批。
packages/vite-plugin/src/plugin.ts 顶部需要判断当前 Vite 是不是 Rolldown 后端的版本——Vite 8+ 才会 export rolldownVersion:
改造前:
改造后:
变化点:
as unknown as <typed shape> 替代 as any——访问 .rolldownVersion 时类型仍然是 unknown | undefined,不会被错当成"任何东西"unknown 而不是 string——因为我们只 Boolean() 它,没必要假设值类型读取 (typed-shape).rolldownVersion 后想拿来用,编译器会强制你 narrow 一次(typeof === 'string' 之类)——这正是 unknown 的价值。
pending.get(key)! 的非空断言packages/runtime/src/request.ts 的 dedupe 拦截器:
改造前:
虽然 ! 不直接是 any,但它是类型系统的逃生口——和 any 同根。biome 的 noNonNullAssertion 规则把它当 lint warning 提示。
改造后:
变化点:
has + get 双查询——一次 get 拿值,用 truthy 检查代替if (inflight) 之后自然 narrow 出 Promise<AxiosResponse>,不需要 !has(key) 和 get(key) 之间理论上 map 可能被改教训:每次想写
!,问自己一句"能不能改写成 narrow"——大多数情况都能。
oxc 选项packages/vite-plugin/src/plugin.ts 配置 minifier 时,需要根据是不是 Rolldown 决定用 oxc 还是 esbuild,但 oxc 字段在 Vite 5/6/7 的 UserConfig 类型里不存在:
改造前:
改造后:
为什么不直接写"正确类型":因为 lhx-kit 的 peerDeps 是 vite ^5 || ^6 || ^7 || ^8——如果只取 Vite 8 的类型,Vite 5/6/7 的安装会编译失败。as unknown as Record<string, unknown> 是一种"保留对象语义但脱离 Vite 版本"的折中。
这种 case 的纪律:
as unknown as <Specific> 而非 as any(更窄)有些场景确实无法用纯类型解决:
| 场景 | 推荐做法 |
|---|---|
第三方库根本没 .d.ts,且没人写 @types/* |
自己在项目里加 src/types/<lib>.d.ts 写一份最小够用的 shim,不要 as any |
| 第三方类型严重错误(你确认它的运行时行为不是它声明的那样) | 写一个 typed wrapper 函数,把 escape 局限在 wrapper 内部,不让它扩散 |
| 真正的动态形状(如 plugin runtime 收到的用户配置) | 用 unknown + zod 校验,不是 any |
Vendor build artifacts 里的 any(不是你的代码) |
不用管——biome 默认不扫 node_modules/dist |
核心原则:把 escape 局限在最小作用域。绝不让 any 出现在公共 API 的签名里。
biome.json 里启用 lint.suspicious.noExplicitAny:
⚠️ lhx-kit 当前 biome 配置默认是 warn 而不是 error——这是一个值得升级的优化项。改成 error 之后,pre-push 钩子(
biome check .)会直接拦下任何含any的提交。
.husky/pre-push 执行 biome check .——只要有 lint warning 都会拦。结合上面把 noExplicitAny 升到 error,等于任何 any 都过不了 push。
.github/workflows/ci.yaml 里的 pnpm lint 这一步同样会跑 biome check .——即使本地 hook 被 --no-verify 跳过,CI 还是会拦红。
人工 review 时关键关注:
as 关键字(不限 as any): any 类型标注// biome-ignore lint/suspicious/noExplicitAny——这条尤其要追问"为什么必须 escape"| ❌ 反例 | ✅ 正例 |
|---|---|
function f(x: any) |
function f(x: unknown) 或精确类型 |
const data: any = JSON.parse(s) |
const data: unknown = JSON.parse(s) 之后 narrow |
arr.find(x => ...) as any |
arr.find(x => ...) as Specific 或返回 T | undefined |
try { ... } catch (e: any) {} |
try { ... } catch (e: unknown) {} 之后 narrow(TS 4.4+ 默认 unknown) |
Map<string, any> |
Map<string, KnownValueType> |
Record<string, any> |
Record<string, unknown> 或精确 union |
(x as any).foo |
(x as {foo: unknown}).foo |
// @ts-ignore 来跳过类型错误 |
修类型,或退而用 // @ts-expect-error <reason> |
any 时回答这三个问题:
any 出现是因为查 generics / utility types 麻烦。花 3 分钟,通常能找到精确类型。unknown + narrow,不是 any。as unknown as <Specific> + 注释说明 + changeset/issue 记录回收。实在挡不住的极端 case——要 // biome-ignore 也行,但写明理由,并把这个 escape 列入下次重构的待办。