ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从Typeless到Zod:放弃无类型编程后的类型安全实践

从Typeless到Zod:放弃无类型编程后的类型安全实践 去年有一段时间我被“更快地写代码”这个念头冲昏了头手头一个内部工具项目又恰好是从零开始于是动了“不要类型”的心思。当时正好看到 Typeless 这类无类型方案主打去掉类型声明、让 JavaScript/TypeScript 开发者回归“纯 JS”的畅快感。我试了一周最终被现实狠狠劝退。今天想把这个经历完整写出来包括我踩过的坑、劝退的真实原因以及后来我替换掉的方案。如果你也在纠结要不要上“无类型编程”这条路这篇内容应该能帮你少走几天弯路。这个选题适合谁主要是被类型系统搞得烦躁的前端开发者、刚接手 TS 项目但被各种类型体操折磨的新手以及在“团队要不要继续上类型”这个问题上反复摇摆的技术负责人。我会把整个过程按选型、踩坑、替代方案、实操落地、问题排查的顺序拆开讲尽量把代码和思路都交代清楚。1. 我为什么会被 Typeless 吸引核心思路与方案选型1.1 Typeless 类方案在解决什么问题先说清楚 Typeless 到底是一个什么东西。严格来说它代表的是一种“去类型化”的开发思路你写的代码不需要维护类型声明运行时也不做类型检查接口出参入参全靠约定。听起来和十年前写 JavaScript 一样但它和“不写 TS”还是有区别——Typeless 类工具更多是帮你把 TS 项目的类型负担“擦掉”或者让你在新模块里彻底不写类型用某种机制自动推断和传递。它主打的卖点有三个第一写代码更快因为不需要考虑 interface、type、泛型这些“心智负担”第二代码更简洁文件体积更小一眼看上去全是业务逻辑第三对新手友好不需要先学一套类型语法就能上手改代码。我当时为什么会心动因为那个内部项目时间紧、需求变来变去每天都被 PM 追着改逻辑。类型定义写了一遍又一遍改一个字段要连带改三四个地方那时候真的会觉得“类型系统在拖我后腿”。加上项目是纯内部工具用户量小、接口也不对外我心想何必自找麻烦不如用 Typeless 类的方案先把业务跑通再说。1.2 我当时的项目背景和选型动机项目本身是一个数据看板需要对接公司内部五六个数据源前端负责拉取数据、做聚合、展示图表。后端接口不是标准 REST很多字段时有时无结构非常“野生”。这种情况下类型系统确实很尴尬——你定义了一个User接口但实际返回的数据缺字段、多字段、类型漂移经常出现undefined报错。我最初的想法是既然数据本身就不规范那不如不要类型直接在代码里做防御式判断。Typeless 这类方案不是正好把类型层去掉让我专注于处理那些“乱七八糟”的数据结构吗于是我在新项目里尝试了这种写法。刚开始半天确实爽——不用写 interface不用管泛型函数拿来就用。但问题在第三天开始集中爆发。下面这部分是我最想说的也是让我最终放弃 Typeless 的真实原因。2. 劝退实录Typeless 在实际项目中的 3 个硬伤2.1 类型信息消失后IDE 和代码提示变成摆设第一个劝退点是开发体验的“高开低走”。刚上手 Typeless 的时候代码写起来确实很爽但切换到已有模块时你就会发现编辑器里所有的变量、函数参数、对象属性全部变成了any鼠标悬停上去没有任何提示。我用的 VS Code平时依赖 TS 的 IntelliSense 提示来回忆函数签名。没有类型之后我只能不停地跳转到函数定义处看实现然后手动记住参数顺序。一次两次还好一个组件里十几个函数每个都要这样翻效率反而比写类型的时候更低。更难受的是对象属性的自动补全也失效了。因为编辑器不知道这个对象有哪些字段它只能猜。我试过连续打错三个字段名最后在运行时才发现是拼写问题。那一刻我突然意识到类型系统最大的价值不是“约束”而是“记忆”——它把数据结构、函数契约固化在代码里让工具能帮你兜底。丢掉类型等于丢掉了一个不会累的助理。2.2 团队协作成本被明显放大我那个项目虽然是内部工具但也不是我一个人写。当第二个同事加入时问题变得更明显。同事接手我写的模块时第一句话就是“这个函数的参数是什么格式”我只好现场口头解释。但每次改完逻辑口头解释就过时了。后来同事开始用最原始的方式互相对接口——把数据结构复制到一个共享文档里手动维护。这等于把类型系统的工作交给了人肉效率低不说还很容易遗漏变更。有几次同事按文档里的结构调接口结果我悄悄改了返回字段名又没有同步文档接口直接崩了。排查了大半天最后发现是字段名不一致。类型系统至少会在编译期报错丢掉了这层保护这些低级错误全部变成了线上运行时错误。我后来认真想过这个问题单人项目可以靠记忆管理数据结构但两个人以上协作时代码本身就是最可靠的沟通文档。类型声明就是这个“代码文档”的核心索引。丢掉类型等于让团队成员之间靠聊天记录和记忆来维持代码契约这显然不是可持续的协作方式。2.3 重构与排查问题的成本陡增最让我崩溃的是重构。那次需求改了一个核心数据结构的字段命名——从user_name改成userName。在 TypeScript 项目里做这种重命名是编辑器全局替换加类型检查十几秒搞定有遗漏马上会报错。但在 Typeless 无类型模式下我全局搜索替换完了以为搞定了结果运行时连续爆了四五个错误每个都要点开堆栈去定位。有些错误出现在根本不相关的模块——原来是某个函数把旧字段名塞进新对象里传下去了。这种“隐性数据流”根本没有办法靠静态扫描查出来只能靠运行时逐个炸。那几天我光是在console.log和调试器里看数据就花了大半天。最终我得出结论类型系统不是免费的午餐但它是重构安全的救生衣。你可以不穿救生衣游泳但风浪来的时候它真的能救命。3. 替代方案的选型与整体设计3.1 我想要的“平衡态”是什么被 Typeless 劝退之后我又花了两天时间想清楚一个核心问题我到底嫌类型系统麻烦还是嫌“维护类型”麻烦答案是后者。我不反感类型推导不反感编译器帮我检查错误我反感的是“为已经确定的数据结构反复写重复的类型声明”。Typeless 的极端是“完全不要类型”而 TypeScript 的默认写法有时候又会让你重复定义类型比如接口返回结构、组件入参、事件对象明明很清晰却要写一堆 interface。我想要的平衡态是尽量减少手写类型声明但保留类型推导和编译期检查的能力。换句话说我要的不是“没有类型”而是“不用手写类型也能拥有类型”。同时因为后端接口数据经常不按常理出牌我还需要一层运行时校验确保运行到代码里的数据结构一定是符合预期的。于是我选择了“运行时校验 类型推导”的组合方案。这套组合在国外社区已经流行了很久只是我一直没有认真尝试过。核心思路是用一个 Schema 定义数据结构由 Schema 自动推导出 TypeScript 类型然后在接口边界处调用 Schema 做运行时校验。这样既有了类型安全又不用手动维护两套类型定义。3.2 为什么选“运行时校验 类型推导”组合这套方案的核心是“单一数据源”原则。你只需要写一份 Schema它同时承担两个职责运行时校验数据是否合法、编译期推导出对应的类型。用代码来说大概长这样import { z } from zod; const UserSchema z.object({ id: z.number(), name: z.string(), email: z.string().email(), age: z.number().optional(), }); type User z.infertypeof UserSchema;看到没我没有手动写interface User类型User完全从 Schema 推导出来。改 Schema类型自动变不会出现“类型声明和校验规则不一致”的尴尬情况。这就是它和 Typeless 最大的不同它不回避类型而是用更聪明的方式去获得类型。3.3 具体方案对比表格我试着把 Typeless、纯 TypeScript、运行时校验类型推导这三套方案放在一起做了一个对比这样方便你直观感受差异。维度Typeless 无类型方案纯 TypeScript 写法运行时校验 类型推导最终方案手写类型声明完全不需要需要几乎不需要由 Schema 推导编译期类型检查无完整完整运行时数据校验无无有IDE 提示与补全基本失效良好良好重构安全性低靠人肉排查高编译器兜底高编译器兜底接口数据结构变化易发生线上错误编译期可能发现编译期运行期双重发现学习成本低中中需要学 Schema 语法从表格能看出来最终方案几乎是在各个维度上都优于 Typeless。它的唯一代价是引入一个新依赖、学一套 Schema 语法但这个成本相比它带来的安全性我认为是可以接受的。4. 替代方案落地实操从配置到接入4.1 核心依赖安装与初始化我用的是 Zod 作为运行时校验库。选它不是因为它功能最强而是因为它在 TS 生态里集成最顺滑、类型推导最准确、文档也最丰富。类似的库还有 Yup、Valibot、Superstruct都可以达到类似效果但如果你的项目是 TypeScript 技术栈Zod 是我实测下来最不容易出幺蛾子的。安装很简单一条命令npm install zod如果你的项目用的是 pnpm 或 yarn对应换成pnpm add zod # 或 yarn add zod安装完成后不需要额外配置 tsconfigZod 开箱即用。我当时的项目是 Vite Vue 3 TypeScript接入过程没有任何兼容性问题这点很省心。4.2 类型 Schema 与推导的定义方式接下来是核心用法。我先定义了项目中几个最常用的数据结构比如用户、数据源配置、图表配置。每个结构用 Zod 的z.object来定义再通过z.infer推导出类型。以用户数据为例完整代码长这样import { z } from zod; // 定义 Schema const UserSchema z.object({ id: z.number().int().positive(), name: z.string().min(1).max(50), email: z.string().email(), avatar: z.string().url().optional(), role: z.enum([admin, editor, viewer]), createdAt: z.string().datetime(), }); // 直接从 Schema 推导出类型 type User z.infertypeof UserSchema;这样写的好处是User类型和UserSchema永远保持同步。如果哪天后端把role的取值改成了owner我只需要改 Schema 里的z.enum([...])所有用到User类型的地方都会自动更新类型定义同时运行时也会校验出新值是否合法。我当时还定义了一个更复杂的场景——带嵌套对象的图表配置const ChartConfigSchema z.object({ title: z.string(), dataSourceId: z.number(), series: z.array( z.object({ name: z.string(), type: z.enum([line, bar, pie]), yField: z.string(), color: z.string().regex(/^#([0-9a-f]{6})$/i).optional(), }) ), options: z.record(z.unknown()).optional(), }); type ChartConfig z.infertypeof ChartConfigSchema;嵌套结构、数组、枚举、正则校验、动态键值这些在日常业务里非常常见的结构Zod 都能直接用。有了这一层 Schema我在前端组件里拿到的ChartConfig对象不光是类型上安全运行时也一定是经过校验的。4.3 接入项目后的具体代码示例定义好 Schema 之后最关键的动作是在“接口边界处”做校验。什么意思就是数据从外层进入你的业务代码时在入口位置统一跑一遍 Schema 检查。我这里有两个实测下来非常好用的模式。第一个模式是封装一个validate函数把校验逻辑集中起来import { z } from zod; function validateT(schema: z.ZodTypeT, data: unknown): T { const result schema.safeParse(data); if (!result.success) { const errors result.error.issues .map((issue) ${issue.path.join(.)}: ${issue.message}) .join(; ); throw new Error(数据校验失败: ${errors}); } return result.data; }用法就是const rawData await fetch(/api/user/1).then((res) res.json()); const user validate(UserSchema, rawData); console.log(user.name); // 类型安全且运行时一定存在第二个模式是用z.input和z.output做数据清洗。有时候后端返回的字段名是下划线风格但前端想用驼峰风格就可以在 Schema 的transform阶段做映射const ApiUserSchema z.object({ user_id: z.number(), user_name: z.string(), }); const UserSchema ApiUserSchema.transform((raw) ({ id: raw.user_id, name: raw.user_name, })); type User z.infertypeof UserSchema; // User 类型是 { id: number; name: string }这样入口处做转换后续所有代码都用规范化的、类型明确的驼峰字段彻底告别“一会儿下划线一会儿驼峰”的混乱。4.4 这个方案的性能与体积取舍可能有朋友会担心运行时校验会不会影响性能这个问题我在接入前也考虑过。实测结论是在普通业务接口数据校验场景下Zod 的开销完全可以忽略。我用一个简单的基准测试跑过解析一个大约 20 个字段的 JSON 对象Zod 的耗时在微秒级别。以单次接口请求动辄几十毫秒到几百毫秒的网络时间来说增加几微秒的校验时间占比可以忽略不计。如果你实在不放心还有两个优化手段对高频调用的 Schema 做缓存避免反复构造只在数据边界做全量校验业务内部不要到处校验减少重复开销。体积方面Zod 压缩后大约几十 KB在现在动辄几 MB 的前端应用里不算什么。如果你的项目对包体积极其敏感可以换成 Valibot它的体积更小但类型推导体验目前不如 Zod 成熟。我自己的选择是优先保证开发体验和类型安全所以留在了 Zod。5. 常见问题与排查技巧实录5.1 问题一Zod 校验和 TypeScript 类型不一致这个问题多发生在团队刚上手的时候。有人会在 Schema 推导类型之外又手动定义一个 interface然后两边不一致编译期没报错运行时的数据和类型对不上。我的建议是一个数据模型只保留一份 Schema 作为唯一数据源不要在别处再手写它的类型定义。如果某个地方需要类型一律用z.infer推导。这样可以彻底杜绝“双份定义”不一致的问题。另外Zod 的.optional()和 TypeScript 的?:在语义上要区分清楚。{ name: z.string().optional() }推导出来的类型是{ name?: string | undefined }与?:语义一致但如果你在 Schema 里写错成z.string().nullable()推导出来的类型就是string | null和?:明显不同。开发时要注意区分这两种情况。5.2 问题二团队里有人直接写 any类型防线形同虚设运行时校验能把数据边界守好但团队内部如果有人图方便到处写any类型推导的优势也会被削弱。这其实是工程文化问题不是工具问题。我的处理方式有两个。第一是在 tsconfig 里开启noImplicitAny强制要求显式写出any增加“有意写 any”的心理成本。第二是在代码审查时约定从接口边界拿到数据后必须走 Schema 校验校验过的数据才允许在业务中传递没有人可以直接把请求结果丢给组件使用。经过一段时间的执行团队基本形成了习惯any的使用频率大幅降低。这里也提醒一句方案再优秀落地时需要配套的工程制度否则就像买了保险不系安全带防护效果会大打折扣。5.3 问题三运行时校验会不会拖慢接口响应前面说性能优化时提过这里再展开讲一个我实际踩过的坑。一开始我在每个组件内部都调用safeParse导致同样的接口数据被解析了二三十次。虽然单次解析只有微秒级但累计下来也会有可感知的浪费而且代码很啰嗦。后来我把校验统一收敛到 API 层数据从fetch返回后只校验一次后续所有组件拿到的都是已经校验通过的干净数据。这样既保证了安全又把重复开销压缩到了最小。这个改动让体积和性能都有明显改善代码也变得更整洁了。5.4 一个排查技巧先用类型收窄定位再用运行时校验兜底最后分享一个我自己摸索出来的排查技巧。当线上出现“数据不是预期结构”这类问题时我的定位顺序是先看编译期有没有类型错误再用 Schema 的报错信息定位具体是哪个字段、哪条路径出了问题。Zod 的报错信息非常详细会精确到path比如series.0.yField和原因比如Expected string, received number。这个信息量比传统“cannot read property of undefined”不知道高到哪里去了。我把validate函数里的错误信息统一上报到日志平台每次线上数据异常都能在日志里直接看到是哪条接口、哪个字段、期望什么类型、实际拿到什么类型排查效率提升非常明显。这套组合方案我已经在内部项目里稳定跑了两个多月再没有出现过因为数据结构变化导致的线上崩溃。相比 Typeless 那段时间的提心吊胆现在的开发体验可以说是“润物细无声”的稳。最后再分享一个小技巧如果你也在做接口数据对接我建议把 Schema 文件和接口定义文件放在同一个目录命名成xxx.schema.ts并且约定“改接口必改 Schema”。这个约定看起来简单但能帮你避免很多“接口文档更新了前端类型还在原地”的尴尬。希望我的这段经历和方案能让你在“要不要无类型”的纠结里找到一个更务实的答案。
返回列表