我接手一个订单计费服务时,第一个让我皱眉的类就是 PricingDetails。它不是什么复杂业务对象,就是一个满身可空字段的价格详情结构体:基础价、折扣、税、运费、渠道、地区……全都平铺在里面。随着促销活动、阶梯价、区间价一批批加进来,这个类被越堆越大,最后变成人人害怕的“吨级对象”。这次重构的目标很明确:把 PricingDetails 从字段堆砌拉回类型建模的正轨,让非法组合在编译期就报错,而不是上线后靠日志去猜。如果你也在维护类似的价格模型、订单模型或者任何带一二十个可选字段的业务对象,这篇重构复盘应该能给你一些直接能用的思路。
1. 字段堆砌的病灶:真正伤人的不是字段多
1.1 一个典型的 PricingDetails 长什么样
先看重构前的代码,这种形态在很多业务系统里都见过。一个价格详情类,里面一二十个字段全部可空,业务上“哪些字段组合在一起才有意义”完全靠文档和口头约定:
// 重构前:字段堆砌的典型形态 export interface PricingDetails { basePrice?: number; discountAmt?: number; discountRate?: number; taxRate?: number; taxAmt?: number; shipFee?: number; handlingFee?: number; originPrice?: number; settlePrice?: number; minPrice?: number; maxPrice?: number; regionCode?: string; channelType?: string; priceUnit?: string; currency?: string; remark?: string; }这十六七个字段每个都可空,表面意思是“没有这个价格组成部分”,实际上把类型系统彻底废掉了。创建端按当前场景填一部分字段,消费端靠 if 判断字段是否存在来决定怎么展示、怎么算税、怎么给下游。代码里到处是这种分支:
function getDisplayPrice(details: PricingDetails): string { if (details.basePrice !== undefined && details.minPrice === undefined) { return `¥${details.basePrice}`; } if (details.minPrice !== undefined && details.maxPrice !== undefined) { return `¥${details.minPrice} ~ ¥${details.maxPrice}`; } // 还有一堆分支…… }这段代码看起来还能跑,但它背后藏着一个致命问题:类型允许的状态空间,远远大于业务上合法的状态空间。只要字段存在,编译器就闭嘴,真正能不能用取决于运行时数据和运气。
1.2 字段堆砌的三笔隐性债务
第一笔债是无效状态合法化。类型系统允许 100 个字段任意组合,但业务上真正合法的组合可能只有四五个。这意味着所有错误都能通过编译:basePrice 填了 100,discountAmt 却能填 300;taxRate 和 taxAmt 同时存在,可两者算出来根本对不上;minPrice 是 100,maxPrice 是 50。这些脏数据在数据库里能存,在接口里能传,直到下游账算完才发现不对劲,然后一层层排查到底是哪个环节塞进来的。
第二笔债是认知负担呈指数增长。字段越多,组合越爆炸,新同学看到这个类型的第一反应通常是“我该填哪些”。想搞清楚只能翻文档、问老员工,而文档大概率已经过期,老员工自己也要靠 grep 调用方才能确认。更别说同一个字段在不同渠道里含义还不一样,比如 channelType 在 A 系统里是字符串,在 B 系统里又变成另一个枚举。
第三笔债是变更放大。新加一种价格形态,比如“定金预授权价”或者“多币种到账价”,需要在创建、校验、展示、对账四个地方各加一段 if。漏一个就是线上 bug,而且经常是那种只在特定渠道、特定商品上才触发的偶发 bug,复现成本极高。我见过不止一次,为了支持一个新场景,老逻辑里所有取值点位都要过一遍,谁都不敢说自己改全了。
1.3 动手重构的判断信号
并不是所有字段多的类都需要立刻重构,但出现下面几个信号时,我建议直接排期:
- 字段数量已经超过 8 到 10 个,但业务形态其实只有两三套;
- 代码里出现大量“仅当 xxx 字段存在时,yyy 字段才有意义”的注释;
- 同一份数据到不同渠道时,要删掉或填上不同的字段;
- 运行时校验逻辑散落在三个以上文件里;
- 每次加需求,至少三个人都要动同一个类型。
出现两个以上信号,就应该动手。而且不用一上来就重构整个系统,先从最痛的一个类开始。PricingDetails 就是当时最好的切入点,因为它被十几个模块引用,是名副其实的“公共瓶颈”。
2. 重构前先盘点:画清模型边界再动手
2.1 第一步不是写代码,而是画出字段组合矩阵
我做的第一件事不是打开编辑器,而是把线上调用方、历史接口文档、消息协议翻了个底朝天,把 PricingDetails 在使用中出现的所有组合列成一张矩阵表。这张表是重构的地图,没有它后面所有设计都是在瞎猜。
| 业务场景 | 必填字段 | 可选字段 | 绝对不应出现 |
|---|---|---|---|
| 单品直购固定价 | basePrice, currency | discountAmt, shipFee | minPrice, maxPrice, tiers |
| 展示用区间价 | minPrice, maxPrice, currency | regionCode | basePrice, taxAmt |
| 批发阶梯价 | tiers, currency | channelType | discountAmt, taxAmt |
| 下单后汇总价 | basePrice, taxAmt, shipFee | discountAmt, promoAmt | minPrice, maxPrice |
这张表差点让我发现一个线上问题:老接口里有一个组合,把区间价和固定价混在一起返回,下游展示时随机选了一个价格,谁都没发现。后来在盘点阶段翻接口文档时才对出来。所以说盘点阶段的意义不只是为设计服务,它本身就是在做一次全量业务规则梳理。
做完矩阵之后还要做一件事:把每个字段的所有使用点找出来。我在 IDE 里对每个字段做 “Find Usages”,记录它到底在哪些场景被读、被写。这一步很枯燥,但能防止重构到一半时漏掉某个隐藏调用点。
2.2 区分稳定维度与易变维度
盘点完之后要做的不是马上设计类型,而是先分清楚哪些维度是稳定的,哪些维度是易变的。定价的核心稳定维度其实特别少:金额、币种、数量、税率。容易变的维度就多了:渠道、地区、促销策略、结算周期、价格单位。
稳定维度应该沉淀成基础类型,成为整个模型的骨架。易变维度不要钉死在字段里,而是应该放在 scenario 分支之外,由外部策略配置驱动。这个思路我在多个项目里验证过:凡是把渠道、地区这种高频变动的维度直接塞进核心模型的,后面都会被迫改类型、改接口、改消费方,牵一发动全身。
可以拿装修做个类比。稳定维度是承重墙,易变维度是软装。装修不能砸承重墙,但软装得能随时换。字段堆砌的问题就是把软装也浇筑进了承重墙里,换个渠道就像砸墙,不出事才怪。
2.3 设计目标:让非法状态不可表示
我在这次重构里给自己定了一条原则:非法状态不可表示(make illegal states unrepresentable)。一句话解释就是:如果一个状态组合是业务上非法的,它就不应该能被类型系统构建出来,不是靠文档约定“不许传”,而是从根上“传不进去”。
这个原则听起来有点理想化,但落实起来其实就是几条具体要求:每个业务场景有唯一的类型分支,分支之间字段互不混用;涉及数量关系的地方(比如区间价 min 必须小于 max)要把约束写进类型的构造方式里;所有金额、税率都要有明确的语义类型,而不是裸 number。
当然这不是银弹。外部进来的脏数据依然需要运行时校验,但校验的位置应该收敛到系统边界,而不是散落在业务代码各处。类型模型完成后,内部逻辑可以大胆假设数据可信,只有入口需要防御,整个防御的面一下子缩小了。
3. 类型建模的三件武器:精确化、判别联合、品牌类型
3.1 第一件武器:把可空字段压缩到不能再压缩
我的第一个动作,是把那些零散的 number 字段聚合成业务上有意义的值对象。number 本身没有语义,“金额加币种”才是一个完整语义,所以先把金额和币种捆绑起来:
type Currency = 'CNY' | 'USD' | 'EUR'; declare const positiveAmountBrand: unique symbol; type PositiveAmount = number & { readonly [positiveAmountBrand]: void }; interface Amount { readonly value: PositiveAmount; readonly currency: Currency; } interface PriceRange { readonly min: PositiveAmount; readonly max: PositiveAmount; readonly currency: Currency; } interface FixedPrice { readonly amount: Amount; } interface CompositePrice { readonly base: Amount; readonly discount?: Amount; readonly tax?: TaxAmount; readonly shipFee?: Amount; }这里有几个细节值得说。一是 readonly,一旦创建就不允许再改,避免同一个价格对象在不同模块里被偷偷修改。二是把 basePrice、discountAmt、taxAmt 这种纯数字升级成 Amount 值对象,写代码时再也不会把“金额”和“税率”混在一起传。三是可空字段被极大压缩,原来的十几二十个可空字段,先变成三四个非空值对象字段,可空只剩 discount、tax、shipFee 这种真正可选的组成部分。
关于 PositiveAmount,第一眼看到可能会觉得绕,它的作用我会在 3.3 专门讲,这里是先让 Amount 的 value 类型从一开始就是“正金额”,而不是一个什么数都能装的 number。
3.2 第二件武器:用判别联合拆掉上帝对象
真正的重头戏是把 PricingDetails 改成判别联合(discriminated union)。核心思路是:用一个字段做判别符,不同场景拥有互相独立的字段集合,互不污染。
interface PriceTier { readonly minQty: number; readonly unitPrice: Amount; } type PricingDetails = | { readonly scenario: 'fixed'; readonly price: FixedPrice } | { readonly scenario: 'range'; readonly range: PriceRange } | { readonly scenario: 'tiered'; readonly tiers: readonly PriceTier[] } | { readonly scenario: 'composite'; readonly composite: CompositePrice };拿到这样的类型,消费方再也不用先判字段存在性再决定业务含义,而是先 switch 分支,再在每个分支里放心访问专属字段。编译器会在每个分支里帮你收窄类型,比如走fixed分支时,details.range根本不存在,想访问都访问不了。
配合穷尽检查(exhaustive check),这套模型还有一个额外的红利:以后加第四种价格形态时,编译器会在所有 switch 的地方报错,提醒你别漏分支:
function assertNever(value: never): never { throw new Error(`Unexpected value: ${JSON.stringify(value)}`); } function describePrice(details: PricingDetails): string { switch (details.scenario) { case 'fixed': return `固定价 ${details.price.amount.value}`; case 'range': return `区间价 ${details.range.min.value} - ${details.range.max.value}`; case 'tiered': return `阶梯价 ${details.tiers.length} 档`; case 'composite': return `汇总价`; default: return assertNever(details); } }这里老生常谈一句:default: return assertNever(details)不是可有可无的兜底,它是把“所有分支都处理完”这件事变成编译期检查的关键。谁要是新增了一个 scenario 忘了处理,TS 会直接报错,因为details在 default 分支里类型已经不是never。
为什么不用继承或者给原接口继续打补丁?因为联合类型天然表达“互斥的分支集合”,新增分支不需要改已有分支。继承会引入子类向上转型的问题,运行时还要靠 instanceof 判断;而联合类型用字符串字面量做判别符,序列化友好,跨接口传输时就是普通 JSON,下游直接按字符串判断,不需要任何类加载机制。
3.3 第三件武器:品牌类型约束数量关系
最后一块拼图是品牌类型(branded type)。它的原理很简单:给 number 类型一个编译期的假标记,运行时它还是一个 number,不影响序列化和普通运算,但在编译期它和普通 number 不再是同一类型。
declare const positiveAmountBrand: unique symbol; type PositiveAmount = number & { readonly [positiveAmountBrand]: void }; declare const taxRateBrand: unique symbol; type TaxRate = number & { readonly [taxRateBrand]: void }; function makePositiveAmount(value: number): PositiveAmount { if (!Number.isFinite(value) || value <= 0) { throw new Error(`非法金额: ${value}`); } return value as PositiveAmount; } function makeTaxRate(rate: number): TaxRate { if (rate < 0 || rate > 1) { throw new Error(`非法税率: ${rate}`); } return rate as TaxRate; }为什么需要它?因为业务上很多数量关系没法靠普通类型表达。“金额必须大于 0”“税率必须在 0 到 1 之间”“区间价里 min 必须小于等于 max”,这些约束在 plain number 下完全不存在。一旦把类型升级为品牌类型,业务代码里想直接构造一个负数金额,编译期就过不去,必须老老实实走makePositiveAmount工厂函数:
interface PriceRange { readonly min: PositiveAmount; readonly max: PositiveAmount; readonly currency: Currency; } function createPriceRange(min: PositiveAmount, max: PositiveAmount): PriceRange { if (min.value > max.value) { throw new Error(`非法价格区间: min=${min.value}, max=${max.value}`); } return Object.freeze({ min, max, currency: 'CNY' }); }注意这里的设计逻辑:正值约束由品牌类型在编译期挡住,min 小于等于 max 这个约束放工厂函数里运行时校验。因为“两个正数谁大谁小”没法用类型表达,但把它收敛到唯一一个创建入口,全系统只需要在这一处做防御。
用品牌类型有个经验之谈:不要到处制造 brand,只有那些“业务上绝对不可违反”的约束才值得建模。比如金额正值、税率范围这种。像“折扣金额小于实付金额”这种业务规则,虽然也是约束,但会随促销策略变化,把这种规则写进类型里会让模型变得僵硬,反而不利于后面扩展。
4. 迁移实操:从混乱到约束的安全落地过程
4.1 新旧并行:别急着删老类型
重构不是把文件一删重写,尤其 PricingDetails 被十几个模块引用,直接推翻没人敢接。我的做法是让 V1 和 V2 并行一段时间:老接口继续返回 V1,新模块开始依赖新类型,中间加一个适配层负责互相转换。
function toV2(input: PricingDetailsV1): PricingDetails { if (input.minPrice !== undefined && input.maxPrice !== undefined) { return { scenario: 'range', range: { min: makePositiveAmount(input.minPrice), max: makePositiveAmount(input.maxPrice), currency: input.currency ?? 'CNY', }, }; } // 其他分支的转换…… throw new Error(`无法识别旧数据: ${JSON.stringify(input)}`); }并行期的好处是风险极小:老调用方不用动,新代码先尝到类型约束的甜头。适配层里的转换逻辑相当于把“字段堆砌形态”到“类型建模形态”的映射显式化,而这些映射正是后面迁移调用方时最可靠的参考依据。
4.2 入口守卫:把校验收敛到边界
并行期之后,最重要的一个动作是把所有外部入口收口,统一经过一个解析函数。外部世界传给我们的数据是unknown,必须在这个边界完成“从不可信到类型可信”的转换,而不是在内层业务代码里到处写校验。
export const PricingScenario = { FIXED: 'fixed', RANGE: 'range', TIERED: 'tiered', COMPOSITE: 'composite', } as const; export type PricingScenario = typeof PricingScenario[keyof typeof PricingScenario]; function parsePricingDetails(input: unknown): PricingDetails { if (typeof input !== 'object' || input === null) { throw new Error('PricingDetails 必须是对象'); } const record = input as Record<string, unknown>; const scenario = record.scenario; if (scenario === PricingScenario.FIXED) { const price = parseFixedPrice(record.price); return { scenario: 'fixed', price }; } if (scenario === PricingScenario.RANGE) { const range = parsePriceRange(record.range); return { scenario: 'range', range }; } // 其他分支…… throw new Error(`未知场景: ${String(scenario)}`); }有些人会觉得类型建模这么好,为什么还要运行时校验?这里要澄清一个概念:类型系统只约束编译期,外部世界是不可信的。数据库里可能躺着 5 年前的脏数据,消息队列里可能有别的团队写的不符合当前契约的数据,这些都要靠边界守卫把它挡住或转换成合法模型。
边界守卫带来的另一个好处是,业务代码里那些“if (details.basePrice == null) return”的防御逻辑可以大面积删除。内部代码默认数据合法,出问题就是 bug,直接报错,而不是静默 return 一个空结果把问题吞掉。
4.3 字符串枚举用 const 对象,不要用 enum
建模过程中必然会遇到各种类型枚举,比如渠道类型、价格场景。在 TypeScript 里我强烈建议用as const对象加派生联合类型,而不是enum。原因很简单:TS 的 enum 是带有运行时对象和名义类型语义的,跟字符串字面量联合类型互不通用,跨模块或者跟后端契约对接时经常出现“明明长得一样,类型却不兼容”的诡异问题。
// 推荐:纯字符串,结构类型天然通用 export const PricingScenario = { FIXED: 'fixed', RANGE: 'range', TIERED: 'tiered', COMPOSITE: 'composite', } as const; export type PricingScenario = typeof PricingScenario[keyof typeof PricingScenario];使用的时候写PricingScenario.FIXED,既有自动补全,又保持字符串字面量类型,接口传输时就是普通字符串,没有任何额外运行时开销。靠typeof PricingScenario[keyof typeof PricingScenario]这个技巧,对象的 value 集合自动成为联合类型,加一个新 key,联合类型自动跟着变,不需要手动维护。
4.4 小步替换调用方:一个 PR 只处理一个场景
类型定义好、入口守卫建好之后,剩下的就是按调用方清单逐个替换。我给自己定了节奏:一个变更只处理一个 scenario。替换顺序按调用链从数据源往下游走,先切数据源,让入口解析返回新模型,然后看哪里编译报错,顺着报错一个个往下修。
编译器就是最好的变更清单。每改一处,TS 会把所有访问旧字段的代码都红出来,比人肉搜索安全得多。整个过程中要忍住“顺手把所有地方都改完再一起编译”的冲动,那样一旦出错,你根本分不清是哪个调用方的问题。分批提交还有个好处:每个变更可以独立回滚,出问题了不用牵连全部。
到最后一两个变更时,老类型其实已经没人用了,删掉一个类型定义和几个适配函数,风险极低。我甚至在删老类型那天,把适配函数也一起删了,因为已经没有任何入口会产生 V1 数据。
5. 重构踩坑实录:常见问题与排查技巧
5.1 五个典型问题速查表
这次重构不是一帆风顺,很多问题都是改着改着才暴露的。把最常见的几个问题整理成一张速查表,给后面做类似重构的同学当参考:
| 问题表现 | 根因 | 排查思路 |
|---|---|---|
旧代码还在用details.basePrice,编译直接报属性不存在 | 调用方还没迁移到新类型 | 先加场景判别,再替换访问,最后删旧类型 |
| 入口 parse 抛异常,但线上数据看着正常 | 线上还是 V1 形态,缺 scenario 字段 | 并行期适配层识别 V1 结构并转换,不要直接拒绝 |
| 穷尽 switch 漏了分支但编译器没报错 | switch 后没有assertNever兜底,函数有 undefined 返回路径 | 给 switch 加 default 分支,调用assertNever |
| 品牌类型跟普通 number 混用时疯狂报错 | 这正是设计意图,但误伤太多 | 在工厂函数统一转换,业务内部不要到处 cast |
| 新业务又加字段,老类型不够用 | 建模粒度太粗或新增了本质不同的形态 | 优先新增 scenario 分支,而不是往已有分支加可选字段 |
其中最值得展开的是最后一行。建模完成后,新的需求还会不断来,这时候最容易犯的错误是往某个分支里加一个可选字段“先顶着”。一旦这么做了,那个分支的可空字段会慢慢重新长出来,类型建模就悄悄退化成字段堆砌。正确做法是判断这个新需求到底属于已有形态,还是本质上的新形态。属于后者,就加分支;属于前者,就调整分支内的字段。判别联合的美妙之处正在于加分支不动老代码,编译器会替你兜底。
5.2 三个独家调试技巧
技巧一:在独立文件里先做“类型草稿”。不要一上来就在生产代码里大改。我会单开一个类型测试文件,把所有新类型定义放里面,用几个典型的构造调用验证组合关系,甚至在 playground 里反复尝试联合类型的写法。等草稿稳定了,再拷进生产代码。这样试错成本极低,也不会污染正在运行的模块。
技巧二:给每个分支起可读的业务名,不要叫 VariantA、TypeB。类型名就是团队沟通的语言,名字起得好,代码评审和后续维护都会顺畅很多。比如fixed、range、tiered、composite这四个 scenario,光看名字就知道价格是什么形态,开会讨论新需求时直接说“加一个 flashSale 场景”,比说“加一个 case 3”清晰得多。
技巧三:把“字段到场景”的映射表留在注释里。重构刚完成时一切清晰,但三个月后就会开始模糊。我会在模型文件顶部保留一张简短注释,说明每个分支对应的业务场景、代表的数据来源、关键的合法约束。不需要写长篇大论,是看一眼就能恢复上下文的提示。这比把信息写在某个需求文档里可靠得多,因为代码是唯一确定会被维护的文档。
写在最后
这次重构最深的体会是:字段堆砌看起来是代码习惯问题,本质是业务规则没有建模。类型建模不是炫技,而是逼着自己把几个问题全部回答一遍:价格到底有几种形态?哪些组合是合法的?哪些状态绝对不该出现?回答完之后,代码自然就清楚了。
改完 PricingDetails 之后,团队开新计费需求会的方式都变了。大家直接在白板上画新 scenario 加进联合类型,而不是再翻着旧字段问“这个字段要填吗”。我后来又把这套思路用到订单状态、结算单等其他模型上,效果都差不多:前期建模确实痛苦,后面改起来是真的稳。
如果你现在也有一个越改越乱的字段堆砌类,建议先别急着加那第十八个字段。花一两个迭代做一次类型建模,把非法状态从代码里赶出去,这笔投入大概率是值得的。