回航 II:带回 TypeScript 与 Python
第二程回航,去前端和脚本的水域卸货。TS 这边几乎是顺风船——它的类型系统本来就长着结构化的脸,判别联合、never、字面量类型,上游思想的泊位一应俱全,连 React 生态的最佳实践都在悄悄向 Elm 靠拢。Python 这边要多一分分寸:类型是「提示」不是「法律」,落地策略也随之不同。仍然是清单式:每条标注成本,从零依赖到要开会的排序。
TS:判别联合是亲儿子
① ADT + 穷尽检查。判别联合(discriminated union)是 TS 对和类型的原生支持,穷尽检查靠 never 收尾——这套惯用法值得全文抄写进团队规范:
type Screen =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'loaded'; items: Article[] }
| { kind: 'failed'; error: string };
function assertNever(x: never): never { throw new Error(`漏了分支: ${x}`); }
function render(s: Screen) {
switch (s.kind) {
case 'idle': return null;
case 'loading': return spinner();
case 'loaded': return list(s.items); // 分支内 s 自动收窄,items 可用
case 'failed': return alert(s.error);
default: return assertNever(s); // 新增 kind 忘了处理?这行编译报错
}
}
React Query 用户看到 Screen 的定义应该心头一震:你项目里的 isLoading + data + error 三件套,正是第 4 章测量台里的布尔堆反例——RQ v5 文档自己都开始推 status 判别了。字面量 kind 就是构造子标签,收窄(narrowing)就是模式匹配的绑定,第 4 章的全部推理原样适用,包括「加一个 kind,assertNever 列出所有要改的地方」的重构工作流。顺手补上 branded type,TS 世界的 newtype:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
// cancel(orderId, userId) 传反?编译不过。零运行时开销,纯类型层的钉子。
② Result 之路,两个档位。轻档 neverthrow:就一个 Result<T, E> 加 map/andThen(即 fmap/>>=),半小时上手,错误位随你放 ADT;重档 Effect:TS 生态的完整效应系统(fp-ts 团队已并入),错误、依赖注入、并发、重试全部类型化——相当于把第 11、12、15 章整船搬进 TS,表达力惊人,但学习曲线和「传染性」也惊人。选型建议:业务代码从 neverthrow 起步;Effect 适合基建层且团队买账时上,别为一个表单校验开航母。
③ React 本来就是上游思想的殖民地:「UI 是状态的纯函数」(第 11 章)、useReducer 的 (state, action) => state(第 12 章 State)、不可变更新(第 7 章,immer 帮你写)、纯组件与副作用隔离在 useEffect(国境线的前端版)。你的增量自觉和 JVM 侧一致:reducer 保持纯、状态用判别联合建模、派生数据用 useMemo 而不是冗余 state(「一个事实只存一份」是不可变世界的老规矩)。测试侧 fast-check 补完:fc.property(fc.array(fc.integer()), xs => ...),收缩、种子复现全套齐活。
Zod:parse, don't validate 的 TS 完全体
第 4 章的智能构造子思想,在 TS 生态里长成了一个明星库:Zod(以及同思路的 Valibot/ArkType)。它的妙处是把「运行时校验」和「静态类型」焊成一体——schema 写一遍,校验器和类型定义同时到手:
const User = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).max(130),
});
type User = z.infer<typeof User>; // 类型从 schema 推导,永不失同步
const result = User.safeParse(await res.json()); // 边界上 parse 一次
if (!result.success) return handle(result.error); // 错误结构化,含路径
const user = result.data; // 从这行起,类型可信
注意函数名就叫 parse,不叫 validate——API 边界(HTTP 响应、localStorage、表单)进来的 unknown,经过一次 parse 变成担保过的 User,下游所有代码免检。这正是「校验一次、类型作保」的完整实现,而且比 Haskell 版多解决了一个 TS 特有的痛:网络数据在运行时可以是任何鬼样子,编译期类型对它无能为力——Zod 把国境线设在了运行时数据进入类型世界的关口。如果你的项目还在手写 as User 断言接口返回值,这是本章投入产出比最高的一处升级。
Python:分寸感优先
Python 3.10+ 其实凑齐了一套「够用的上游三件套」——积类型、和类型、模式匹配,一个不缺:
# ① 积类型:冻结 dataclass = 不可变记录
@dataclass(frozen=True)
class Paid:
items: tuple[Item, ...]
tx_id: str
@dataclass(frozen=True)
class Cart:
items: tuple[Item, ...]
Order = Paid | Cart # ② 和类型:union 写法
def refundable(o: Order) -> bool: # ③ 模式匹配:3.10 的 match
match o:
case Paid(): return True
case Cart(): return False
诚实注解:match 没有强制穷尽检查(mypy/pyright 配 assert_never 可以补上,姿势同 TS);frozen 挡的是意外赋值不是深层可变(字段里的 list 照样能 append,所以示例用 tuple);这一切只在你跑类型检查器的前提下成立——所以 Python 侧的第一优先级不是引什么库,而是在 CI 里把 mypy(或 pyright)跑起来,严格度渐进调高。类型是团队合同,没有执法者的合同是装饰。
Zod 在 Python 侧的平行物是 Pydantic(FastAPI 的地基):BaseModel 声明一遍,运行时校验 + 类型注解 + 序列化三合一,「边界 parse 一次、内部全免检」的姿势完全一致——如果你的 Python 服务还在手写 request.json()["field"] 加 if 校验,这是同款升级。
其余货物:Hypothesis 是三大属性测试库里收缩器最聪明的一个(@given(lists(integers())),第 13 章直接平移),数据管道和解析代码用它性价比极高;生成器天生惰性——(f(x) for x in xs) 就是流,配 itertools 的 islice/takewhile 可以搭出第 7 章的按需管道,处理大文件时「生成器链 + 最后一步落地」正是整粮编程;returns 库提供 Maybe/Result,但在动态语言里这类容器的收益打折(没有编译器逼人处理),团队没有类型检查文化时,宁可用「异常 + 文档」的诚实,不要「Result + 随手 .unwrap()」的表演——第 8 章暗礁的 Python 版。
| 上游思想 | TypeScript | Python |
|---|---|---|
| ADT + 穷尽(4) | 判别联合 + assertNever | union + match + assert_never |
| newtype(4) | branded type | NewType(注解层) |
| 不可变(7) | readonly、as const、immer | frozen dataclass、tuple |
| Either/do(8、10) | neverthrow → Effect | returns(慎)/ 异常分层 |
| 纯核心脏外壳(11) | 纯 reducer + useEffect 边缘 | 纯模块 + cli/io 薄壳 |
| 惰性流(7) | 生成器、RxJS(重) | 生成器 + itertools |
| 属性测试(13) | fast-check | Hypothesis |
| 解析组合子(14) | parsimmon / 手搓 50 行 | parsy / 手搓 |
TS:它的类型系统是刻意不健全的(any 的传染、类型断言、结构兼容的边角),所以「类型说不碰就碰不了」的上游保证在这里是「大概率碰不了」——branded type 能被 as 硬闯,判别联合能被 any 污染。对策不是沮丧,是分层:核心模块开 strict 全家桶 + eslint 禁 any/断言,边缘宽松。Python:动态是它的文化根基,那些靠鸭子类型和 monkey patch 吃饭的库(以及同事)不会为你的类型洁癖改变。落地节奏:新代码带注解、CI 跑检查、旧代码不追杀——上游思想在 Python 的最大红利本来也不是类型,是纯函数 + 生成器 + Hypothesis 这三件不需要任何人配合的私人武器。
收尾前把一条支流的水文补全:Elm(2012,Haskell 语法的前端语言)→ Redux(2015,作者明说灵感来自 Elm 架构)→ React Hooks 的 useReducer(2019,Redux 模式内置化)→ 各家 MVI。今天任何一个写 dispatch(action) 的前端工程师,都在不知情地执行 Elm 架构;而 Elm 架构就是 State Monad + ADT 消息 + 纯 update 的教学版。这大概是「上游思想经由一门小语言征服整个下游生态」最完整的一次实例——也是本书标题的最佳注脚。
1. TS 实战:把项目里一个 isLoading/data/error 三件套 state 重构成判别联合 + assertNever。数一数删掉了几个「理论上不可能」的 if 分支。
2. 给一个 fetch 封装换上 neverthrow:错误类型至少区分「网络失败/HTTP 非 2xx/解析失败」三种(sealed 错误 ADT 的 TS 版)。调用点用 match 或 andThen 消费。
3. Python 实战:挑一个数据清洗脚本,把核心变换抽成纯函数(文件读写留在 main),然后给纯函数上三条 Hypothesis 性质。
4. 观察题:翻你们前端项目的 reducer,找出一处「顺手发了请求/写了 localStorage」的不纯 case——它破坏了哪些本章承诺的红利(回放?测试?)?改法是什么?