「一个草稿订单躺在支付表里,状态是已付款。它从未提交,从未审核,从未进入履约流程。」
这不是支付逻辑的bug。支付函数检查了状态,返回了错误,只是没人处理返回值。真正的问题是:Pay方法从一开始就不该接受草稿订单。类型系统本该在编译阶段就拒绝这行代码。
类型即证明:让编译器替你守门
2019年Alexis King写过一篇影响深远的文章《Parse, don't validate》。核心观点很简单:如果一个函数需要有效邮箱,它的参数就该是「已被证明有效」的类型,而不是原始字符串。
验证是系统边界的一次性事件。一旦通过,类型本身携带保证。
Go的标准做法是用未导出字段锁住构造路径:
ValidEmail结构体的address字段小写开头,包外代码无法直接创建。唯一入口是NewEmail函数,它在门口做完所有校验。之后任何接收ValidEmail的函数都无需重复检查——类型即契约,签名即文档。
SendWelcome函数的参数列表里,ValidEmail四个字就是编译器强制的质量保证书。你不可能传一个未经校验的字符串进去,代码根本编不过。
正方:运行时检查是技术债务
支持编译期防御的工程师有一套清晰的算账方式。
第一笔账是扩散成本。一个Email字符串从HTTP handler渗透到repository、service、第三方SDK,沿途每个函数都要防御性校验。同样的逻辑复制十几份,测试用例呈指数膨胀。
第二笔账是遗忘概率。人会漏看返回值,会假设「上游肯定验过了」。类型系统不会遗忘,不会假设,不会心存侥幸。
第三笔账是重构安全。当你把string改成ValidEmail,编译错误就是一张精确的待办清单,指向每一个需要修补的调用点。运行时检查做不到这一点——你只能祈祷测试覆盖率足够高。
Go的显式错误处理常被批评繁琐,但配合类型驱动设计,错误处理被压缩到边界。内部函数签名干净,逻辑纯粹,「快乐路径」不再被防御代码淹没。
反方:类型体操是过度设计
反对声音同样具体,不是抽象的理念之争。
首要阻力是团队成本。Junior工程师需要理解为什么不能用string直接构造,需要学会看编译错误定位问题。类型系统的门槛是真实存在的培训负担。
其次是Go的类型系统局限。没有泛型时代(或项目 stuck 在旧版本),每个领域类型都要手写重复代码。ValidEmail、ValidPhone、NonEmptyString……样板代码量可观。
更微妙的反对来自实际需求:验证规则会变。今天「非空即合法」的邮箱,明天可能要加企业域名白名单。类型构造器是公共API,修改意味着全链路的破坏性变更。字符串则灵活得多,校验逻辑可以渐进增强。
还有性能考量。值对象意味着更多的内存分配、更频繁的类型转换。高频路径上,零分配的原始类型仍有优势。
我的判断:边界硬化,内部流动
两种立场都有道理,但问题本身被错误地二元化了。
关键不是「要不要用类型系统防御」,而是「在哪里画这条线」。Alexis King的原文标题已经给出答案:Parse, don't validate。解析,而非验证。
HTTP handler是天然的解析边界。请求体进来,一次性完成所有校验,构造领域对象。从此向内,类型携带保证。这个模式不排斥灵活——验证规则变更时,只改NewEmail一处,下游无感知。
订单支付的例子尤其说明问题。草稿订单和已提交订单不该是同一类型的不同状态值,而应该是无法互相替代的类型。Pay方法只接受SubmittedOrder,编译器自然拦住所有非法调用。状态流转不是字符串比较,而是显式的类型转换函数,带着业务规则的名字:Submit()、Approve()、Pay()。
这不是「让编译器写代码」,而是「让编译器帮你记住那些人类必然遗忘的规则」。三周的bug排查时间,足够支付一个季度的类型设计成本。
Go的类型系统确实不如Haskell或Rust表达力强,但unexported字段+构造函数的组合拳,已经能覆盖大部分领域约束。够用,且团队学习曲线平缓。
如果你正在维护一个状态流转复杂的业务系统,找一个最痛的bug——那种「明明检查了但还是漏了」的bug——把它推到编译期。感受一次「错误无法被编译」的安心,再决定要不要推广这个模式。
热门跟贴