相关:Rust.md、AI-Agent-Engineering.md - LoopX 长程 agent 的本地控制面、Software-Engineering.md - Strangler Fig。
- “TypeScript 心智模型”先建立 TS / JS / Node 的分工,理解类型擦除;
erasableSyntaxOnly约束源码语法,实际运行兼容性仍需验证。 - “类型基础”把 interface、readonly、泛型、Generator、
as const、判别联合、unknown、Promise 看作同一种动作:把协议或状态写进类型;初学者可用 fp-ts Eq 串起 import、泛型、函数返回类型与箭头函数;Generator 区分产出、结束返回与恢复输入,惰性迭代不等于端到端流式读取;Node 调度、return await与回调时序三节说明类型不表达运行时边界。 - “状态机建模”讲核心设计原则:让非法状态无法构造,而不是靠运行时
if拦截。 - “Effect Program 与语义内核”以 LoopX PR-1 为例,讲纵向迁移如何把 settlement / journal 语义收口到 TS。
- “Runtime 工程模式”沉淀幂等重试、fail closed、常驻 runtime 生命周期、共享解析缓存与性能基线;缓存内容有效性和共享对象的修改隔离需要分别保证。
- “迁移策略与验证”解释为什么纵向切片优于“先迁测试”、六层验证金字塔,以及并发测试如何控制交错、用 Proxy 做定点故障注入。
- “学习路径”和“源码阅读检查表”给动手顺序和读代码时的检查问题。
类型工具的组合读法:Pick 限制函数依赖,Partial<Pick<…>> 限制构造器可调项,Extract<Awaited<ReturnType<…>>, …> 从异步接口派生阶段输入;更新协议用判别联合区分 keep / clear / set。内置 Omit 不会分配到联合成员上(keyof 只留共同键),需要保留分支时改用分配式条件类型。静态类型、运行时校验与业务授权分别承担不同约束。
外部数据沿着 smart constructor → io-ts codec 理解:先执行校验,再把结果交给业务;同一份 codec 定义可以组合复杂校验并推导静态类型。
数组与集合操作还要区分值、位置与顺序:map 的 ordinal可以被推断为 number,但类型不证明它属于正确的数组快照;Map 的首次插入顺序可以决定分组展示顺序,不能随意换成普通对象。
连续箭头函数的读法见 contramap 与柯里化:区分函数类型里的箭头和创建函数的箭头,再按 contramap(f)(E) 的两次调用拆解。
IO 与 Monoid 接着区分“创建动作”和“执行动作”:函数也能作为被合并的值,组合后的动作仍可交给下一个组合器;time 的 chain / map 展示如何插入计时、日志并保留原返回值。
沿着 Functional Design 系列主线 继续:fastest 把耗时作为数据来组合策略,Tagless Final / MonadIO 再把 IO / Task 实现作为参数,复用同一计时流程;后续通过 类型驱动开发 拆实现,通过 判别联合 建模合法状态。
对象操作还要区分“值类型合法”“必填键齐全”与运行时属性语义:Record + satisfies先检查领域对象,动态写入还需正确处理 __proto__ 等键。跨语言排序也要明确比较的是 UTF-16 码元还是 Unicode 码点,不能仅按运算符外观迁移。
| 概念 | 作用 |
|---|---|
| JavaScript | 真正运行的语言 |
| TypeScript | 给 JavaScript 增加的静态类型检查层 |
| Node.js | 在服务器 / CLI 环境运行 JavaScript 的 runtime |
tsc |
TypeScript 类型检查器 / 编译器 |
package.json |
Node 项目的依赖、命令和 runtime 要求 |
tsconfig.json |
TypeScript 的类型检查规则 |
function add(a: number, b: number): number {
return a + b;
}运行时不存在 number 这些类型:tsc 先检查,Node 实际执行的是去掉类型后的 JavaScript。因此:
TypeScript 能检查代码内部的类型关系,但不能自动保证网络、JSON、磁盘文件中的数据符合类型。
这也是 TS runtime 的输入仍需要 requiredString()、asObject() 等运行时校验的原因。PR-1 使用 Node 22.6+ 的 type stripping 直接执行 .ts 文件,不额外生成 .js 构建产物。
{ "compilerOptions": { "erasableSyntaxOnly": true } }该选项限制不能仅靠擦除 TS 语法来运行的写法。例如 constructor(public store: Store) {} 中的参数属性还隐含创建字段与赋值;enum 通常也需要生成运行时代码,因此都会被拒绝。可改用显式字段与赋值、字符串联合等写法。
它把类型擦除的语法边界前移到编译期,但不保证目标 Node 版本能解析、加载并正确执行源码,仍需实际运行验证。参考:官方配置说明。
private constructor(after: string | null, offset: bigint, limit: number) 不是 TypeScript 6 的新特性:private 是 TypeScript 的构造器访问修饰符,至少从 TypeScript 2.0 就已支持;参数类型注解则会在编译后被擦除。它表达的是:类外不能 new,子类也不能继承这个类,实例必须由类内部或静态 factory 创建。
class AuthorityJournalScan {
private constructor(after: string | null, offset: bigint, limit: number) {}
}tsc 会把它变成 JavaScript 形态:
class AuthorityJournalScan {
constructor(after, offset, limit) {}
}因此,LoopX 用它把 prepare() 设为唯一构造入口:先校验 cursor / limit,再创建 AuthorityJournalScan,避免外部直接构造未验证对象。这是 controlled construction,不是 runtime 的硬私有;它和 JavaScript 的 #private 字段不是一回事。
| 写法 | 约束发生在哪里 | 编译 / 运行时含义 |
|---|---|---|
private constructor() |
TypeScript 类型检查 | 编译后只是普通 constructor();直接 new 的限制会消失 |
#field |
JavaScript runtime | 运行时仍保持私有,但不能写成 #constructor |
这也解释了 PR #4287 的 comment:最低版本 job 使用 Node.js 22.6.0 的内置 strip-types,它最初只做轻量的类型擦除,不是完整的 TypeScript 编译器;在 新扫描器的第 12 行 解析到 private constructor 时,Node 不能把这个 TS-only 修饰符转换掉,于是报 Unexpected identifier 'constructor'。tsc 通过不代表声明的最低 Node runtime 一定能加载源码。
兼容 Node 22.6 的直接修复是去掉 private,保留显式字段和赋值;但这会削弱“只能由 prepare() 构造”的静态门禁。若该不变量重要,应把校验放进普通构造器,或使用模块私有 token / factory 在运行时继续保护构造入口,并补一个最低版本的直接 import smoke test。Node 官方也明确区分了轻量 type stripping 与完整 TypeScript 支持:Node.js 22.6.0 release note、TypeScript runtime 文档。
TypeScript 对 LoopX 的主要价值,不是“代码更短”或“运行更快”,而是把状态机、协议、effect 顺序和失败分支变成编译器能够检查的结构。
PR-1 做的不是“把 Python 翻译成 TS”,而是一次纵向迁移:
- TypeScript 成为 Effect Program 与 settlement 规则的唯一语义所有者;
- Python 保留兼容入口和仍未迁移的副作用 callback;
- 引入一个可复用的常驻 TS runtime;
- Turn journal 的判断和真实原子写入已迁到 TS;
- 同一个 PR 删除对应 Python 解释器,避免长期维护两套规则。
export interface SettlementIdentityInput {
goal_id: string;
agent_id: string;
todo_id?: string | null;
turn_instance_id: string;
replan_obligation_id?: string | null;
}它表示合法输入必须有 goal_id、agent_id、turn_instance_id,而 todo_id / replan_obligation_id 可缺省或为 null。Python 的 dataclass 也能表达,但 TS 的优势是接口直接约束所有调用者、handler、测试和返回值——重命名字段时,tsc 会把所有受影响位置找出来。
TypeScript 是结构类型(structural typing):
const input = {
goal_id: "g1",
agent_id: "a1",
turn_instance_id: "t1",
};
settlementIdentity(input);只要对象结构满足接口即可,不必显式声明“它是某个类的实例”。
弱类型检测(weak type detection)不等于额外字段白名单。 下面的 Context 属性全部可选,属于弱类型;将一个有属性、却与它没有任何共同属性的对象赋给它,会触发错误:
interface Context { work_summary?: string; rationale?: string }
const unrelated = { agent_id: "伪造身份" };
const rejected: Context = unrelated; // 错误:没有共同属性
const raw = { work_summary: "已完成调查", agent_id: "伪造身份" };
const context: Context = raw; // 通过:存在共同属性,且属性类型兼容
Object.keys(context); // ["work_summary", "agent_id"]
const empty: Context = {}; // 通过:所有属性都可省略还要区分多余属性检查(excess property checking):直接把对象字面量赋给 Context,显式写出的未知字段会被检查;经变量传入时,结构类型仍可能容许额外字段。
const direct: Context = {
work_summary: "已完成调查",
agent_id: "伪造身份", // 错误:对象字面量含未知属性
};有共同属性只消除了“完全无交集”的错误,仍须满足属性类型等约束。类型注解既不复制对象,也不删除字段;context 仍引用 raw,序列化或展开它时,agent_id 仍可能被带出。需要严格字段边界时,应使用 协议解码校验 拒绝未知字段,或逐字段构造新对象。参考:TS 2.4 — Weak Type Detection、Excess Property Checks。
export interface EffectProgram {
steps: readonly EffectStep[];
execution_mode: string | null;
}readonly EffectStep[] 让 program.steps.push(newStep) 直接成为类型错误。这对状态机很重要:receipt、计划、phase prefix 应更接近不可变值,而不是任何函数都能原地修改的共享 list。
注意边界:readonly 只阻止 TypeScript 调用者直接修改数组,不会冻结运行时对象,也不会深度复制内部字段。如果 Store 把内部 receipt 数组直接返回给外部,调用方仍能通过嵌套对象改坏内部状态。做法是返回前做值隔离:
structuredClone(transaction.receipts)Conformance 测试会故意修改返回的 projection,再重新读取,验证内部状态没有变化——这是在验证 value isolation,不是普通类型检查。
共享数据可以按使用需求选择复制或冻结:
| 方式 | 运行时效果 | 成本与边界 |
|---|---|---|
| 每次深拷贝 | 调用者修改自己的副本 | 每次都要遍历、分配,增加 GC 压力 |
| 逐层冻结后共享 | 调用者共用不可修改的对象图 | 冻结也有遍历成本,但同一缓存值只需做一次;需要修改的调用者另行复制 |
Object.freeze() 只冻结当前这一层,不会沿引用自动递归:
const data = { items: [{ count: 1 }] };
Object.freeze(data);
data.items[0].count = 2; // 仍能修改:items 和内部对象都没有冻结对普通 JSON.parse() 产生的对象与数组,需要逐层冻结后再共享;基本类型无需处理。这个结论不能直接推广到任意 JS 对象,例如冻结 Map 本身仍不能阻止 .set()。参考:Object.freeze 与 deep freezing。缓存的磁盘有效性另见共享解析缓存。
export interface SettlementResult<Value = unknown> {
value: Value | null;
receipts: readonly SettlementReceipt[];
failure: SettlementFailure | null;
}SettlementResult<string>、SettlementResult<SettlementIdentity>、SettlementResult<JsonObject> 共享 receipt / failure 结构,但成功值不同。这比把所有返回值写成 dict[str, Any] 更容易理解,也让 IDE 知道 value 里究竟是什么。
注意:当前接口理论上允许 value 和 failure 同时存在。更强的表达见“状态机建模”。
泛型参数只建立“可赋值关系”,不自动建立“值相等关系”。S 可以被推断成联合类型,因此两个参数不一致也可能通过检查:
declare function pair<S extends string>(schema: S, returned: S): S;
const value = pair("create_v0", "archive_v0");
// S 可被推断为 "create_v0" | "archive_v0"如果第一个参数应决定合同,其他位置只能服从,可用 NoInfer<S>:
declare function pair<S extends string>(
schema: S,
returned: NoInfer<S>,
): S;
pair("create_v0", "archive_v0"); // 类型错误NoInfer<S> 仍要求参数满足 S,但不让该位置参与推断;它收紧的是推断来源,不是运行时校验。
品牌类型:给字符串附加用途身份。 普通别名仍都是 string,品牌则模拟名义类型,防止把 ProviderRevision 接到需要 OperationId 的位置:
type OperationId = string & { readonly __brand: "OperationId" };
type RequestDigest = string & { readonly __brand: "RequestDigest" };
type ProviderRevision = string & { readonly __brand: "ProviderRevision" };
declare const revision: ProviderRevision;
declare function recover(id: OperationId): void;
recover(revision); // 类型错误在 LoopX 中,OperationId 用于定位原操作,RequestDigest 用于核对请求意图,ProviderRevision 用于并发版本比较。品牌只防内部误接线:JSON 字符串仍须在解析边界验证后才能获得对应品牌,也不代表权限、存在性或租约有效。
来源:fp-ts
Eq与fromEquals、ReadonlyArray.getEq。下例使用用户提供的function声明形式,原库使用箭头函数声明;比较逻辑相同。
这段代码接收“元素怎样算相等”的规则,生成“数组怎样算相等”的规则。getEq 返回比较规则对象;真正比较两个数组,要调用返回对象的 .equals()。
import { Eq, fromEquals } from 'fp-ts/Eq'
export function getEq<A>(E: Eq<A>): Eq<ReadonlyArray<A>> {
return fromEquals(
(xs, ys) =>
xs.length === ys.length &&
xs.every((x, i) => E.equals(x, ys[i]))
)
}先认识 Eq<A>:它是“有一个 equals 函数,能比较两个 A”的对象类型。库中的核心定义是:
export interface Eq<A> {
readonly equals: (x: A, y: A) => boolean
}(x: A, y: A) => boolean 在这里是函数的类型:接收两个 A,返回真假。readonly equals 表示不能通过这个类型重新给 equals 属性赋值,不会自动冻结整个对象。
从外到内读函数声明:
| 语法 | 初学者读法 |
|---|---|
import { Eq, fromEquals } from 'fp-ts/Eq' |
从指定模块按名字导入;大括号列出导入项。Eq 是类型,fromEquals 是运行时函数 |
export function getEq |
声明名叫 getEq 的函数,并允许其他文件导入 |
<A> |
声明一个类型占位符 A,叫泛型;A 可代表 number、string 或对象类型,不是运行时参数 |
(E: Eq<A>) |
接收参数 E,其类型是“比较两个 A 的规则”。E 只是变量名,也可改名为 elementEq |
参数括号后的 : Eq<ReadonlyArray<A>> |
标注函数返回值类型:比较两个 A 数组的规则对象;不是 boolean |
ReadonlyArray<A> |
A 元素组成的只读数组,等价写法是 readonly A[];限制 TypeScript 中的写操作,不是运行时冻结 |
Eq<ReadonlyArray<A>> 末尾的 >> |
依次关闭 ReadonlyArray 和 Eq 两层类型参数,不是右移运算 |
当 A 是 number,签名就是“输入 Eq<number>,输出 Eq<ReadonlyArray<number>>”。Eq 只用于类型检查;若项目开启 verbatimModuleSyntax,应把导入拆成 import type { Eq } from 'fp-ts/Eq' 与 import { fromEquals } from 'fp-ts/Eq',见 import type。
fromEquals 把一个返回真假的比较函数包装成带 .equals() 的对象。实际实现先判断 x === y:相同值 / 同一对象引用直接返回 true,否则再调用传入的函数。它不是一执行就比较数组,而是在创建之后可复用的比较规则。
再看两层箭头函数:
| 表达式 | 含义 |
|---|---|
(xs, ys) => 表达式 |
定义比较两个数组的函数。箭头后没有 {} 时直接返回表达式;若使用 {},需要显式 return |
xs.length === ys.length |
两个数组长度严格相等 |
条件一 && 条件二 |
两个条件都成立;第一个为 false 时不执行第二个,叫短路 |
xs.every((x, i) => ...) |
遍历检查 xs 的元素;全部通过才返回 true,遇到 false 就停止 |
(x, i) |
x 是当前元素,i 是它在 xs 中的下标,从 0 开始 |
E.equals(x, ys[i]) |
按传入的元素规则,比较两数组相同位置的元素 |
例如 [1, 2] 与 [1, 3]:长度相同 → 下标 0 的 1 与 1 相等 → 下标 1 的 2 与 3 不等 → 返回 false。空数组与空数组相等;这里讨论常规稠密数组,JS 的 every 会跳过稀疏数组的空槽。
完整调用:
// ① 两个数字怎样算相等
const numberEq: Eq<number> = {
equals: (x, y) => x === y
}
// ② 把元素规则变成数组规则;从 numberEq 推断 A = number
const arrayEq = getEq(numberEq)
// 也可以显式写 getEq<number>(numberEq)
// ③ 现在才传入两个数组,得到 boolean
arrayEq.equals([1, 2], [1, 2]) // true
arrayEq.equals([1, 2], [2, 1]) // false:顺序不同
arrayEq.equals([1, 2], [1]) // false:长度不同数组结构的比较方式保持不变,元素的相等规则交给 E;例如对象可以按 id 比较,而不是默认深比较所有字段。这体现了函数式组合的一种做法:把规则作为普通参数传入,再由小规则构造更大结构的规则。
来源:fp-ts
Eq.contramap。承接上节 Eq;示例把原库单独声明的函数类型写回参数与返回值注解中。
f => E => 结果 可以理解为 f => (E => 结果):外层函数接收 f,返回一个接收 E 的内层函数。箭头函数可以返回任意值,函数本身也是一种值,所以可以连续写 =>。
import { Eq, fromEquals } from 'fp-ts/Eq'
export const contramap = <A, B>(f: (b: B) => A) =>
(E: Eq<A>): Eq<B> =>
fromEquals((x, y) => E.equals(f(x), f(y)))代码含泛型与类型注解,属于 TypeScript。const contramap = ... 把一个函数赋给变量;export 让其他文件可以导入它。这里的四个箭头需要分清:
| 位置 | 含义 |
|---|---|
f: (b: B) => A |
类型中的箭头:要求 f 是“接收 B、返回 A”的函数;没有在这里执行或创建 f |
<A, B>(f: ...) => ... |
外层箭头函数:接收转换函数 f,返回内层函数 |
(E: Eq<A>): Eq<B> => ... |
内层箭头函数:接收 A 的比较规则,返回 B 的比较规则;冒号后的 Eq 是返回类型 |
(x, y) => E.equals(f(x), f(y)) |
传给 fromEquals 的比较函数:x、y 是 B,先转成 A,再按 E 比较 |
把运行时的箭头函数全部展开成普通函数,逻辑相同:
export function contramap<A, B>(f: (b: B) => A) {
return function (E: Eq<A>): Eq<B> {
return fromEquals(function (x: B, y: B): boolean {
return E.equals(f(x), f(y))
})
}
}外层 return 返回函数,内层 return 返回 Eq 对象,最里面的 return 才返回 boolean。f 与 E 被内层函数通过闭包保留,所以外层调用结束后,后续比较仍能使用它们;闭包保留的是变量绑定,不会自动深拷贝对象。
一个完整例子:用户对象按 id 判断是否相等,忽略 name。
type User = { id: number; name: string }
const numberEq: Eq<number> = {
equals: (x, y) => x === y
}
const getId = (user: User): number => user.id
// ① 先给 f;A = number,B = User,返回一个等待 Eq<number> 的函数
const withId = contramap(getId)
// ② 再给 E,得到 Eq<User>
const userEq = withId(numberEq)
// 上面两步也可合写:const userEq = contramap(getId)(numberEq)
// ③ 调用 equals,才真正比较两个用户
userEq.equals({ id: 1, name: '甲' }, { id: 1, name: '乙' }) // true
userEq.equals({ id: 1, name: '甲' }, { id: 2, name: '甲' }) // false调用分为三层:contramap(f) 返回函数 → 再传 (E) 返回比较器 → .equals(x, y) 返回真假。定义 contramap 或调用前两层都不会立即执行 f(x);比较时才执行投影,且 fromEquals 对同一引用会直接短路为 true。
这种“参数分两次接收”的写法是柯里化形式(currying);const withId = contramap(getId) 先固定一部分参数,称为部分应用(partial application)。因此调用写成 contramap(f)(E),不是 contramap(f, E);后者是另一种函数签名。这里同时接收、返回函数,也属于高阶函数。
名字的直觉:f 把 B 转成 A,而 contramap 用“比较 A 的能力”构造“比较 B 的能力”,方向相反。具体到例子就是“用户 → id”,配合“比较两个 id”,得到“比较两个用户”。上节 getEq 扩展到数组结构,本节 contramap 则通过预处理输入复用已有规则。
来源:gcanti,Functional design: combinators — Example 2。概念与结合律 / 单位元见 函数式编程笔记。
Monoid<A> 包含 concat: (x: A, y: A) => A 和 empty: A;IO<A> 则是 () => A。下面把结果的合并规则变成动作的合并规则,并据此重复执行动作:
import type { IO } from 'fp-ts/IO'
import type { Monoid } from 'fp-ts/Monoid'
import { concatAll } from 'fp-ts/Monoid'
import { replicate } from 'fp-ts/ReadonlyArray'
function getMonoid<A>(M: Monoid<A>): Monoid<IO<A>> {
return {
concat: (x, y) => () => M.concat(x(), y()),
empty: () => M.empty
}
}
const monoidVoid: Monoid<void> = {
concat: () => undefined,
empty: undefined
}
function replicateIO(n: number, mv: IO<void>): IO<void> {
return concatAll(getMonoid(monoidVoid))(replicate(n, mv))
}
const sayHello: IO<void> = () => console.log('你好')
const program = replicateIO(3, sayHello) // 只创建组合动作,没有打印
program() // 顺序打印三次“你好”读法只抓三处:
| 代码 | 执行含义 |
|---|---|
(x, y) => () => M.concat(x(), y()) |
外层接收两个动作,返回无参新动作;调用新动作时,才依次执行 x、y 并合并返回值 |
empty: () => M.empty |
空动作只返回结果类型的单位元;Monoid<void> 对应不做事、返回 undefined |
concatAll(M)(replicate(n, mv)) |
replicate 构造 n 个 mv 函数引用;concatAll 从 M.empty 开始依次合并,得到一个动作;n=0 时得到空动作 |
M.concat 忽略结果,不等于 x、y 没有执行。 JavaScript 调用 M.concat(x(), y()) 时先求实参:执行 x()、执行 y(),最后才调用 M.concat。IO<void> 只是没有有用的返回值,仍可产生打印等副作用。
replicateIO(3, printFib)() 的前一对括号创建组合动作,后一对括号运行它,效果近似 printFib(); printFib(); printFib()。文章的 printFib 每次都会重新取随机数、计算 Fibonacci、打印;重复的是函数调用,不是复用某一次的计算结果。组合结果仍是 IO,所以可继续包成 time(replicateIO(3, printFib))()。
来源:Functional design: combinators — Example 3;实现核对:fp-ts
IO.Monad、flatMap / chain、now、log。
time 接收动作 ma: IO<A>,返回带计时的新动作 IO<A>:执行顺序是“开始时间 → 原动作 → 结束时间 → 打印耗时 → 返回原结果”。A 可以是任意结果类型,计时没有把它改成耗时或 void。
import type { IO } from 'fp-ts/IO'
import { Monad } from 'fp-ts/IO'
import { now } from 'fp-ts/Date'
import { log } from 'fp-ts/Console'
export function time<A>(ma: IO<A>): IO<A> {
return Monad.chain(now, (start) =>
Monad.chain(ma, (a) =>
Monad.chain(now, (end) =>
Monad.map(log(`Elapsed: ${end - start}`), () => a)
)
)
)
}这里 IO 是类型,Monad 是 IO 模块提供的操作对象。Monoid 规定同类值如何合并;这里的 Monad.chain 则让后续动作依赖前一步的结果。
| 代码 | 读法 |
|---|---|
now |
IO<number>,即获取当前毫秒时间戳的无参函数;传函数本身,由 chain 安排调用 |
Monad.chain(ma, f) |
返回新 IO;运行时先执行 ma() 得到值,再调用 f 得到下一动作并执行。此处可展开为 () => f(ma())() |
Monad.map(ma, f) |
返回新 IO;运行时执行 ma(),再用 f 转换其结果。此处可展开为 () => f(ma()) |
log(message) |
创建 IO<void>,调用该动作才打印;构造日志动作本身不打印 |
() => a |
忽略日志返回的 undefined,返回此前算出的 a;不会重新执行原动作 |
区别在 f 的返回值:chain 的 f 返回 IO 动作,map 的 f 返回 普通值。若用 map 接收返回 IO 的 f,会得到嵌套的 IO<IO<B>>,不会自动执行里面的动作。这里 Monad.chain(ma, f) 是两参数调用;文章另一处单独导入的 chain(f)(ma) 是柯里化接口,语义相同。
把嵌套代码展开成普通函数,执行行为就直观了:
export function time<A>(ma: IO<A>): IO<A> {
return () => {
const start = now()
const a = ma()
const end = now()
log(`Elapsed: ${end - start}`)()
return a
}
}
const measured = time(() => 42) // 只创建动作,没有读取时间或打印
const result = measured() // 打印耗时,result 仍为 42嵌套回调让 start、a、end 都能被最后一层访问;反引号中的 ${end - start} 将毫秒差值插入字符串。结束时间在打印之前取得,所以不包含最后的日志耗时。此例针对同步 IO;若 ma 抛错,后面的取时间和打印不会执行;若返回 Promise,也不会自动等待其完成。
保留 IO<A> 接口,才能继续组合:time(replicateIO(3, printFib))() 测三次执行的总耗时,replicateIO(3, time(printFib))() 则分别记录三次耗时。
来源:Functional design: how to make the time combinator more general;源码:Ord.contramap、getMeetSemigroup / min、IO.getSemigroup。
这里的 time 已升级为 IO<A> → IO<[A, number]>,把结果与耗时一起返回,供调用者选择用途。前节的 time 只打印耗时并返回 A,不能直接接到这段代码;下文将新版命名为 timeWithElapsed,补齐 ignoreSnd,并把原文同名局部变量 fastest 改为 fastestRun。
第二篇拆开了“测量”与“怎样使用测量结果”:withLogging 在计时之后打印结果和耗时,再返回 A;ignoreSnd 只保留 A;fastest 按耗时选择 A。这三种策略复用同一个计时动作,无需修改测量逻辑。
import type { IO } from 'fp-ts/IO'
import { getSemigroup, Monad } from 'fp-ts/IO'
import { fold, getMeetSemigroup } from 'fp-ts/Semigroup'
import { contramap, ordNumber } from 'fp-ts/Ord'
import { now } from 'fp-ts/Date'
function timeWithElapsed<A>(ma: IO<A>): IO<[A, number]> {
return () => {
const start = now()
const a = ma()
return [a, now() - start]
}
}
function ignoreSnd<A>(ma: IO<[A, unknown]>): IO<A> {
return Monad.map(ma, ([a]) => a)
}
export function fastest<A>(head: IO<A>, tail: Array<IO<A>>): IO<A> {
const ordTuple = contramap(([_, elapsed]: [A, number]) => elapsed)(ordNumber)
const semigroupTuple = getMeetSemigroup(ordTuple)
const semigroupIO = getSemigroup(semigroupTuple)
const fastestRun = fold(semigroupIO)(timeWithElapsed(head), tail.map(timeWithElapsed))
return ignoreSnd(fastestRun)
}逐行读成一条“排序规则 → 选择规则 → 动作组合”的链:
| 代码 / 类型 | 含义 |
|---|---|
head: IO<A>, tail: Array<IO<A>> |
head 保证至少一个动作,tail 可以为空。Semigroup 只有满足结合律的 concat,不要求 empty,所以由 head 提供归约初值 |
([_, elapsed]: [A, number]) => elapsed |
解构二元组 [结果, 耗时],取第二项。_ 是未使用的普通变量名,不是特殊运算符 |
ordTuple: Ord<[A, number]> |
Ord.contramap 将数字排序规则 ordNumber 搬到二元组上,只按耗时比较,不比较 A 的值;与 Eq.contramap 同样是先提取字段再应用规则 |
semigroupTuple: Semigroup<[A, number]> |
getMeetSemigroup 把“能排序”变成“二选一”:concat 返回耗时较小的整个二元组;耗时相等时保留左边 |
semigroupIO: Semigroup<IO<[A, number]>> |
把二元组选择规则提升为动作组合:新动作运行时先执行左动作,再执行右动作,最后从两个结果中选较小者 |
fold(semigroupIO)(timeWithElapsed(head), tail.map(timeWithElapsed)) |
tail.map 只给每个动作包计时,不执行;fold 从带计时的 head 开始,依次合并 tail,构造一个 IO<[A, number]> |
ignoreSnd(fastestRun) |
snd 指 second;运行组合动作后只取 [a],丢弃耗时,返回 IO<A>;不会再跑一次胜出的原动作 |
用普通循环展开,行为更直观:
function fastestPlain<A>(head: IO<A>, tail: Array<IO<A>>): IO<A> {
return () => {
let best = timeWithElapsed(head)()
for (const action of tail) {
const candidate = timeWithElapsed(action)()
if (candidate[1] < best[1]) best = candidate
}
return best[0]
}
}fastest(head, tail) 只创建动作,末尾再加 () 才执行。假设三次测得 ['甲', 12]、['乙', 5]、['丙', 9],最终返回“乙”。所有候选都按顺序执行过,落败者的副作用也已发生;整体等待时间包含所有候选,fastest 指本轮观测耗时最短的返回值,不是并发竞速或自动选择以后只跑哪一个。普通 IO 遇到异常会中断,不会自动跳过失败候选。
代码保留文章的 fp-ts v2 命名,其中 fold、getMeetSemigroup、ordNumber、IO.getSemigroup 在所读源码中已标为 deprecated。设计上值得保留的是:先把耗时从固定日志变成可组合的数据,再复用 Ord / Semigroup 完成选择;Monoid 与 Semigroup 的关系见 函数式编程笔记。
来源:Functional design: tagless final;源码核对:Kind、IO.MonadIO、Task.MonadIO。概念见 组合式设计。
同步 IO<A> = () => A 与异步 Task<A> = () => Promise<A> 都需要“开始 → 动作 → 结束”。把具体的 IO.Monad 换成参数 ops,计时流程就可以复用;但 fp-ts/Date 导出的动作 D.now 是 IO,还需要 fromIO 将它提升到目标效果。原文用 M 同时命名类型参数和运行时对象;这里分别改成 F、ops。
“把效果实现作为参数”具体就是:把一组规定如何组合这类计算的函数传进去。类似 sort(compare) 接收比较规则,time(ops) 接收动作组合规则。这里 F 表示计算形式,ops 是普通 JavaScript 对象,ma 才是具体业务动作:
| 传入的 ops | ops.chain(ma, a => next(a)) 在组合动作运行时的含义 |
|---|---|
I.MonadIO |
调用同步动作 ma 得到 a,再构造并运行下一动作 |
T.MonadIO |
调用异步动作 ma,等待 Promise 完成得到 a,再构造并运行下一动作 |
所以 time 只写“读开始时间 → 跑业务动作 → 读结束时间”,顺序如何落实由 ops.chain 的实现负责。time(I.MonadIO) 与 time(T.MonadIO) 复用相同流程,分别生成接收 IO、Task 的计时函数;业务动作通过后续的 (ma) 传入。F 是编译期的类型参数,真正运行时传入的是带 map / chain / fromIO 等函数的 ops 对象。
import type { Kind, URIS } from 'fp-ts/HKT'
import type { Monad1 } from 'fp-ts/Monad'
import * as I from 'fp-ts/IO'
import * as T from 'fp-ts/Task'
import * as D from 'fp-ts/Date'
interface MonadIO<F extends URIS> extends Monad1<F> {
readonly fromIO: <A>(fa: I.IO<A>) => Kind<F, A>
}
function time<F extends URIS>(
ops: MonadIO<F>
): <A>(ma: Kind<F, A>) => Kind<F, [A, number]> {
const now = ops.fromIO(D.now)
return ma => ops.chain(now, start =>
ops.chain(ma, a => ops.map(now, end => [a, end - start]))
)
}
const monadIOIO: MonadIO<I.URI> = {
...I.Monad,
fromIO: fa => fa // identity:返回动作函数本身
}
const monadIOTask: MonadIO<T.URI> = {
...T.Monad,
fromIO: T.fromIO
}
const timeIO = time(monadIOIO)
const timeTask = time(monadIOTask)
timeIO(() => 42)() // [42, 耗时毫秒]
timeTask(() => Promise.resolve(42))() // Promise<[number, number]>Writing a MonadIO instance 就是补齐一份符合接口的操作字典,不需要 class 或 new:
| 代码 / 术语 | 读法 |
|---|---|
F extends URIS、Kind<F, A> |
F 标识一种类型构造器;Kind<'IO', A> 是 IO<A>,Kind<'Task', A> 是 Task<A>。TypeScript 没有原生 HKT,fp-ts 通过类型映射编码 |
Monad1<F> |
已有的 map / of / ap / chain 接口;MonadIO 在它之上加 fromIO。Monoid 负责合并同类值,Monad 负责组合带效果的计算,两者不同 |
...I.Monad / ...T.Monad |
对象展开,复制已有的 URI 与 Monad 操作,再补 fromIO;原文使用旧聚合对象 ...io / ...task,原理相同 |
fromIO: fa => fa |
IO → IO 无需转换,返回同一个函数;写成 fa() 才会执行,并错误地返回 A。也不能用 of 替代:of(fa) 把函数当普通值,会多包一层 |
fromIO: T.fromIO |
复用库函数,将 IO 包成 Task;实现 是 fa => () => Promise.resolve().then(fa),运行 Task 后才安排执行 fa,不创建新线程 |
I.URI 和 T.URI 分别是 'IO'、'Task' 的类型标识,原文两段代码中导入的同名 URI 来自不同模块。上述 instance 也已有库内实现,可直接写 time(I.MonadIO)、time(T.MonadIO)。
调用分三层:time(instance) 选实现 → (action) 构造计时动作 → () 执行并得到值或 Promise。const now 保存的是可重复执行的动作,不是时间值;两处使用分别取开始、结束时间。Task.chain 等原动作的 Promise 完成后才执行结束计时;普通 IO 不会自动等待 Promise。示例只记录成功完成的动作,抛错 / reject 时不会自动生成耗时结果。
生成器函数 function* distinctTurns(...) 的返回类型 Generator<Run, void, unknown> 描述三个通道:
| 参数 | 含义 | 本例 |
|---|---|---|
Y |
每次 yield 交给调用方的值 |
Run |
R |
生成器结束时的返回值 | void,不返回有用的终值 |
N |
调用方通过 .next(value) 传回、由 yield 表达式接收的值 |
unknown;本例未使用此输入通道,并非禁止传值 |
相较于先构造完整的去重 Run[],生成器让调用方按需取下一条:计数达到 2、6 或 20 次阈值即可停止,省去后续遍历与完整结果数组的构造。这里惰性发生在去重迭代层,历史输入的解码仍是有界批量处理,不能据此称整个系统已改成流式读取。
来源:TypeScript 3.6:Stricter Generators。
export const SETTLEMENT_STEP_KINDS = [
"validation",
"durable_writeback",
"quota_spend",
"terminal_closeout",
] as const;
export type SettlementStepKind =
(typeof SETTLEMENT_STEP_KINDS)[number];as const 后得到的不是普通 string,而是四个字面量的联合;const step: SettlementStepKind = "quota_spned" 会被编译器拒绝。对 LoopX 特别有价值,因为过去许多控制面 bug 的根源是:字符串拼错、新增状态后漏处理、某个模块使用旧枚举、不同模块对同一状态含义理解不同。
另一处常见写法(把“转换名/状态名”收窄成联合):
const TRANSITIONS = [
"initial",
"identity_reset",
"advance_after_interval",
] as const;
type Transition = typeof TRANSITIONS[number];关键读法:[number] 是索引访问类型(indexed access),对数组 / 元组类型做 [number],得到“任意下标位置的元素类型”——这里是三个字面量的联合。名字容易误导,它不是“第 number 个元素”,而是“元素们的类型”:
typeof TRANSITIONS -> readonly ["initial", "identity_reset", "advance_after_interval"]
typeof TRANSITIONS[number] -> "initial" | "identity_reset" | "advance_after_interval"
两个等价写法:(typeof TRANSITIONS)[number] 与 typeof TRANSITIONS[number],后者读起来像 typeof (TRANSITIONS[number]),但意义相同。
最容易踩的坑是去掉 as const:此时 typeof TRANSITIONS 是 string[],[number] 只能得到 string,字面量联合消失。收窄是 as const 做的,[number] 只是把它取出来——两者缺一不可。
如果是对象而不是数组,对应写法是 keyof typeof OBJ 生成键的联合:数组擅长表达“一组候选值”,对象擅长表达“一组键”,按场景选择。
这类联合类型是状态机 / 协议的基础素材:判别字段的候选值、合法的状态集合、白名单配置都可以从“单一来源数组”派生,新增一项时所有 switch / 判别收窄 / 校验点会被编译器推动着一起更新(见“判别联合与 never”)。
as const satisfies:既要精确,又要校验
如果联合类型已经定义好(比如 SettlementStepKind),又想从一个数组推导出精确成员,可以:
const BASE_SETTLEMENT_STEPS = [
"validation",
"durable_writeback",
"quota_spend",
] as const satisfies readonly SettlementStepKind[];两个关键字分工不同:
as const保留精确字面量和 tuple 顺序;satisfies检查每个成员都符合SettlementStepKind,但不会把精确类型扩宽成普通数组。
误写 "quota_spned" 编译器直接报错;同时后续代码仍知道数组里是三个具体 step,而不是“某些字符串”。
对比单纯类型注解:
const steps: readonly SettlementStepKind[] = [...]后者合法,但会把数组收窄声明成 readonly SettlementStepKind[],丢失精确 tuple 信息。区别在于:类型注解是「要求值符合声明类型」,而 satisfies 是「校验表达式是否符合目标类型,同时保留表达式自身的类型」。
不过这句话要说得更精确一点:satisfies 保留表达式自身的类型,但目标类型仍会作为上下文类型参与推断,于是结果不一定等于「完全没有目标类型时的推断」。两个可验证的后果:
const a = { allowed: true };
a.allowed = false; // 可以:a.allowed 推断为 boolean
const b = { allowed: true } satisfies { allowed: boolean };
b.allowed = false; // 报错:这里保留了字面量类型 true原因是 boolean 本身就是 true | false,当上下文期望的类型是字面量类型(或字面量类型的联合)时,字面量不会被扩宽——satisfies 并不等于「绕开目标类型」。
interface Observation {
hash: string;
proof?: { version: number };
}
const a = { hash: "abc" } satisfies Observation;
a.proof = { version: 3 }; // 报错:a 的类型里没有这个属性
const b: Observation = { hash: "abc" };
b.proof = { version: 3 }; // 可以satisfies 只检查、不补全:目标类型里「对象字面量没有写出来的可选属性」不会进入推断结果。后续需要写入这些字段时,就用明确的类型注解,或者在字面量里把字段写出来。
同样地,satisfies 是静态检查,不会在运行时裁掉字段:
type Edit = { note?: string };
const source = { note: "reviewed", actor: "B" };
const edit = { ...source } satisfies Edit;
console.log(edit.actor); // "B",字段仍然存在对象字面量里显式写出的多余属性可能被 excess property check 拦下;而通过展开(...source)带进来的属性会原样保留。类型只表达设计意图,运行时白名单得由输入边界自己执行——比如需要「只传指定字段」时,就得显式构造新对象,而不是指望 satisfies 或 Pick 帮忙裁剪。
先区分运行时对象与编译器知道的类型。 Object.fromEntries 是 JavaScript 函数,把一组 [键, 值] 转成对象;键就是对象的属性名:
const counts = Object.fromEntries([
["open_items", 2],
["done_items", 1],
]);
// 实际对象:{ open_items: 2, done_items: 1 }
// 标准库推断的类型:{ [k: string]: number }这里没有把实际对象的键删掉;丢失的是返回类型里“必定包含 open_items、done_items”的保证。标准库通用重载的返回类型为 { [k: string]: T },T 表示值的类型;[k: string] 是字符串索引签名,约束字符串键对应的值类型,不列出必须存在的属性。例如空对象 {} 也能赋给 { [k: string]: number },不能据此断言任意键真的存在。开启 noUncheckedIndexedAccess 后,经这种索引签名读取的值还会带 undefined。
Record<键集合, 值类型> 可以把必填栏目列成类型。 假设待办摘要必须包含“待办”和“已完成”两个栏目,lane 只是业务里的“分组名称”,不是 TS 关键字:
type TodoSummaryLane = "open_items" | "done_items";
type Row = { ordinal: number; text: string };
type Selected = Record<TodoSummaryLane, readonly Row[]>;
// 相当于:
type SelectedExpanded = {
open_items: readonly Row[];
done_items: readonly Row[];
};| 表示“或”,一个 TodoSummaryLane 值只能是这两个字符串之一;Record 则要求联合中的每一个键都存在。Row 定义一条记录的形状,Row[] 是记录数组,readonly Row[] 是只读数组类型;上面的属性没有 ?,所以不能省略。
对照看,Record<string, number> 是没有指定必填键的字典,Record<TodoSummaryLane, number> 则必须有这两个键。同样,TodoSummaryLane[] 只检查数组中每个名称是否合法,["open_items"] 仍合法,不保证覆盖全部栏目。
satisfies 要求编译器在对象构造处对照这份合同。
const openRows: readonly Row[] = [{ ordinal: 0, text: "写笔记" }];
const doneRows: readonly Row[] = [{ ordinal: 1, text: "读材料" }];
const selected = {
open_items: openRows,
done_items: doneRows,
} satisfies Record<TodoSummaryLane, readonly Row[]>;
const missing = {
open_items: openRows,
} satisfies Record<TodoSummaryLane, readonly Row[]>;
// 编译错误:缺少必填的 done_items可以把 satisfies 读作“请检查左边是否符合右边的类型”。此处能检查漏键、值类型错误,以及直接写出的属性名拼错;它在编译时工作,不会运行一遍检查函数,不会自动补齐字段。它也不是要求精确删除额外字段,展开对象等边界见前面的 as const satisfies 小节。
若将 TodoSummaryLane 扩展为 "open_items" | "done_items" | "blocked_items",原来的 selected 就会因缺少 blocked_items 报错,推动摘要构造逻辑一起更新。该联合应来自业务合同;若仅从 selected 反推键集合,就无法发现它相对业务要求漏了什么。
这件事也能用普通类型注解完成:const selected: Selected = { ... }。satisfies 的作用是检查可赋值关系并保留表达式自身的类型信息(目标类型仍会参与上下文推断),不必把它视为唯一正确写法。as Selected 则是类型断言,不能用来证明字段真的齐全。
完整性检查放在动态转换之前,因为这时编译器还看得见具体键。 例如传输层只需要每个栏目的原始 ordinal:
const lanes = Object.fromEntries(
Object.entries(selected).map(([lane, rows]) =>
[lane, rows.map(row => row.ordinal)] as const,
),
);
// 实际对象:{ open_items: [0], done_items: [1] }
// 标准库返回类型:{ [k: string]: number[] }
const json = JSON.stringify({ lanes });Object.entries 将对象拆成键值对;map 保留栏目名,将记录数组换成 ordinal 数组;as const 在这里保留每一对的 tuple 结构;Object.fromEntries 再组装对象。真正生成 JSON 文本的是 JSON.stringify,fromEntries 本身不负责序列化。
如果只在这个宽返回类型后面加 satisfies Selected,并不能找回原来的键信息,而且上例的值也已从 Row[] 转成 number[]。即使检查目标改成 Record<TodoSummaryLane, number[]>,该通用返回类型仍未证明必填键存在。原始 selected 已经通过检查,也不意味着后续转换可以任意过滤或漏掉栏目;转换过程仍需遵守一一映射的合同。
三层检查各管一件事:构造时用 Record 检查栏目齐全;转换时保持键和值的映射;接收外部 JSON 时做运行时校验。 TS 类型不会随 JSON 传到另一种语言,接收端仍要检查键集合、数组形状,以及 ordinal 是否属于原快照和选中集合,见 map + filter:位置属于来源快照。
参考:TypeScript — Record、TS 4.9 — satisfies、TS 5.9.3 标准库 fromEntries 声明。此处讨论通用重载;标准库另有返回 any 的宽重载,同样不能提供键完整性保证。
Record 不改变动态属性写入的语义。 Record<string, number> 只是静态类型,{} 仍是普通 JavaScript 对象,不会自动变成“任何键都只作为数据”的字典:
const sources: Record<string, number> = {};
const key = "__proto__";
const ordinal = 7;
sources[key] = ordinal;
Object.hasOwn(sources, key); // 默认对象原型下为 false:没有创建自身属性
Object.defineProperty(sources, key, {
value: ordinal,
enumerable: true,
writable: true,
configurable: true,
});
Object.hasOwn(sources, key); // true
JSON.stringify(sources); // '{"__proto__":7}'普通赋值可能调用继承的 __proto__ setter;此例的数字值会被忽略,而不是保存为自身属性,也不是修改了原型。Object.defineProperty 则直接定义自身数据属性,不调用该继承 setter;enumerable: true 让键参与枚举和 JSON 序列化,另外两项允许改值与重新配置。创建属性时这三个标志默认都是 false,需要按合同显式设置。
因此,类型检查通过不代表动态写入符合协议;对来源表、计数表等开放键集合,须明确采用自身数据属性、无原型对象或 Map,并分别检查序列化与消费端的行为。参考:ECMAScript — Object.defineProperty、MDN — __proto__。
来源补充:Functional design: Algebraic Data Types;积 / 和的状态数与 Option / Either / fold 见 函数式编程笔记。
先把有依赖关系的字段放进同一分支。例如 { editable: boolean; onChange?: (text: string) => void } 允许可编辑却没有回调;可以改为:
type Props =
| { type: 'READONLY' }
| { type: 'EDITABLE'; onChange: (text: string) => void }判断 props.type === 'EDITABLE' 后,onChange 必然存在。这里的改进来自模型已表达字段关联,再由编译器跟踪收窄。
|:联合类型(union type)
| 读作「或」。type X = A | B 表示 X 可以取 A 或 B 中的一种形态。下面 SettlementNextAction 是三个对象形状的联合:一个结算决策要么是 failed、要么是 execute、要么是 complete。
关键点:
- 联合里的每个分支叫一个 member / variant;
- 变量是联合类型时,必须先确定它落在哪个分支(用判别字段判断、
switch、typeof/in收窄),TS 才允许访问该分支独有的字段; - 没有收窄前,只能访问所有分支共有的字段。
type Result =
| { ok: true; value: string }
| { ok: false; error: string };
function show(r: Result) {
if (r.ok) {
r.value; // 已收窄到 { ok: true; ... }
} else {
r.error; // 已收窄到 { ok: false; ... }
}
}const 解构后仍可关联收窄(TS 4.6+)。 如果类型把 status 与 next_action 的合法组合写成判别联合,检查解构出的 status,也能收窄同时解构出的 next_action:
type ProgressResult =
| { status: "current" | "delivered"; next_action: "finish" }
| { status: "waiting"; next_action: "retry" };
function handle(result: ProgressResult) {
const { status, next_action } = result;
// 此时 next_action: "finish" | "retry"
if (status === "current" || status === "delivered") {
const action: "finish" = next_action; // 已收窄为 "finish"
}
}const { ... } = result 是对象解构;if 触发控制流分析;: "finish" 是字符串字面量类型注解,要求赋值只能是这个字符串。它不是 as 断言,也不会在运行时把值转换成 "finish";纯 JavaScript 没有这段类型注解。
关键是原类型已经表达了字段关联。如果把两个字段各自写成独立的联合(status: "current" | "delivered" | "waiting"、next_action: "finish" | "retry"),类型就允许任意组合,检查 status 不能推出 next_action。这里的能力适用于 const 解构,也支持函数体内从不重新赋值的解构参数,不能泛化为任意 let 解构。参考:TS 4.6 — Control Flow Analysis for Destructured Discriminated Unions。
| 也用于字面量联合("running" | "succeeded" | "no_change"),与对象联合是同一机制。
export type SettlementNextAction =
| {
decision: "failed";
step_kind: null;
result: SettlementResult<JsonObject>;
}
| {
decision: "execute";
step_kind: SettlementStepKind;
result: SettlementResult<JsonObject>;
}
| {
decision: "complete";
step_kind: null;
result: SettlementResult<JsonObject>;
};decision 是判别字段。判断 if (action.decision === "execute") 后,TS 自动知道 step_kind 不可能是 null。配合穷尽 switch:
switch (action.decision) {
case "execute":
return run(action.step_kind);
case "complete":
return finish();
case "failed":
return fail(action.result);
default: {
const unreachable: never = action;
return unreachable;
}
}以后新增 "paused" 但忘记更新这个 switch,never 会让 typecheck 失败。这就是 TS 对状态机真正有价值的地方:
新增一种状态时,编译器会指出所有没有同步理解这种状态的消费者。
| 的搭档是交叉类型 &:同时满足两种结构,适合给现有 payload 增加判别字段而不重复声明所有字段:
type StoreLoadResult =
| ({ status: "loaded" } & StoreHead) // loaded 分支同时拥有 status + head 的全部字段
| { status: "missing" }
| StoreReadFailure;注意:互相矛盾的交叉类型会坍缩成 never,例如 { status: "loaded" } & { status: "missing" }——类型层面能表达,但永远构造不出值。
type JsonObject = Record<string, unknown>;而不是 Record<string, any>:
any:编译器放弃检查;unknown:使用前必须证明它是什么。
function requiredString(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${label} must be a non-empty string`);
}
return value;
}输入是 unknown,经过 typeof value === "string" 后 TS 才把它收窄为 string。但 params as unknown as TurnJournalInspectionRequest 这类写法是迁移缝:它告诉编译器“相信我”,并不构成运行时验证,后续应逐步用 typed decoder 或显式 schema parser 替代,不能误以为“用了 TS 就自动安全”。
解码协议中的次数时,应依次检查数值类型、安全整数和业务范围。下面的 maxCount 是协议定义的可信上限:
function decodeCount(value: unknown, maxCount: number): number {
if (
typeof value === "number" &&
Number.isSafeInteger(value) &&
value >= 0 && value <= maxCount
) {
return value;
}
throw new TypeError("invalid count");
}typeof在运行时排除非数字,同时让编译器把unknown收窄为number;&&右侧和成功分支都能使用这个结论。Number.isSafeInteger排除小数、NaN、无穷大和安全整数范围以外的数;它返回普通boolean,不是类型谓词,单独调用不会把unknown收窄为number。- 安全整数仍可能是负数或超过协议允许的次数,需要另做范围校验。校验后的静态类型仍是
number,不会自动变成“有界整数类型”。
执行了检查,与类型系统理解了检查结果,是两件事。 跨语言协议解码既需要真实的运行时验证,也需要编译器能跟踪的类型收窄,不能用 as number 替代。参考:TS 类型收窄、Number.isSafeInteger。
TS 的结构类型通常容许变量携带额外字段;对象字面量的多余属性检查和弱类型检测只能拦下一部分情况,不能替代运行时白名单(见 interface 与结构类型)。控制面协议往往要求拒绝未知字段,以发现协议漂移。
requireExactFields(
receipt,
EFFECT_RECEIPT_FIELDS,
"external capability effect_receipt",
);requireExactFields 检查对象是否存在未知字段或缺少字段。对跨语言、跨 provider 的协议很重要:外部系统多返回一个看似无害的字段,可能慢慢形成未定义协议。原则是:
把 JSON 当作版本化协议,而不是随意的字典;未知字段在边界处拒绝,而不是悄悄透传。
共享 decoder 时,内部命令全集不应自动成为每个入口的白名单:例如 terminal 入口只接受 complete / cancel,mutation 入口只接受 claim / update,仍需按入口分别校验。若统一返回宽联合类型,调用方仍看不到 kind 与 command 的对应关系;需要时可用判别联合与重载保留这种关联。命令白名单限定协议范围,具体操作授权仍需独立检查。
export async function commitTurnJournal(
params: JsonObject,
): Promise<JsonObject> {
await atomicWriteJson(path, journal);
return { ok: true, appended: true, effect_id: incomingEffectId };
}Promise<JsonObject> 读作:这个函数现在不能立刻给出结果,但未来成功时一定给出一个 JsonObject,失败时抛出异常。Node 的网络、文件、进程 API 大量采用异步模型,适合未来的多 Agent control plane;代价是需要避免无界并发和丢失 await。
但 Promise<T> 只描述 fulfilled 通道的值:它不声明 rejected 通道的异常类型,也不要求调用者必须处理拒绝。async 函数甚至可以抛出任意值:
async function run(): Promise<number> {
throw "connection lost";
}因此,读异步函数时要同时追踪两条通道:
- 正常兑现:返回值(例如
AuthorityStoreCommitResult中的conflict、ambiguous); - Promise 拒绝:连接错误、驱动异常、实现缺陷等,标准库的拒绝回调参数也是
reason: any。
返回值列全失败状态,不等于异常通道消失。对 Promise<AuthorityStoreCommitResult>,LoopX 将 try/catch 精确放在可能产生提交副作用的 store.commitAuthority 周围:调用已经进入写入边界,异常不能证明“没有写入”,所以恢复成 status: "ambiguous",而不是重试或伪装成明确失败。回执 payload 解码则只把已知的 AuthorityStoreProtocolError 转成 typed failure,其他异常继续抛出,避免把程序 bug 包装成存储异常。见 提交与回执恢复。
阅读习惯可压缩为一句话:先看返回值和异常分别代表什么,再判断异常发生前是否已经跨过副作用边界。
来源:Functional design: TDD in TypeScript。它是下节类型驱动开发中,从普通数组追加操作推导 Promise 追加操作的辅助函数。
下面来自用户提供的代码。lift 表示提升,A 指 Applicative,2 指函数接收两个参数;名字里的 A 与泛型参数 A 无关。它把普通函数 (A, B) => C 变为接收两个 Promise、返回 Promise<C> 的函数。
function liftA2<A, B, C>(
f: (a: A, b: B) => C
): (fa: Promise<A>, fb: Promise<B>) => Promise<C> {
return (a, b) => a.then(aa => b.then(bb => f(aa, bb)))
}
const addAsync = liftA2((x: number, y: number) => x + y)
addAsync(Promise.resolve(2), Promise.resolve(3)).then(console.log) // 5<A, B, C> 分别表示两个输入与一个输出的类型,可以不同。外层参数 f 是普通二元函数;冒号后 (fa: Promise<A>, fb: Promise<B>) => Promise<C> 是“返回的函数”的类型。类型签名里的 fa / fb 不要求与实现里的 a / b 同名:这里 a / b 是 Promise,aa / bb 才是成功后拿到的 A / B。
执行分两次调用:liftA2(f) 创建并返回函数,闭包保留 f;再传 (fa, fb) 时注册 then 回调,立即返回新 Promise。a 成功后拿到 aa,再等待 b 拿到 bb,最终计算 f(aa, bb)。then 回调返回普通 C 时兑现新 Promise;返回另一个 Promise 时跟随它的状态,所以嵌套 then 仍得到一个 Promise<C>。此处按 f 返回普通值理解;原生 Promise 还会展开回调返回的 Promise / thenable。
便于理解的 async / await 写法(替换上面 return 部分):
return async (fa, fb) => {
const a = await fa
const b = await fb
return f(a, b)
}先等待 a 再读取 b,不代表两个异步任务串行启动:入参是已经创建的 Promise,它们可能已同时运行。原版直到 a 成功才给 b 挂处理链;若 b 先拒绝,可能暂时无人处理该拒绝。两个独立输入通常可改成 return (fa, fb) => Promise.all([fa, fb]).then(([a, b]) => f(a, b)),同时订阅二者;成功值仍按输入顺序交给 f,但拒绝的观察时机与原版不同。Promise.all 不负责启动或取消传入 Promise 的底层任务。
来源:Functional design: TDD in TypeScript (aka abusing declare)。这里 TDD 指 Type-Driven Development,即类型驱动开发;不同于通常所说的测试驱动开发。
先声明目标签名,把实现缺口拆成带类型的辅助函数,再逐一填上。原文要将 Array<Promise<T>> 变成 Promise<Array<T>>:
declare function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>>推导链:用 reduce 归约 → 元素类型是 Promise<T>,累加器是 Promise<T[]> → 初值为 Promise.resolve([]) → 普通 push: (T[], T) => T[] 经 liftA2 提升为 Promise 累加器。复用上节 liftA2,可将原文的 push / pushPromise 合写为:
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
const pushPromise = liftA2<T[], T, T[]>((xs, x) => xs.concat([x]))
return promises.reduce(pushPromise, Promise.resolve<T[]>([]))
}
// sequence([]) 成功得到 [];多个成功结果按输入顺序组成数组declare 只为类型检查提供签名,不生成函数、变量或 mock。草稿可以在编辑器中继续推导,但若没有实际运行时提供者,调用声明的函数会报 ReferenceError。declare 不能直接写在函数体里;原文用顶层 declare const TODO: any 临时表示缺口,any 会放宽检查,最终应补齐这些占位并验证实际行为。
类型能指导“哪些部件可以接起来”,不能证明算法满足全部要求。这份 sequence 演示类型推导,不完整复刻 Promise.all:它按累加链逐个订阅输入,拒绝的观察时机不同;逐次 concat 还会反复复制数组。成功顺序、失败传播与执行成本都需单独考虑,不能由签名推出。
async 让函数返回 Promise,并允许用 await 暂停;不会把其中的同步计算移到后台线程:
const raw = await readFile(path, "utf8"); // 等待文件期间,可以处理其他工作
const data = JSON.parse(raw); // 恢复后,在当前 JS 线程同步解析同一事件循环上的 JS 回调需要轮流执行。解析或大循环持续多久,其他请求的 JS 处理就可能被阻塞多久。可在有界的小段工作之间主动让出事件循环:
import { setImmediate } from "node:timers/promises";
// 每个 Buffer 都是一条完整且大小受限的 JSON 记录。
async function parseRecords(records: readonly Buffer[]): Promise<unknown[]> {
const values: unknown[] = [];
let processedBytes = 0;
for (const record of records) {
values.push(JSON.parse(record.toString("utf8")));
processedBytes += record.byteLength;
if (processedBytes >= 256 * 1024) { // 示例预算,按实际耗时调整
processedBytes = 0;
await setImmediate();
}
}
return values;
}这里导入的是 Promise 版 setImmediate:暂停当前函数,等事件循环运行到 check 阶段兑现 Promise 后再续跑。其他就绪的 I/O 回调等因此获得调度机会,但不保证所有请求都先执行。
| 写法 | 暂停后怎样恢复 | 对长循环的作用 |
|---|---|---|
await Promise.resolve() |
后续代码进入微任务队列;事件循环继续处理 I/O 等阶段前,需先清空微任务 | 循环不断追加微任务,仍可能让 I/O 一直等待 |
await setImmediate() |
等待事件循环的 immediate 调度,再恢复后续代码 | 在计算片段之间给事件循环推进的机会 |
这是协作式让出,提高响应性,没有减少解析工作量,也没有让计算并行。字节预算只是耗时近似:单条巨大记录、解码或递归冻结仍可能长时间占用线程;单次 JSON.parse() 中途无法插入 await。也不能随意切开一个 JSON 文档分别解析:需要独立记录或增量解析器;CPU 工作确需并行时再考虑 Worker Threads。
参考:Node 调度说明、Promise 版 setImmediate。
return promise 和 return await promise 只差一个 await,但改变了谁来观察这次拒绝、以及 finally 在什么时刻运行。
async function first() {
try {
return readProof(); // 返回 Promise 本身
} catch {
return { status: "ambiguous" };
}
}
async function second() {
try {
return await readProof(); // 在 try 内等待
} catch {
return { status: "ambiguous" };
}
}first:catch只能捕获调用readProof()这一刻同步抛出的异常;返回的 Promise 之后发生的拒绝不会进入这个catch,而是直接传播给调用者。second:在try内await,异步拒绝也进入本地catch,调用者拿到的是{ status: "ambiguous" }。
涉及资源释放时,差别更直接:
async function withLock<T>(operation: () => Promise<T>): Promise<T> {
const lock = await acquireLock();
try {
return await operation(); // 不能写成 return operation()
} finally {
await releaseLock(lock);
}
}return operation():函数立刻把 Promise 交出去,控制流随即进入finally→ 锁在操作完成之前就被释放。return await operation():先在锁内等到操作结束(成功或失败),再进入finally释放锁;顺序是「操作完成 → 释放锁」。
所以 return await 的判据不是风格,而是这次异步结果是否需要被本地的 try / finally 观察:
| 位置 | 是否必须 await |
|---|---|
try { return f() } catch { ... },希望异步拒绝被本地转换 |
必须 |
try { return f() } finally { ... },希望释放发生在其后 |
必须 |
| 单纯透传、外层不关心时序 | 二者等价,可省 |
两个容易过时的印象:
- 「
return await会多花一次微任务」是早期引擎的行为;ECMA-262 改过之后不再成立,ESLint 的no-return-await规则也已在 v8.46.0 弃用(并指出return await的栈追踪反而更好)。 - 类型系统不会替你补这个
await:两种写法都通过Promise<T>检查,差的是运行时边界。
一句话:await 决定异常在哪里被观察、资源在哪里被释放;return 只决定值怎么交出去。
() => void 表达的是调用方不使用返回值,不是「这个函数同步执行」;返回其它值的函数本来就可以赋给它(TypeScript 手册写在 Return Type void 一节)。
const hook: () => void = async () => {
await saveSomething();
};
hook(); // 调用方没有等待,拒绝也没人处理所以把回调标注成 () => void 并不能防止异步工作被丢到后台。要建立顺序,必须在调用点明确等待:
type WriteDeps = {
beforeWrite?: (handle: JsonObject) => void | Promise<void>;
};
await deps.beforeWrite?.(handle); // 同步返回也能 await
await revalidateAuthority();
await commit();void | Promise<void> 是诚实的合同(可以实现成同步,也可以实现成异步),而调用点的 await 同时覆盖两种实现;反过来,只把类型写成 Promise<void> 却漏掉 await,顺序依然没有保证——合同声明和调用方式要一起成立。
const path = source.path;
const digest = source.sha256;
return async () => hash(await readFile(path)) === digest;为什么不直接在回调里读 source.path、source.sha256?因为闭包持有的是对象引用,属性在调用时才被读取:
const source = { path: "old" };
const readProperty = () => source.path; // 调用时才读属性
const path = source.path;
const readSnapshot = () => path; // 捕获的是取出的值
source.path = "new";
readProperty(); // "new"
readSnapshot(); // "old"const source 只固定变量绑定,并没有冻结对象。把要用的值提前取出成局部常量,等于给这次判断拍一张快照——即使别处改了请求对象,校验器也不会被悄悄换成「检查另一个文件、另一个摘要」。
类型侧是同一件事的另一面:
function makeReader(source: { path: unknown }) {
if (typeof source.path !== "string") throw new Error("invalid");
return () => source.path.toUpperCase();
// 报错:回调执行时,这个可变属性未必仍是 string
}收窄只对当前这次读取有效,不会延伸到回调执行的那一刻。提成局部常量可以同时解决两个问题:
const path = source.path; // 此处已收窄为 string
return () => path.toUpperCase();- 类型层面:把已经确认的
string交给闭包,而不是让它在未来重新读一个unknown属性; - 运行时层面:闭包用的是取出的值,不受之后属性变更影响。
判断规则:闭包要长期持有一个「已校验」的值,就在收窄处取出来(const local = obj.prop),不要在闭包里反复写 obj.prop。这和 TS 不会自动解决什么里的 TOCTOU 是同一主题——校验的时点和使用的时点之间,状态可能已经变了。
不想手写第二份类型时,可以直接“按字段取类型”:
receipts: SettlementResult<unknown>["receipts"]它不是重新声明 receipts: readonly SettlementReceipt[],而是引用 SettlementResult 里 receipts 字段的权威类型。收益:以后 SettlementResult.receipts 的只读性或结构变化,这里自动同步,不会形成第二份类型知识。与数组上的 [number](取元素类型)是同一套 indexed access 机制,只是把下标换成字段名。
Pick<T, K> 从 T 中挑出 K 指定的键,构造一个新的对象类型;它是纯编译期操作——不拷贝、也不删除任何运行时字段。它同时承担两个作用:声明函数依赖哪些事实,以及限制构造器允许设置哪些字段。
type CheckInput = Pick<Request, "resource" | "actorId" | "expectedVersion">;
type ResultOptions = Partial<Pick<Result, "retryable" | "nextState">>;Pick<T, K> 选取字段,也声明函数允许依赖哪些事实:接收 CheckInput 的函数不能在正常类型检查下读取完整请求中的其他字段。TS 使用结构类型,已有完整请求对象仍可合法传入;Pick 不会删除运行时字段,裁剪或脱敏需要显式构造新对象。
Partial<Pick<…>> 先选出可调字段,再将其变为可选,适合构造器的 options:
function makeResult(
outcome: Result["outcome"],
code: string,
options: ResultOptions = {},
): Result {
return {
retryable: false, nextState: null, ...options,
schemaVersion: 1, outcome, code,
};
}相较 Partial<Result>,调用方不会获得 outcome、code 等字段的第二个设置入口;固定字段放在展开之后,也避免运行时额外同名字段覆盖它们。需要严格白名单时仍应逐字段构造。受限 options 只能减少自由度,不能自动排除“失败结果带成功状态”等组合;可用结果判别联合或专门的成功 / 失败构造器进一步约束。
Omit<T, K> 的定义是 Pick<T, Exclude<keyof T, K>>,而 keyof (A | B) 只保留各成员共有的键。所以直接对联合类型用 Omit,会把分支专属字段一起丢掉:
type Effect =
| { kind: "validate"; command: string; trace: string }
| { kind: "commit"; revision: string; trace: string };
type Collapsed = Omit<Effect, "trace">;
// 期望:{ kind: "validate"; command: string } | { kind: "commit"; revision: string }
// 实际:{ kind: "validate" | "commit" }——command / revision 都不见了要让 Omit 分别作用于每个分支,用分配式条件类型(裸类型参数放在 extends 左边会触发分配律):
type DistributiveOmit<T, K extends PropertyKey> =
T extends unknown ? Omit<T, K> : never;
type Preserved = DistributiveOmit<Effect, "trace">;
// { kind: "validate"; command: string }
// | { kind: "commit"; revision: string }对用判别联合描述 effect / state 的协议,这一点很关键:kind 必须和它对应的 payload 一起被收窄,丢字段等于丢掉了「这个分支能做什么」的信息。反过来,从单个 interface 上 Pick / Omit 字段(如 Pick<EditInput, "patch" | "clearFields">)不存在这个问题,不必为此引入新泛型工具。
type TurnSettlementExecution = ...; // 内部实现
type TurnSettlementOutcome = ...; // 内部实现
export type TurnSettlementReduction =
| TurnSettlementExecution
| TurnSettlementOutcome;外部只依赖导出的协议联合,内部的具体类型不暴露:
- 外部依赖一个稳定的契约,内部可自由重构 helper / 拆分结构;
- 不把每个临时实现类型都变成公共 API——TS 项目很容易因为“导出很方便”把所有内部 DTO 暴露,最后任何重构都变成 breaking change。
判断标准:能成为公共 API 的是“别人要依赖的形状”,内部字段、临时 helper、演进中的结构都留在模块里。
可预期的业务失败作为结果返回,理论上不应到达的状态直接抛异常:
// 业务失败:调用方需要处理,走正常返回
return settlementFailed({ kind: "receipt_missing", ... });
// 不变量违背:实现或协议出现矛盾,不应该伪装成业务失败继续跑
throw new Error(
`failed_provider_attempt for ${stepKind} unexpectedly committed`,
);两者语义不同:
- typed failure:系统知道这类失败如何进入 receipt、projection 和恢复流程;
- exception:表示状态机或协议本身出了 bug,继续“优雅包装”只会掩盖问题。
控制面代码里把所有异常都转成优雅结果反而危险:它把实现矛盾当成普通业务失败,等 bug 被吞掉后,错误会以更难查的形式冒出来。
异常是否能恢复取决于边界:effect adapter 可以在“副作用可能已发生、响应丢失”的窗口把外部 I/O 异常转成 ambiguous;协议解码只转换已知错误;未知异常仍应暴露。
function isStringLiteral<const Values extends readonly string[]>(
value: string,
allowed: Values,
): value is Values[number]拆解三件事:
const Values尽量保留调用方数组的字面量 tuple 信息(精确成员集合);value is Values[number]是用户定义类型谓词(type predicate):告诉 TS「这个函数返回 true 时,value就是这个联合」;- runtime 真的执行 membership check(不是假装通过)。
因此 requireStringLiteral(value, QUOTA_SPEND_SOURCES, ...) 同时完成两件事:运行时白名单校验 + 返回精确的 QuotaSpendSource,没有任何不受验证的 as QuotaSpendSource——类型收窄来自真实的运行时证据。谓词 = 可执行的真值函数 + 类型契约。
TS 5.5 起可自动推断部分类型谓词,使 filter 的结果随之收窄(开启 strictNullChecks):
const values: (string | undefined)[] = ["todo_next", undefined];
const ids = values.filter(value => value !== undefined); // string[]
// 回调自动推断为 (value: string | undefined) => value is string推断要求:无显式返回类型、只有一个返回且无隐式返回、不修改参数,返回与参数收窄相关的布尔表达式。给回调标 : boolean 会阻止本例的谓词推断;!!value 也不能在这里替代 value !== undefined,因为它还会排除空字符串,返回 false 不代表值一定是 undefined。参考:TS 5.5 — Inferred Type Predicates。
来源:Functional design: smart constructors。与 函数式编程中的 smart constructor 对照。
约束由四部分配合:运行时条件验证值,类型谓词传递检查结果,品牌类型区分已验证值与原始值,smart constructor 用 Option 显式表达失败。
import { none, some } from 'fp-ts/Option'
import type { Option } from 'fp-ts/Option'
interface NonEmptyStringBrand {
readonly NonEmptyString: unique symbol
}
type NonEmptyString = string & NonEmptyStringBrand
function isNonEmptyString(s: string): s is NonEmptyString {
return s.length > 0
}
function makeNonEmptyString(s: string): Option<NonEmptyString> {
return isNonEmptyString(s) ? some(s) : none
}
function greet(name: NonEmptyString): void {
console.log(name)
}
greet('') // 编译错误:普通 string 没有品牌
greet('Alice') // 同样编译错误:合法内容也须先获得品牌
const result = makeNonEmptyString('Alice')
if (result._tag === 'Some') greet(result.value)真正执行检查的是 s.length > 0;s is NonEmptyString 是类型谓词,声明返回 true 时可以把 s 当作 NonEmptyString。运行时函数仍只返回 boolean,品牌和谓词注解均被擦除,不会给字符串附加属性。make 的 true 分支把收窄后的 s 包成 Some;false 分支返回 None。直接写同样的长度判断,TypeScript 不会自动推导出这个自定义品牌。
TypeScript 信任开发者声明的谓词,不会证明其实现正确:将函数体改为 return true 仍可通过类型检查,却破坏了业务不变量;'' as NonEmptyString、any 或未受检的 JavaScript 调用也可绕过约束。因此只公开受检构造入口,业务函数只收品牌值,并保证构造器检查正确。类型负责保留和传播校验结果;greet / person 本身不会自动插入运行时校验。
检查只能保证它实际检查的性质:length > 0 接受空白字符串;外部输入若为 unknown,还要先验证 typeof value === 'string'。原文 Int 的条件是 Number.isInteger(n) && n >= 0,实际表示非负整数,包含 0。
来源:io-ts 稳定模块文档、Type 实现。本节按所读 v2.2.22 的
import * as t from 'io-ts'整理;README 中的 Decoder / Codec / Schema 等实验模块采用独立、不向后兼容的 API。
io-ts 将手写 smart constructor 变成可组合的 codec:基础规则可以组成对象、数组与联合类型;同一份定义既执行运行时校验,又通过 TypeOf 提取静态类型,减少 interface 与 validator 分别维护的漂移。安装时需要 io-ts 和其 peer dependency fp-ts,后者提供 Either 等类型。
核心是 Type<A, O = A, I = unknown>:I 是输入,A 是解码成功后的业务值,O 是编码输出。
| 接口 | 含义 |
|---|---|
codec.decode(input) |
I → Either<Errors, A>;实际调用带默认上下文的 validate,成功 Right(A),失败 Left(Errors) |
codec.is(value) |
类型守卫:判断 value 本身是否已经符合 A,返回 boolean;不做解码转换,也不给详细错误 |
codec.encode(value) |
A → O;按 codec 编码,接口假定输入已是合法 A,不会自动先调用 decode 校验 |
t.TypeOf<typeof codec> |
编译期提取 A;InputOf / OutputOf 分别提取 I / O,这些类型工具不执行检查 |
承接上节非空字符串,基础类型检查和业务条件可以分层组合:
import * as t from 'io-ts'
import { isLeft } from 'fp-ts/Either'
import { PathReporter } from 'io-ts/PathReporter'
interface NonEmptyStringBrand { readonly NonEmptyString: unique symbol }
const NonEmptyString = t.brand(
t.string,
(s): s is t.Branded<string, NonEmptyStringBrand> => s.length > 0,
'NonEmptyString'
)
const UserCodec = t.type({ name: NonEmptyString })
type User = t.TypeOf<typeof UserCodec>
const input: unknown = { name: 'Alice' }
const result = UserCodec.decode(input)
if (isLeft(result)) {
console.error(PathReporter.report(result))
} else {
const user: User = result.right
console.log(user.name)
}
// UserCodec.decode({ name: '' }) 或 { name: 42 } 均返回 Left这里真正的检查链是“对象形状 → t.string → 长度谓词”。brand / refinement 先执行基础 codec 的 validate,成功后才运行 predicate;品牌仍只存在于类型层,自定义 predicate 的正确性仍由开发者保证。库负责组合与传递结果,不会证明业务条件。
常用组合:t.type 定义对象字段,t.partial 定义可选字段,t.array(C) 校验数组元素,t.union 表达多种合法形态,t.intersection 合并约束。t.Int 只要求整数,包含负数;非负 / 正数需要另加谓词。
使用边界:
- 解码可能转换值,例如
Type<Date, string, unknown>把合法日期字符串解成 Date、再编码成字符串;.is检查的是 Date 本身。因此成功后使用result.right,而非继续把原始 input 当成校验结果。 t.type默认保留多余字段;t.exact在解码结果中裁掉它们,t.strict(props)等价于t.exact(t.type(props))。若协议要求遇到未知字段就报错,需要另外检查。Errors保留失败值、字段路径 / codec 上下文与可选 message,PathReporter将其转成字符串数组;对比只表达成功 / 失败的 Option,Either 保留了失败原因。内置校验失败返回 Left,由调用方决定如何处理。
断言签名告诉 TS:函数正常返回,所断言的条件就成立。例如 assert.ok 的简化声明:
declare function ok(value: unknown): asserts value;在测试辅助函数内:
const result = await store.loadAuthority();
assert.ok(result.status === "loaded");
return result; // 已收窄到 loaded 分支类型谓词在返回 true 的分支收窄;断言函数在正常返回后收窄。普通返回 void 的检查函数通常不能向调用者传递这条类型事实。asserts 签名本身不生成检查逻辑,自定义实现必须在条件不成立时抛错,不能只靠签名保证正确。参考:TypeScript Assertion Functions。
async function withFileMutationLock<T>(
targetPath: string,
operation: () => Promise<T>,
): Promise<T>它不关心 operation 返回什么,只增加一个控制面性质:该 operation 在锁内串行执行。调用者仍得到原始的精确返回类型:
return await withFileMutationLock(indexPath, async () => {
return QuotaSpendCommitResult;
});这是 resource-scoped combinator:把 acquire / release / finally 固化在一个边界中,业务函数无法「忘记解锁」,同时 T 透传、不污染业务返回类型。
这里的 return await 不能省:它决定 finally 在操作完成之前还是之后运行,机制见 return await:异常处理与 finally 的边界。
// 脆弱:开发者承诺
(error as NodeJS.ErrnoException).code === "ENOENT"
// 稳健:运行时建立证据
error instanceof Error && "code" in error && error.code === code现代 TS 中 catch (error) 应视为 unknown:第一种写法是「相信我它有 .code」的承诺;第二种逐步建立证据——先确认是 Error,再确认存在 code 字段,最后比较值。通用 helper 推荐第二种:它更适合未来把非 Node 异常或测试 double 送进来时 fail closed,而不是因为「某个对象恰好有 code 但语义不同」而误判。
{ a: 1, b: 2 }
{ b: 2, a: 1 }语义相同,但 insertion order 不同可能得到不同字符串——request_digest 这类 hash 用途不能直接 hash 普通对象。本 PR 用递归 stableValue():数组保留顺序、对象 key 排序、Object.fromEntries() 重建、再 JSON.stringify() + SHA-256。
它只是项目内 canonicalization,不是完整标准 Canonical JSON(如 RFC 8785):
undefined/ 函数 /NaN/Infinity有特殊序列化行为;BigInt会抛错;- 循环引用无法处理;
- 键排序必须遵守选定协议:
localeCompare是地区排序,不能代替明确的码元 / 码点规则;RFC 8785 §3.2.3 指定 UTF-16 码元顺序,并非 Python 字符串的码点顺序。两种次序的差异见 跨语言排序。
当前主要安全来源是请求先经过 JSON RPC;未来 Stage 3 进程内直接调用 TS kernel 后,不能默认「类型是 JsonObject 就一定可以稳定 JSON 化」。
type JsonObject = Record<string, unknown>; // 只表示:字符串键 → 任意未知值
const commit = { ... } satisfies JsonObject; // 只能证明结构可赋值Record<string, unknown> 不保证值可被 JSON 序列化——bigint、function、undefined、class instance、cycle 都可能混进来。satisfies JsonObject 只是「宽类型可赋值」检查,很容易制造错误安全感。控制面真正的安全合同来自 runtime decoder、RPC serialization 和 domain invariant,而不是 JsonObject 这个名字(呼应「unknown 与 any」「requireExactFields」)。
let expectedRevision: ReturnType<typeof parseRevision>;不手写 { store_identity: string; revision: string } | null 这种第二份返回类型,而是直接取解析函数的返回类型。以后 parser 返回结构变化,变量类型自动同步。
这是「类型知识单一来源」的延续:parser 已经是权威,就不要再维护第二份返回类型(和「索引访问类型」「用户定义类型谓词」同一思路)。
工具类型可以从内向外组合,直接派生异步接口的成功分支:
type Loaded = Extract<
Awaited<ReturnType<Store["load"]>>,
{ status: "loaded" }
>;Store["load"] 取方法类型,ReturnType 取返回类型,Awaited 取 await 后的结果,Extract 保留可赋值给 { status: "loaded" } 的联合成员。接收 Loaded 的提交函数只接受已加载分支,字段随上游接口演进,无需重复手写。前提是上游返回类型已用字面量 status 区分分支;这不会替代运行时加载检查。
import type { Store, StoreCommit } from "./store.ts";import type 只供 tsc 检查,运行时会被擦除。它避免三件事:
- 只引用 interface,却产生不必要的 runtime import;
- ESM 循环依赖;
- Node 在运行时加载一个本不需要的模块。
配合 type-strip 执行的配置:
{
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"noEmit": true
}含义:TypeScript 只做检查、不生成 JS,Node 直接 type-strip 执行 .ts,所以源码 import 可以显式写 .ts 后缀。
数据库 cursor / revision 可能超过 JS 安全整数范围(Number 最大安全整数 2^53 - 1),所以:
const parsed = BigInt(cursor);
const next = (parsed + 1n).toString();完整链路:
PostgreSQL bigint
→ SQL ::text
→ TypeScript BigInt 做比较和加法
→ decimal string 进入 JSON / provider token
不能直接把 TS bigint 塞进 JSON:JSON.stringify({ cursor: 1n }) 会抛异常。模式是内部用 BigInt 做数学,协议边界一律 string(呼应「canonical JSON」里 BigInt 不是 JSON-safe——这里补上完整用法)。
const match = PATTERN.exec(value);
if (!match) throw new Error(...);
return {
store_identity: `postgresql:${match[1]!}`,
revision: match[2]!,
};match[1]! 的意思是「我向编译器保证这里不是 undefined」——它不生成任何运行时检查。这里之所以安全,是因为前面已经检查 match 非空、且正则拥有固定的两个捕获组。
! 应只用在编译器无法追踪、但程序已经有明确运行时证明的地方;滥用它和滥用 as 一样,都是在绕过类型系统(见「错误码的运行时收窄」)。
参考:tsconfig: noUncheckedIndexedAccess、tsconfig: exactOptionalPropertyTypes。
strict: true 打开的是一组固定开关,另有若干严格检查必须单独开启。两个常用的:
noUncheckedIndexedAccess:让数组与索引签名的读取体现「可能不存在」。
const items: Item[] = [];
const first = items[0];
// 关闭时:Item
// 开启时:Item | undefined它对回执数组、按 id 建立的冲突索引这类代码很有价值。但它只说明「这个位置可能没有值」,不证明下标与内容对应正确——业务边界仍然要自己校验。
exactOptionalPropertyTypes:区分「属性缺席」与「属性存在但值显式是 undefined」。
type Options = { ttl?: number };
const a: Options = {}; // 合法
const b: Options = { ttl: undefined }; // 开启后不合法这与更新协议直接相关:"ttl" in options 能区分这两种对象,而直接读 options.ttl 在两种情况下都可能是 undefined。如果合同确实允许显式 undefined,要写成 { ttl?: number | undefined }。
开启这些开关不是「顺手加一行」:新增的 undefined 分支要逐处确认真实语义(是缺失、是显式清空,还是实现 bug),否则只是把问题从运行时挪到一份很长的编译错误清单里。
同一份实现,写成「方法简写」和写成「函数属性」,参数兼容性的检查强度并不一样——这是 strictFunctionTypes 留下的一处例外:
interface MethodHook {
authenticate(value: unknown): boolean;
}
const hook: MethodHook = {
authenticate(value: string) { // 允许
return value.startsWith("token-");
},
};
hook.authenticate(42); // 编译通过,但运行时会崩接口承诺「任何值都能传」,实现却只处理 string,编译器仍然接受——因为方法语法保留了参数双变(bivariance)检查:只要两个参数类型互相可赋值其一,就算兼容。
把同一个接口改成函数属性,实现就会被拒绝:
interface FunctionHook {
authenticate: (value: unknown) => boolean;
}
const hook: FunctionHook = {
authenticate: (value: string) => value.startsWith("token-"),
// 报错:string 处理不了任意 unknown
};直觉:接口说「你可以传任何值」,实现就不能只接受字符串——参数位置要求逆变,实现的输入至少要能和目标一样宽。
工程含义:
- 外部会拿任意输入调用你的边界(回调、认证函数、插件 handler、事件订阅)优先用函数属性语法,让
strictFunctionTypes挡掉参数收窄; - 方法语法适合「调用方是同一抽象内部的代码、参数由该抽象自己保证」的场景;
- 读到「实现明显只处理一种输入,却依然编译通过」的接口时,先确认它是方法简写还是函数属性——
strictFunctionTypes的收紧只作用于函数类型位置,方法被有意排除在外。
参考:TypeScript Handbook: Function Parameter Bivariance、tsconfig: strictFunctionTypes。
interface Store {
load(): Promise<...>;
commit(...): Promise<...>;
readReceipt(...): Promise<...>;
}File 和 Database 都能 implements Store,只说明方法签名一致;编译器无法证明它们都满足:
- CAS 只能一个 writer 成功;
- state / event / receipt 原子提交;
- 历史 receipt 可重放;
- cursor 有序;
- 返回结果不会泄漏内部引用。
做法是用 typed factory 为每个 provider 注册同一套 conformance tests:
registerStoreConformance("Database provider", async () => ({ store, contender }));核心认识:
TypeScript interface 是静态形状合同;分布式存储的行为合同必须由共享 conformance suite 和真实 backend 测试证明。
真实写法(settle_completion 的 gate 检查):
if (
completed.completion_continuation === "successor" &&
completed.successor_todo_ids.some(
(todoId) => !request.materialized_todo_ids.includes(todoId),
)
) {
return unchangedResult(
"settle_completion",
"awaiting_successor",
request.lines,
);
}从里到外拆解:
request.materialized_todo_ids.includes(todoId)
→ 已物化列表里是否包含这个 todoId(返回 boolean)
!request.materialized_todo_ids.includes(todoId)
→ 取反:这个 todoId 尚未被物化
completed.successor_todo_ids.some((todoId) => !...includes(todoId))
→ 对 successor_todo_ids 逐元素调用箭头函数
→ 只要「存在一个」尚未物化的 todoId,整体就是 true
整句读法:
successor_todo_ids 里存在至少一个不在 materialized_todo_ids 中的 id。
.some() 是数组的存在性谓词:对每个元素执行传入的函数,任一元素返回 true 就立即返回 true(短路,不再遍历);空数组返回 false。箭头函数 (todoId) => ... 是匿名函数,todoId 是当前元素,函数体返回 boolean。
等价 Python 写法:
any(
todo_id not in request.materialized_todo_ids
for todo_id in completed.successor_todo_ids
)对照表:
| TS | 含义 | Python 对应 |
|---|---|---|
arr.some(fn) |
存在一个元素满足谓词 | any(fn(x) for x in arr) |
arr.every(fn) |
所有元素都满足谓词 | all(fn(x) for x in arr) |
arr.includes(x) |
数组是否包含 x | x in arr |
!arr.includes(x) |
数组是否不包含 x | x not in arr |
常见坑:
- 空数组的语义相反:
some返回false,every返回true——写 gate 条件时要先想清楚“空列表应该放行还是停留”; includes用 SameValueZero 比较,能正确识别NaN;indexOf用严格相等,NaN永远找不到;some/every都会短路,谓词里不要写有副作用、且依赖“全部元素都被访问”的代码。
这个片段也是状态机的典型写法:continuation 是 "successor" 但还有 successor 未物化时,不推进结算,而是返回 unchangedResult("awaiting_successor")——条件不满足就幂等停留,避免提前结算或重复副作用。
选择下一个可执行 Todo 的典型写法:
const candidates = todos.filter((todo) =>
todo.status === "open" &&
!CONTROL_TASK_CLASSES.has(todo.task_class ?? ""),
);
candidates.sort((left, right) => {
const leftClass = left.task_class === "advancement_task" ? 0 : 1;
const rightClass = right.task_class === "advancement_task" ? 0 : 1;
return leftClass - rightClass ||
priorityRank(left.text) - priorityRank(right.text) ||
left.index - right.index;
});关键细节:
filter创建新数组,所以后续sort修改的是副本,不会污染调用者传进来的readonly todos;如果直接对todos.sort(...),即使类型写了readonly,实现也会破坏原数组;- 多条件排序用
||串联比较结果:第一个非零差值决定顺序(first diff wins); advancement task优先于监控、用户 gate、阻塞类控制任务,再按[P0]到[P4]、最后按原始 index 排序。
元素可以是 bigint,比较器返回值仍应是 number。 Array<T>.sort 的 TS 比较器合同是 (a: T, b: T) => number;排序只需要负数、零、正数表达前后关系,不需要精确差值。
const instants: bigint[] = [9007199254740993n, 9007199254740992n];
// 错误:a - b 返回 bigint,TS 不接受
instants.sort((a, b) => a - b);
// 正确:精确比较 bigint,返回 number;这里是降序
instants.sort((a, b) => a > b ? -1 : a < b ? 1 : 0);前一种写法在 JS 中也有问题:比较器被调用后,排序算法会对返回值执行 ToNumber,而 bigint 会触发 TypeError。后一种直接比较原始整数,可保留以 bigint 存储的微秒时间戳精度;排序结果只需用 -1 / 0 / 1 表示。参考:ECMAScript — CompareArrayElements。
跨语言“字符串排序”未必相同。 JavaScript 字符串的 < 按 UTF-16 码元做字典序比较,Python str 按 Unicode 码点;ASCII 样本看不出差异,补充平面字符则可能改变顺序:
"\u{10000}" < "\uE000"; // true
// U+10000 在 UTF-16 中是 D800 DC00,首码元 D800 < E000"\U00010000" < "\uE000" # False:码点 0x10000 > 0xE000迁移事件 / ID 排序时应保留协议规定的顺序:若要兼容 Python,就用统一的码点字典序比较器,逐码点比较,公共前缀相同时较短字符串在前,并用补充平面字符覆盖回归。不能直接复用 JS 的 <,也不能任意换成 localeCompare:地区排序语义不同,还可能受 locale、选项与实现版本影响。码点顺序也不是所有协议的默认正确答案,须以具体合同为准。
参考:ECMAScript — IsLessThan、Python — Value comparisons。
需要把筛选结果交回原数组处理时,应先保留来源位置,再过滤或排序。 ordinal 意为序号,这里特指从 0 开始的原始数组下标,不是筛选结果的新排名,也不是元素的永久 ID。
const source = ["A", "B", "C"];
const selected = source
.map((item, ordinal) => ({ item, ordinal }))
.filter(({ item }) => item === "C");
// [{ item: "C", ordinal: 2 }]
const renumbered = source
.filter(item => item === "C")
.map((item, ordinal) => ({ item, ordinal }));
// [{ item: "C", ordinal: 0 }];这个 0 用于 source[0] 会取到 "A"ordinal 从哪里来,要区分值的传入与类型推断。 JavaScript 的 map 调用回调时依次传入当前元素、它在被遍历数组中的下标、该数组;第二个参数可以任意命名为 index、i 或 ordinal,名称不改变行为。上述第一段遍历的是 source,因此处理 "C" 时收到 2;第二段遍历的是过滤后的 ["C"],因此收到 0。
TypeScript 则根据 map 的声明对回调做上下文类型推断。简化声明为:
interface Array<T> {
map<U>(callback: (value: T, index: number, array: T[]) => U): U[];
}source 是 string[],因此 T = string,item 被推断为 string、ordinal 为 number;({ item, ordinal }) 是返回对象的箭头函数,属性简写等价于 { item: item, ordinal: ordinal },进而推断 U 为 { item: string; ordinal: number }。TS 没有自动生成名为 ordinal 的变量,也没有把它推断为“这个元素在这份快照里的合法位置”。filter 只保留符合条件的元素,不会重写对象里已经保存的 ordinal。
类型正确不代表来源正确。 普通 number 不关联具体数组或快照,noUncheckedIndexedAccess 也只能提示可能越界,不能识别“合法下标取错元素”。跨语言 / 进程返回这些位置时,应检查它们是合法整数、在来源数组范围内、属于此次选中集合,并按合同检查重复与元素身份对应;来源可能变化时还要绑定并核对 snapshot_id / revision。保存 ordinal 不会冻结数组:原数组发生插入、删除或重排后,旧位置可能失效;跨快照追踪元素应使用稳定 ID,位置另行解析。
参考:MDN — map 回调参数、TypeScript — Contextual Typing。
Map 按键的首次插入顺序遍历;更新已有键不会把它移到末尾,删除后重新插入则算新插入。这个顺序可以成为算法语义:按任务原顺序遍历,首次遇到领取者时建立分组,再按组的插入顺序分配展示位置,最后按事先保存的 ordinal 恢复任务原顺序。分组展示顺序与任务来源顺序是两个维度。
const groups = new Map();
groups.set("10", "先加入");
groups.set("2", "后加入");
groups.set("10", "更新已有组");
[...groups.keys()]; // ["10", "2"]
Object.keys({ "10": "先加入", "2": "后加入" });
// ["2", "10"]:普通对象的数组索引式字符串键按数值升序枚举Python 字典保持插入顺序;迁到 TS 时,不能只看两边都像“键值表”,还要检查是否依赖遍历顺序。普通对象也有确定的键枚举规则,但不等同于 Map 的插入顺序。参考:MDN — Map、Python — dict。
const CONTROL_TASK_CLASSES = new Set([
"continuous_monitor",
"user_gate",
"blocker",
]);
... !CONTROL_TASK_CLASSES.has(todo.task_class ?? "")Set.has() 是 O(1) 成员判断,也比 includes 更直接地表达“这是语义化黑名单”。task_class ?? "" 表示 null / undefined 时回退到空字符串(?? 只回退 nullish,不覆盖 0、false 这类 falsy 值),让 has("") 自然返回 false——未分类的 Todo 不会被误判为控制任务。
"1" == 1 // true:宽松相等会先做类型转换
"1" === 1 // false:严格相等要求值和类型都相同JavaScript 有两种相等比较:
===(严格相等):不转换类型,值和类型都相同才返回true;==(宽松相等):先做复杂的隐式转换再比较,是经典 bug 来源。
| 表达式 | 结果 | 原因 |
|---|---|---|
"1" == 1 |
true | 宽松相等把字符串转成数字 |
"1" === 1 |
false | 类型不同 |
0 == "" |
true | 都隐式转成 0 |
0 == false |
true | false 转成 0 |
"" == false |
true | 都转成 0 |
null == undefined |
true | 宽松相等的特殊规则 |
null === undefined |
false | 类型不同 |
NaN === NaN |
false | NaN 不等于任何值(包括自己) |
[] == false |
true | 空数组先转 "" 再转 0 |
[] === [] |
false | 对象按引用比较,两个空数组不是同一个引用 |
要点:
- 对象 / 数组按引用比较:
[] === []为 false,const x = []; x === x才为 true; - 判断 NaN 用
Number.isNaN(value)或Object.is(value, NaN),不要用===; Object.is()与===几乎一致,两个区别:Object.is(NaN, NaN)为 true、Object.is(+0, -0)为 false;- 唯一值得用的
==惯用法是value == null:同时匹配null和undefined(===做不到,必须写value === null || value === undefined)。
TypeScript 语境:=== 是类型收窄的触发器。判别联合里写 if (action.decision === "execute") 之后,TS 会把 action 自动收窄到该分支(呼应“判别联合与 never”);switch 的 case 比较同样是严格相等语义。
速记:
写比较默认用
===;只有明确想同时匹配 null / undefined 时才用== null。
return checked.outcome === "apply"
? { ...checked, code: "transition", nextStatus: "done" }
: checked;后写字段覆盖先写字段。外层在检查通过后继承内层证据,并明确增加状态转移;反过来写 { nextStatus: "done", ...checked },可能被旧值覆盖。展开是浅拷贝,嵌套对象仍共享引用。检查这种代码时,要看继承了什么、覆盖了什么、是否带入不属于当前阶段的字段;展开语法本身不证明业务转换合法。
核心原则:
不只给字段标类型,还要让非法状态无法构造。
一个 settlement 只能绑定一个 todo、一个 autonomous replan obligation,或者保持 unbound;不能同时绑定 todo 和 replan。当前代码在运行时拦截:
if (todoId && replanObligationId) {
throw new Error(
"settlement identity cannot bind both todo_id and replan_obligation_id",
);
}更进一步,可以把输入直接建模为联合类型,让“双重 binding”甚至不能被构造出来:
type SettlementBinding =
| { kind: "todo"; todo_id: string }
| { kind: "autonomous_replan"; obligation_id: string }
| { kind: "unbound" };原接口理论上允许 value 和 failure 同时存在;更强的表达:
type SettlementResult<T> =
| {
ok: true;
value: T;
failure: null;
receipts: readonly SettlementReceipt[];
}
| {
ok: false;
value: null;
failure: SettlementFailure;
receipts: readonly SettlementReceipt[];
};成功值和失败同时出现,在类型层就无法表达。
真实流程(Todo 完成 → Next Action 重投影)必须按固定顺序:
标记旧 Todo 为 done
→ 创建并物化 successor Todo
→ 把 successor ID 写回旧 Todo 的 lineage
→ 重投影 Next Action
→ 统一写回 state 文件
顺序很重要:如果先重投影 Next Action、后创建 successor,可能出现“旧 Todo 已完成、新 Todo 还不存在、Next Action 被清空”。因此引入 successor fence——所有声明的 successor 都 materialize 之后,才允许切换 Next Action(对应上一节的 some + includes 片段):
if (
completed.completion_continuation === "successor" &&
completed.successor_todo_ids.some(
(todoId) => !request.materialized_todo_ids.includes(todoId),
)
) {
return unchangedResult(
"settle_completion",
"awaiting_successor",
request.lines,
);
}这是状态机的通用思想:
只有满足前置不变量,才允许进入下一状态;条件不满足时返回“未变化”,而不是强行推进。
字面量联合能限定状态集合:
type ProviderStatus = "running" | "succeeded" | "no_change";
type SettlementStatus = "running" | "ready_to_settle";比 status: string 强:非法状态在边界处就被拒绝。
仅靠两个独立的状态联合,还没有表达状态与字段的关联:
running不能有 receipt;running不能带 mutation;succeeded必须对应 committed receipt;no_change必须对应 no_change receipt,且不能带 mutation。
这些字段关联可以进一步写成判别联合;外部输入仍需在边界校验,receipt 是否真实提交等事实还需查询权威状态。类型约束对象形态,运行时验证输入与业务事实。
string | null 表达数据值,无法单独区分“不修改”和“明确清空”。PATCH API、配置继承、数据库更新可显式建模操作意图:
type Change<T> =
| { kind: "keep" }
| { kind: "clear" }
| { kind: "set"; value: T };
function applyChange<T>(current: T | null, change: Change<T>): T | null {
switch (change.kind) {
case "keep": return current;
case "clear": return null;
case "set": return change.value;
}
}也可约定“字段缺省表示保持、null 表示清空、具体值表示设置”,但 decoder 必须保留缺省与 null 的区别;提前使用 ?? 回退可能吞掉清空意图。判别联合适合替代多组 boolean + nullable 字段,减少互相矛盾的组合。
读取当前状态
↓
判断下一项可执行 effect
↓
执行 effect
↓
获得 typed receipt 或 typed failure
↓
reduce 成新状态
↓
可重放地判断下一步
在 LoopX 里,effect 不是泛指“函数调用”,而是具备业务意义、可观察的动作:validation、durable writeback、quota spend、terminal closeout、Turn journal 原子写入。基础结构是:
输入 Request
→ 解释 Interpretation
→ 得到 Observation/Decision
→ 生成 Next Effect
EffectTurn 接口由 request / interpretation / observation / next_effect 组成。LoopX 本质不是脚本集合,而是长程 Agent 的语义状态机,因此这个抽象很贴合。
Python CLI / Control Plane
→ Python transition adapter
→ loopback JSON RPC
→ Managed TS Effect Runtime
→ Typed handler registry
→ effect_program.ts / turn_journal.ts / turn_journal_effects.ts
→ Typed result / receipt / failure
关键文件(名称保留,路径脱敏):
- TS 语义核心:
effect_program.ts; - RPC 方法注册:
effect_runtime_handlers.ts; - 常驻进程与传输:
effect_runtime_server.ts; - Python 迁移桥:
effect_runtime.py; - Turn journal 规则:
turn_driver/turn_journal.ts; - TS 原生副作用:
turn_driver/turn_journal_effects.ts。
这里不是“每迁一个模块,就造一个 server”,而是:
一个 TS control-plane runtime,内部用一个 typed method registry 承载多个逐步迁入的 bounded context。
以后迁移 todo、quota、replan 等域,继续注册到同一个 runtime,而不是分别启动多个 Node 服务。
这个 server 是迁移桥,不一定是终局:
- CLI-only 形态:LoopX CLI 和 control-plane 主体都迁到 TS 后,同进程直接 import TS kernel,Python→TS RPC bridge 可以删除;
- App / 多进程共享权威形态:如果未来需要多 CLI 并发、App 与 CLI 共享状态、多 Agent 跨进程协作、watcher / scheduler 常驻,仍可能保留一个可选的 control-plane daemon。
最终删除的是“因为 Python→TS 迁移而存在的桥”,不一定删除所有长期有价值的共享 runtime。
迁移中必须区分两个概念:
- 哪一步可执行、receipt 是否齐全、phase 是否合法:TS 拥有;
- 某些遗留 writeback / spend callback:暂时仍由 Python 调用;
- Turn journal 写入:已经完全由 TS 执行。
因此这不是双实现,而是迁移中的端口:TS owns decision → Python temporarily supplies some effect handlers → TS reduces the result。
迁移前可能出现:Python 实现一份 settlement 规则、Python 另一处又解释一份 journal、测试自己再隐含一份 phase 认知。迁移后:
effect_program.ts
= settlement 语义所有者
turn_journal.ts
= journal 解释所有者
turn_transaction_contract.json
= transaction phase 唯一数据源(Python 和 TS 不再各维护一份 phase 列表)
Python 只负责兼容和调用,不再保留第二份规则解释器。实际效果:
effect_program.py减少约 177 行;- 删除旧约 238 行 Python journal 规则测试;
- 新增 TS 原生测试和 Python→TS 集成测试。
但行数不是价值证明:规则定位更清晰、旧实现被删除、语义能被独立验证,才是价值。
假设新增一个 settlement step(如 "human_gate"),类型系统会推动你检查:step union、receipt、failure、next-action、reducer、handler、测试。在 Python 动态字典模式下,很多遗漏只能等运行到罕见分支才暴露。
另一个长期收益是合同共享:LoopX 控制面最终包含 CLI、本地 App、dashboard、scheduler、multi-agent runtime、extension provider,这些表面天然接近 JSON / TypeScript 世界,TS kernel 可以共享 enum、DTO、state transition、projection schema、error contract。更理想的方式不是手工复制接口,而是从同一 schema 生成或导入类型。
PR #3414 的核心模块 next_action.ts 是一个完整的 TS 语义转换案例:Python 负责文件和集成,TS 负责协议解析、状态转换和纯语义规则。四步走:
请求来自 Python / JSON,入口类型必须是 unknown,不能直接相信它满足某个接口:
function requiredObject(value: unknown, label: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as JsonObject;
}分层是:
unknown
↓ runtime validation
JsonObject
↓ field validation
TodoNextActionRequest
TypeScript 类型只在编译期存在;JSON 运行时不会自动遵守接口,所以每个边界都要显式验证。
请求不是“一个充满可选字段的大接口”,而是两个明确分支:
export type TodoNextActionRequest =
| {
operation: "bind";
lines: readonly string[];
todo_id: string;
}
| {
operation: "settle_completion";
lines: readonly string[];
todo_id: string;
agent_todos: readonly TodoNextActionSnapshot[];
materialized_todo_ids: readonly string[];
};operation 是判别字段,判断后 TS 自动收窄:
if (request.operation === "bind") {
// 这里 request 一定有 todo_id 和 lines
} else {
// 这里 request 一定有 agent_todos 和 materialized_todo_ids
}对比不安全写法:一个大接口 + 大量可选字段(operation: string; agent_todos?: Todo[]; ...),会允许大量非法状态,到处需要 if (!request.agent_todos)。
匹配旧 Next Action 的优先级:
- 正确 schema 的 typed binding(Markdown 注释里的
todo_id外键); - 没有任何 binding 时,允许一次 legacy exact-text 迁移(唯一可见条目且文本与完成的 Todo 相同);
- 其他情况全部
route_unmatched,不修改。
const boundTodoId =
matches.length === 1 &&
matches[0].schema === NEXT_ACTION_BINDING_SCHEMA
? matches[0].todoId
: null;随后:boundTodoId === completed.todo_id → typed_todo_binding;无 binding 且唯一可见条目文本等于已完成 Todo → legacy_exact_text;否则 routeUnmatched。即使某个 Agent Todo 完成,也不能推断 Owner 手写的 “发布路线” 属于它。
不确定归属时,不自动改写用户状态。
Python public API
↓
Python snapshot / file integration
↓
TypeScript semantic transition
↓
typed result
↓
Python state writeback
Python 只做:文件、CLI、锁、状态写回、兼容入口;TS 只做:协议解析、状态转换、纯语义规则。效果 runtime 的 fingerprint 列表会包含 todos/next_action.ts,新源码进 wheel 后不会误连旧 bundle。
| 技巧 | PR 中的用途 |
|---|---|
unknown |
把 JSON / 跨语言输入视为不可信 |
| runtime type guard | 进入核心逻辑前验证字段 |
| discriminated union | 用 operation 区分 bind / settle_completion |
typeof CONSTANT |
让 schema version 成为字面量类型 |
readonly |
表达“函数不应修改输入” |
| copy-on-write | const updated = [...lines],返回新数组,不污染原值 |
Set |
O(1) 判断状态和控制任务类别 |
Extract<Union, {...}> |
从 union 中取出指定操作分支 |
?? / ?. |
清楚表达 nullish fallback |
Map<string, Handler> |
effect runtime 的 handler 注册表 |
| schema version | Python 与 TS 之间的协议演进边界 |
| 纯函数式 transition | 相同输入得到相同结果,便于测试和推理 |
Partial<T> |
测试 helper 中只覆盖需要变化的字段 |
测试 helper 示例:
function todo(
todoId: string,
overrides: Partial<TodoNextActionSnapshot> = {},
): TodoNextActionSnapshot {
return {
todo_id: todoId,
status: "open",
task_class: "advancement_task",
// ...默认值
...overrides,
};
}Partial<T> 的意思是:构造测试对象时所有字段都可以暂时省略,最后通过默认值补齐,比每个测试重复写整个 Todo 对象更清晰。
- Markdown 解析仍是兼容层:只在
## Next Action下存在唯一明确条目时才自动迁移,安全但说明未来应让结构化 Todo 成为主路径; task_class仍是string | null:未来可建模成字面量 union("advancement_task" | "continuous_monitor" | ...),获得编译期约束;- 优先级仍从文本
[P0]–[P4]解析:兼容现有 Markdown 的实用方案,但业务语义藏在字符串里,长期应来自结构化 metadata; - Python / TS 之间仍有两套字段规范化逻辑:当前靠 schema version 和双端验证保持安全,更进一步可抽成 JSON Schema 减少字段规则重复。
核心思想:把“会失败的 I/O”和“纯状态规则”分开。
shell(命令式外壳) core(纯函数核心)
文件锁、网络 I/O、持久化 状态校验、receipt 绑定、
环境变量、进程、平台 API 结算状态、字段语义
- shell 拥有副作用和失败;core 是纯函数,相同输入得到相同结果,可以独立单测;
- shell 把 writeback、spend 这类动作作为 callback 传给 core,由 core 决定顺序;
- 收益:外部世界可以失败,但状态规则只集中在一个核心,避免多处在各处维护自己的状态机。
这是 RPC、effect runtime、外部集成里最值得复制的分层:外层随便换,语义核心不漂移。
const handlers = new Map<string, EffectRuntimeHandler>([
["capability.validate_result", validateResult],
["capability.validate_settlement_callback", validateSettlementCallback],
]);把协议名和实现解耦:
- 新增 handler 不需要改一大串
if / else,注册一行即可; - 调用方只依赖稳定的协议名,不依赖具体实现;
- 所有协议入口统一收口到同一个 runtime / dispatcher,方便统一校验、日志和审计。
外部输入除了类型验证,还要做资源保护:数组长度、字符串大小、payload 字节数都要设上限。否则一个“形状合法”的超大 JSON 也能拖垮运行时(呼应“unknown 不保证数据量”)。
边界校验同时回答两个问题:形状对不对、规模是否可接受。
真实场景:TS 已经把 journal 写入磁盘,但还没来得及给 Python 回应,进程崩溃——Python 无法知道写入到底有没有发生,直接重试可能重复产生副作用。
解决办法是为每个 effect 建立稳定 effect_id:
goal_id : agent_id : todo/replan binding : turn_instance_id
写 journal 前检查已有文件:
- 没有文件:正常写;
- 已有相同
effect_id:同一 effect 的安全重放; - 已有不同
effect_id:拒绝覆盖。
合同是:
same effect_id + same typed effect → retry safe
different effect_id → fail closed
Python runtime 复用相同 request identity,并且只对声明为 retry_safe 的调用重试。测试覆盖:常驻 runtime 复用、重启后同 effect 可重放、不同 effect 不得覆盖、runtime 意外退出后自动恢复。
这类“副作用发生了,但 ACK 丢了”的问题,是长程 Agent control plane 必须认真处理的,不是普通 CRUD 的边角问题。
完整链路可以再拉长一层:
effect_id → invocation_id → provider idempotency_key → receipt → writeback / spend
同一个 effect 只允许对应一次真实调用:进程崩溃后不换 invocation 重发,而是读 journal、用同一个 key reconcile,最终只允许一个 receipt 进入结算。
RPC 请求被限制为 2 MiB。Python 在连接前拒绝超大请求;TS server 也独立限制:
if (Buffer.byteLength(raw, "utf8") > MAX_REQUEST_BYTES) {
socket.destroy();
return;
}return 很关键:连接销毁后不能继续 parse 或 dispatch。行为测试证明:oversized request 没有进入 handler、runtime 没有因此重启、后续正常请求继续使用相同 PID。
- 冷启动:Python 计算源码 fingerprint → 查找 runtime info → 不存在则拿 startup lock → 启动 Node → 注册 typed handlers → 写入 0600 runtime info;
- 热调用:后续请求直接复用同一个 Node 进程,不需要每次重启;
- 升级:fingerprint 覆盖所有相关 TS/JSON 源码,升级后的 wheel 拥有不同 fingerprint,不会误连旧 runtime;
- 空闲退出:默认空闲 5 分钟后关闭并释放内存。
复用解析结果时,需要分别回答两个问题:
| 问题 | 对应机制 |
|---|---|
| 磁盘内容是否仍与缓存对应? | 将新读到的字节与缓存对应的字节比较,或按约定比较内容摘要 |
| 调用者能否改坏共享结果? | 将解析出的普通 JSON 对象与数组逐层冻结后再共享,见 readonly 与冻结 |
只冻结对象,无法发现磁盘变化;只校验字节,也无法防止调用者通过共享引用修改解析结果。内容匹配后复用已冻结的解析值,可以省去重复解析和逐次深拷贝,但未必省去读盘。
内容匹配仅说明本次读到的内容与缓存一致,不保证磁盘随后不会再变;需要更强的一致性时,还须定义读取快照或版本语义。冻结与内容校验本身也有成本,应计入整体收益。
| 场景 | 结果 |
|---|---|
| Node 冷启动 | 约 163 ms |
| warm ping p50 | 约 0.243 ms |
| warm identity p50 | 约 0.236 ms |
| 两次 RPC 的 bind p50 | 约 0.464 ms |
| crash recovery | 约 137 ms |
| 活跃 runtime RSS | 约 85 MB |
| idle 资源释放 | 默认 5 分钟 |
端到端 deep-doctor 的成对差异:p50 约 -237 ms、p95 约 +169 ms——波动大于桥本身的亚毫秒 warm RPC 成本,当前没有观察到显著端到端性能回退,也不能据此宣称 TS 让 LoopX 更快。
正确结论:
PR-1 以可接受的冷启动和内存成本,换来了长期运行中的低延迟 TS 语义内核;主要收益是可维护性和正确性,而非单次命令提速。
后续两个原则:不要把每个微小表达式都拆成一次 RPC;紧密的 Effect Program 步骤应在 TS 一侧批量解释、reduce 或直接执行。等主 CLI 迁入 TS 后,同进程 import 会消除这层 RPC 成本。
“先把测试全部迁成 TS”看起来风险小,但若生产语义仍归 Python,TS 测试只能隔着接口测 Python 行为,并没有形成新的架构所有权——结果是 Python 实现一套、Python 测试保留、TS 又写一套跨语言测试,仓库更重。
PR-1 采用纵向切片:
先刻画 Python 现状
→ 迁移一个 cohesive semantic owner
→ 切真实生产调用
→ 删除对应 Python 规则
→ TS 原生测试新 owner
→ 跨语言测试只验证边界
先用 characterization 锁定行为,再让测试跟随新的语义所有者一起迁移。
仅有 tsc 通过远远不够:
- 静态类型检查:
npm run typecheck:control-plane,strict: true; - TS 原生单元测试:直接测试 Effect Program 与 Turn journal 的语义 owner,当前 13/13 通过;
- Python characterization parity:以迁移前固定基准构造输入,分别跑旧基线和新实现,当前 Turn journal 10/10 精确一致——回答“这次迁移是否偷偷改变了原有语义”,不宣布旧行为永远正确;
- Python→TS runtime 集成测试:覆盖 runtime 复用、restart/replay、cross-effect overwrite、crash recovery、idle shutdown、oversized request,当前 5/5 通过;
- wheel/sdist 安装测试:证明
.ts文件真的进入 wheel / sdist、全新环境能启动 Node runtime、deep doctor 能验证真实语义而非只检查文件存在; - 性能与故障测试:cold start、warm latency、memory、idle exit、crash recovery、端到端 overhead。
适合优先迁移的模块通常具备:大量 typed state、明确状态转移、非法状态较多、需要 replay / idempotency、会被 CLI / App / dashboard 共同消费、当前规则散布在多个 Python 文件、能形成 cohesive vertical slice。例如 todo completion / continuation 状态机、quota should-run 决策、replan obligation lifecycle、scheduler projection / ack、typed event reduction、goal authority / handoff contract。
不适合为了迁移而迁移的:很薄的 shell / OS glue、稳定且只在 Python 调用的辅助脚本、仍强依赖 Python-only SDK 的 host adapter、没有明确语义所有权的小工具函数。
判断标准不是“这个文件能不能翻译成 TS”,而是:
把它迁到 TS 后,能否删除旧规则、收紧状态合同,并让下一次修改更容易定位、验证和回滚?
test("runtime input and validation receipts fail closed", () => {
assert.throws(
() =>
reduceTodoCompletionTransaction(
request({ requested_no_followup: "true" }),
),
/requested_no_followup must be a boolean/,
);
assert.throws(
() =>
reduceTodoCompletionTransaction(
request({
todo: { ...baseTodo, validation_command: "true" },
validation_receipt: {
schema_version: "issue_fix_validation_command_v0",
command_label: "unsafe receipt",
exit_code: 0,
passed: true,
stdout_captured: true,
stderr_captured: false,
local_path_captured: false,
},
}),
),
/stdout_captured must be false/,
);
assert.throws(
() =>
reduceTodoCompletionTransaction(
request({
requested_no_followup: true,
requested_has_successor: true,
}),
),
/cannot record both no_followup and a successor/,
);
});这段语法有三层,从外到内拆:
test("名字", () => {...}):声明一个测试用例。第一个参数是测试名(失败时会在报告中显示),第二个是执行体——assert抛错时test会把这个用例标记为失败。assert.throws(fn, /正则/):断言「调用fn必须抛出异常」,并且抛出的错误消息要匹配第二个参数的正则字面量/.../。() => reduceTodoCompletionTransaction(request({...})):延迟执行的关键。assert.throws接收的是「一个函数」,由它内部去调用并捕获异常。如果不包这层箭头函数、直接写reduceTodoCompletionTransaction(request({...})),函数会当场执行,异常在assert之外抛出——测试用例直接崩掉,assert.throws根本没机会断言。
为什么要断言「错误消息」而不只是「会抛」:/requested_no_followup must be a boolean/ 验证的是「不仅失败,而且以正确的方式失败」——错误消息点出了具体字段和期望类型。如果只写 assert.throws(fn),任何异常都能通过,可能掩盖「字段校验根本没走到、错误从别处冒出来」的假失败。
三段都在测 fail-closed(防御式拒绝),和「边界校验」「让非法状态无法构造」是同一主题的测试形态:
- 第一段:
requested_no_followup传了字符串"true"而不是 boolean → 边界运行时校验拒绝类型错误,而不是悄悄接受(呼应「TS 类型只在编译期存在,外部输入必须 runtime 校验」); - 第二段:构造非法
validation_command(字符串)配一个可疑的validation_receipt,断言这类「command 与 receipt 字段组合」被拒绝,错误消息指向stdout_captured字段——测的是 receipt 内部的交叉约束,而不是单个字段; - 第三段:
no_followup和successor是互斥语义,同时设置必须被拒绝(呼应「判别联合 / 让非法状态无法表达」——类型层面没拦住时,运行时校验兜底)。
其它语法细节:
- 多行函数调用 + 尾逗号:
request({...})跨多行、嵌套调用闭括号对齐,都是纯格式,不影响语义; { ...baseTodo, validation_command: "true" }:对象展开构造「在基础对象上覆盖单个字段」的变体,是测试里构造合法基线的常用手法(和Partial<T>测试 helper 是同一思路)。
(test / assert.throws 来自 Node 内置 node:test 或 Vitest / Jest 等测试框架,写法一致。)
Promise.all 只表达「一起启动、等全部完成」,不表达「两个操作读到同一个旧版本」:
await Promise.all([createA(), createB()]);实际执行顺序可能退化成串行:
A 读取 → A 提交 → B 读取
此时 B 看到的是 A 提交后的新状态,可能直接以「已存在」返回,根本没走到预期的 CAS 分支。于是「断言一胜一冲突」的测试在某种交错下失败、在另一种交错下通过——它测到的是调度运气,而不是 CAS 语义。
要真正验证 CAS,必须把交错做出来:
A 读取旧版本 ─┐
├─ 两者都读完 → 放行提交 → 一胜一冲突
B 读取旧版本 ─┘
做法是在两个关键动作之间插入共享屏障(barrier / latch):两边各自完成读取后停在屏障上,等对方也到达,再一起进入提交。这样「同一版本上的两个写者」就从概率场景变成确定性场景。
可以当检查表用:
Promise.all/allSettled只保证「都在跑、都会等到」,不保证任何读写顺序;- 用
setTimeout、随机延迟「制造并发」得到的是概率覆盖,不是证明; - 并发测试要控制的是关键事件的先后关系(读到旧版本 → 放行 → 提交),而不是「同时开始」。
背景:Proxy 是什么。 Proxy 用一组拦截器(trap)包住目标对象:读属性走 get、写属性走 set、函数调用走 apply……没写的 trap 就是默认行为。TypeScript 标准库给它的声明是(lib.es2015.proxy.d.ts):
new <T extends object>(target: T, handler: ProxyHandler<T>): T;
get?(target: T, p: string | symbol, receiver: any): any;两处值得注意:构造结果类型就是 T,和原对象完全相同;而 get 拦截器的返回值是 any。也就是说,get 里返回什么都能编译通过,编译器照样认为这个对象是 T——「类型检查通过」证明不了转发正确。
为什么直接转发会出问题:this 变了。 Proxy 是与目标不同身份的对象,而方法调用时 this 是接收者:
const proxy = new Proxy(new Map([["k", 1]]), {});
proxy.get("k");
// TypeError: Method Map.prototype.get called on incompatible receiver #<Map>Map 的数据存在目标对象的内部槽([[MapData]])里,proxy 没有这个槽。私有字段同理,MDN 专门记了 no private field forwarding:
class Secret {
#secret = "x";
get secret() { return this.#secret; }
}
const proxy = new Proxy(new Secret(), {});
proxy.secret;
// TypeError: Cannot read private member #secret from an object whose class did not declare it这里炸的是 getter——读属性时 this 一样是 proxy。
修法就是这次测试里的写法:
const value = Reflect.get(target, property); // 不传 receiver,getter 的 this 仍是 target
return typeof value === "function"
? value.bind(target) // 方法绑定回真实对象再返回
: value;Reflect.get(target, property)不传receiver,getter 内部this就是target;若写成Reflect.get(target, property, receiver),或直接target[property]取到方法后不绑定就调用,this又会变回 proxy;bind(target)把方法固定成「以真实对象为this调用」——上面两个 TypeError 场景都能靠它恢复;- 代价:每次取到的都是新的绑定函数(
proxy.method !== proxy.method)。所以它适合有限代理,不是通用透明代理; - 原生对象(Map、Set、Date、DOM 节点等带内部槽的对象)都不能靠「空 handler 的 Proxy」假装透明,这也是 MDN 强调「no-op 转发只对普通对象成立」的原因。
这种代理在测试里做什么。 用 Proxy 包住真实 store,只拦截 commit,其余调用原样转发——既能在「提交成功、响应丢失」这个精确位置注入故障,又保留真实的数据库提交,验证到的是真正的恢复语义,而不是一个被替换掉的假存储。
边界:类型上的 T 是标准库给的假设,约束不了 trap 的实现;要行为等价,靠的是针对该接口的契约测试(见「静态形状 vs 行为合同:conformance suite 证明语义」),而不是 Proxy 本身。
- 类型在运行时不存在:外部 JSON 仍可能传
{"step_kind": 12345},RPC、文件、插件输入必须有 runtime decoder。 as可以绕过类型系统:as unknown as SettlementIdentityInput是受控但真实的逃生通道,应逐步收紧,而不是大量复制。- 类型不证明业务正确:effect 顺序写错、receipt 判定错误、idempotency key 设计错误、crash window 没覆盖、replan 语义不合理,类型全通过也可能发生。
- 类型不会让「检查过」的事实保鲜:
ValidatedX这类标记只能证明构造时校验通过,不能证明使用时仍然成立(TOCTOU)。中间的await、其它请求、其它进程都可能推进状态,因此授权、锁与 epoch 要在真正执行副作用的边界再检查一次,而不是拿到对象时检查一次就一路信任;外部执行器的 fencing 与终态锁是独立职责。 - 字符串分类债务:部分失败类型仍根据 reason 是否包含
"budget"分类;长期应让 callback 返回 typed error kind,而不是从错误文案反推语义。 - Node 引入运行成本:安装要求、冷启动、常驻 RSS、socket / framing、进程恢复、包升级一致性。TS 应优先迁移“值得成为语义内核”的部分,而不是机械迁移所有 Python 文件。
- 标准库的类型是假设,不是行为保证:
Proxy<T>声明为T、get拦截器返回any,代理行为是否真的等价于T只能由测试证明;同类情况还有「类型通过但转发错误」「并发启动但没发生竞争」。
真正让迁移成立的不是 .ts 后缀,而是:
typed model
+ single semantic owner
+ runtime validation
+ explicit effects
+ idempotency
+ characterization parity
+ crash/replay tests
+ artifact validation
+ performance measurement
缺少这些,换成 TS 也可能只是一次昂贵的语法翻译。
第一阶段:只学类型。读 Effect request / turn、Settlement enums、Settlement identity / result、NextAction union。练习:给 failure kind 增加一个值看 tsc 是否提示遗漏;把 SettlementResult 改写为成功 / 失败判别联合;写一个带 never 的穷尽 switch。
第二阶段:理解状态机。读 settlementIdentity、seedCommittedSteps、settlementNextAction、commitStepPayload。思考三个问题:当前哪些 receipt 已经存在?下一步 effect 为什么是它?重放时如何避免重复执行?
第三阶段:理解 runtime。读 Python request bridge、runtime server、handler registry、native journal effect。
第四阶段:运行验证。依次执行 npm ci、npm run typecheck:control-plane、npm run test:control-plane、Python 集成测试、uv build、loopx doctor --deep。
读一段 TS control-plane 代码时,依次回答:
- 这个值来自外部 JSON 还是内部构造?有没有 runtime decoder?
- 有没有
as/any逃生通道?它是不是迁移缝? - 联合类型的判别字段是否穷尽处理?新增 variant 会不会被编译器抓住?
- 非法状态是否可构造(例如成功值和失败同时存在)?
- 副作用有没有稳定 identity?重试是否幂等?不同 identity 是否 fail closed?
- 谁拥有规则语义?Python / TS 是否各有一份?
- 哪些是纯决策、哪些是副作用?边界在哪一层?
await是否丢失?并发是否无界?- 错误分类靠类型还是字符串匹配?
- 这个模块迁到 TS 后能否删除旧规则?
- 未知字段是否被 exact-field 校验拒绝?还是被悄悄透传?
- 外部输入的数组长度 / payload 是否有上限?
- 异步函数有哪些 fulfilled / rejected 通道?异常发生前是否已经跨过副作用边界?哪些异常可恢复,哪些必须继续抛出?
interface → 对象合同(结构类型,不用声明 class)
readonly → 不可在原地修改
泛型 <T> → 同一结构承载不同 value
as const → 从数组得到字面量联合
Record<K,V> → K 为有限键联合时,要求每个键都有 V 类型的值
satisfies → 在构造处检查类型合同,不生成运行时校验
联合类型 → 封闭的形态集合
判别联合 → 按判别字段自动收窄;TS 4.6+ 支持 const 解构后关联收窄
类型谓词 → TS 5.5 起部分检查可自动推断,filter 据此收窄元素类型
asserts → 断言函数正常返回后收窄;实际检查由实现负责
never → 穷尽检查(新增状态漏处理会编译失败)
unknown → 使用前必须证明它是什么
any → 放弃检查
Promise<T> → 只约束未来成功的值;拒绝无异常类型,也无处理义务
NoInfer<S> → 禁止某个参数参与泛型推断,收紧推断来源
品牌类型 → 给 string 附加用途身份,防止 ID / digest / revision 混用
Pick<T,K> → 选取字段,收窄静态依赖
Partial<T> → 字段可选,常用于受限 options
ReturnType → 从函数派生返回类型
Awaited<T> → 递归解包 await 结果类型
Extract → 按可赋值条件筛选联合成员
一句话:TS 对 LoopX 的核心价值,是把“状态集合明确、分支可穷尽、字段不漂移、改协议时所有消费者一起报错、副作用与纯决策分离、replay 与 idempotency 成为一等类型和测试合同”变成可编译检查的约束。
补充:=== 是默认相等运算符(值 + 类型都相同,且是类型收窄触发器);== 会隐式转换,只保留 == null 惯用法。
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } }