多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

TypeScript类型建模实战:用判别联合和品牌类型重构价格详情

TypeScript类型建模实战:用判别联合和品牌类型重构价格详情 我接手一个订单计费服务时第一个让我皱眉的类就是 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 填了 100discountAmt 却能填 300taxRate 和 taxAmt 同时存在可两者算出来根本对不上minPrice 是 100maxPrice 是 50。这些脏数据在数据库里能存在接口里能传直到下游账算完才发现不对劲然后一层层排查到底是哪个环节塞进来的。第二笔债是认知负担呈指数增长。字段越多组合越爆炸新同学看到这个类型的第一反应通常是“我该填哪些”。想搞清楚只能翻文档、问老员工而文档大概率已经过期老员工自己也要靠 grep 调用方才能确认。更别说同一个字段在不同渠道里含义还不一样比如 channelType 在 A 系统里是字符串在 B 系统里又变成另一个枚举。第三笔债是变更放大。新加一种价格形态比如“定金预授权价”或者“多币种到账价”需要在创建、校验、展示、对账四个地方各加一段 if。漏一个就是线上 bug而且经常是那种只在特定渠道、特定商品上才触发的偶发 bug复现成本极高。我见过不止一次为了支持一个新场景老逻辑里所有取值点位都要过一遍谁都不敢说自己改全了。1.3 动手重构的判断信号并不是所有字段多的类都需要立刻重构但出现下面几个信号时我建议直接排期字段数量已经超过 8 到 10 个但业务形态其实只有两三套代码里出现大量“仅当 xxx 字段存在时yyy 字段才有意义”的注释同一份数据到不同渠道时要删掉或填上不同的字段运行时校验逻辑散落在三个以上文件里每次加需求至少三个人都要动同一个类型。出现两个以上信号就应该动手。而且不用一上来就重构整个系统先从最痛的一个类开始。PricingDetails 就是当时最好的切入点因为它被十几个模块引用是名副其实的“公共瓶颈”。2. 重构前先盘点画清模型边界再动手2.1 第一步不是写代码而是画出字段组合矩阵我做的第一件事不是打开编辑器而是把线上调用方、历史接口文档、消息协议翻了个底朝天把 PricingDetails 在使用中出现的所有组合列成一张矩阵表。这张表是重构的地图没有它后面所有设计都是在瞎猜。业务场景必填字段可选字段绝对不应出现单品直购固定价basePrice, currencydiscountAmt, shipFeeminPrice, maxPrice, tiers展示用区间价minPrice, maxPrice, currencyregionCodebasePrice, taxAmt批发阶梯价tiers, currencychannelTypediscountAmt, taxAmt下单后汇总价basePrice, taxAmt, shipFeediscountAmt, promoAmtminPrice, 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 Recordstring, 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 加进联合类型而不是再翻着旧字段问“这个字段要填吗”。我后来又把这套思路用到订单状态、结算单等其他模型上效果都差不多前期建模确实痛苦后面改起来是真的稳。如果你现在也有一个越改越乱的字段堆砌类建议先别急着加那第十八个字段。花一两个迭代做一次类型建模把非法状态从代码里赶出去这笔投入大概率是值得的。
返回列表