Effect Predicate 模块实战:运行时类型守卫与组合式校验指南
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
导读
本文基于 effect-smol 仓库的 AI 教程文档 10_predicate/index.md 及其配套示例,系统讲解 Effect 标准库中Predicate模块的用法:什么是运行时类型守卫(Predicate / Refinement)、为什么团队规范要求"永远不要自己手写isRecord、isString之类的辅助函数"、以及如何用and/or/not/compose等 API 把零散检查组合成精确的校验逻辑。读完本文,你将掌握一套复用、组合、类型安全的运行时校验方案,并能在处理unknown数据(如JSON.parse、外部 API 响应、用户输入)时写出简洁可靠的守卫代码。
一、Predicate 模块是什么
Predicate模块位于 packages/effect/src/Predicate.ts(约 1880 行),其模块级注释定义得很清楚:
Defines runtime checks for values. A
Predicate<A>returnstrueorfalsefor anA. ARefinement<A, B>is a predicate that also narrows the TypeScript type when it succeeds.
也就是说,该模块提供两类核心类型:
1.1Predicate<A>:只判断、不收窄
export interface Predicate<in A> { (a: A): boolean }源码见 Predicate.ts。一个Predicate<A>是一个纯函数:对给定的A返回true或false,自身不抛异常,也不会在类型层面收窄输入。它适合用作可复用的布尔判断,特别是要传给数组filter、迭代器或与其他谓词组合的场景。
1.2Refinement<A, B>:判断且收窄类型
export interface Refinement<in A, out B extends A> { (a: A): a is B }源码见 Predicate.ts。Refinement是"带有类型收窄的谓词":当它返回true时,TypeScript 会把输入从A收窄为B。这是处理unknown值的核心武器——搭配if或filter使用,编译器就能在分支内安全访问具体字段。
示例(来自源码 JSDoc):
import { Predicate } from "effect" const isString: Predicate.Refinement<unknown, string> = (u): u is string => typeof u === "string" const data: unknown = "hello" if (isString(data)) { data.toUpperCase() // => "HELLO",类型已收窄为 string }二、核心规范:不要自己写isRecord/isString
原文档用一句近乎强制的规范点明了本模块的定位:
NEVERwrite your own helper functions like
isRecordorisString, instead use the helpers from thePredicatemodule.
翻译过来就是:永远不要自己手写isRecord、isString这类辅助函数,直接使用Predicate模块提供的守卫。原因很实际:
- 正确性有保障:
Predicate内置守卫经过完整测试覆盖,例如isObject要同时排除null和数组,这些边界条件极易在手写时遗漏; - 类型安全:内置守卫大多以
Refinement形式导出,能正确收窄类型,而随手写的typeof x === "string"未必带类型谓词标注; - 可组合性:模块内守卫的类型签名(
Refinement<unknown, T>)是为组合 API 量身设计的,自写函数类型各异,难以接入and/or/compose; - 避免重复代码:多模块项目里每个人各写一份
isObject,语义可能不一致,维护成本高。
需要说明的是:原文档举例的isRecord在当前仓库源码中并无同名导出,当前版本里"非null、非数组的对象"检查由 isObject 承担,实现为:
export function isObject(input: unknown): input is { [x: PropertyKey]: unknown } { return typeof input === "object" && input !== null && !Array.isArray(input) }注意这一行同时完成了三件事:排除typeof为"object"的null、排除数组、并收窄到可索引对象类型。类似的还有isObjectOrArray(L1010)和isObjectKeyword(L1105)。文档以isRecord/isString为例要传达的精神是一致的:运行时检查交给标准库,不造轮子。
三、内置守卫一览:把"每个值的检查"都交给标准库
Predicate模块为 JavaScript 常见值提供了丰富的现成守卫(category: guards/predicates),全部定义于 Predicate.ts。常用列表如下:
| 守卫 | 判断依据 | 源码位置 |
|---|---|---|
isString | typeof input === "string" | L556 |
isNumber | typeof input === "number" | L589 |
isBoolean | typeof input === "boolean" | L622 |
isBigInt | typeof input === "bigint" | L654 |
isSymbol | typeof input === "symbol" | L686 |
isPropertyKey | string \| number \| symbol | L721 |
isFunction | typeof input === "function" | L753 |
isUndefined | input === undefined | L784 |
isNull | input === null | L844 |
isNullish | null \| undefined | L904 |
isNotNullish | 非null非undefined | L935 |
isNever | 恒为false | L958 |
isObject | 非null、非数组的object | L1042 |
hasProperty | 对象上存在某属性 | L1140 |
isTagged | _tag字段严格等于给定值 | L1175 |
isError | instanceof Error | L1208 |
isUint8Array | instanceof Uint8Array | L1238 |
isDate | instanceof Date | L1267 |
isIterable | 具有Symbol.iterator | L1297 |
isPromise | instanceof Promise | L1326 |
isPromiseLike | 可 then 化对象 | L1356 |
isRegExp | instanceof RegExp | L1385 |
isSet/isMap | instanceof Set/instanceof Map | L490 / L522 |
isTruthy | !!input | L458 |
isTupleOf(n) | 数组长度恰好为n | L393 |
isTupleOfAtLeast(n) | 数组长度至少为n | L426 |
3.1 面向"标签联合"的两个关键守卫
hasProperty(property):基于isObjectKeyword(self) && (property in self)实现(L1140-L1147),返回Refinement<unknown, { [K in P]: unknown }>,用于确认某个属性存在;isTagged(tag):内部调用hasProperty(self, "_tag") && self["_tag"] === tag(L1175-L1181),用于判别带_tag字段的可辨识联合:
import { Predicate } from "effect" const isOk = Predicate.isTagged("Ok") isOk({ _tag: "Ok", value: 1 }) // => true isOk({ _tag: "Err", error: "boom" }) // => false这类守卫在解析 Effect 的Either、Option、Exit等数据类型时非常常用。
四、组合 API:and/or/not/compose
原文档明确指出谓词可以通过Predicate.and、Predicate.or、Predicate.not、Predicate.compose进行组合。这四个 API 的实现都集中在 Predicate.ts 的组合子(combinators)部分,且全部支持柯里化(curried)与管道(pipe)两种调用方式。
4.1and:全部满足才为真
export const and: { /* ... 多个重载 ... */ } = dual( 2, <A>(self: Predicate<A>, that: Predicate<A>): Predicate<A> => (a) => self(a) && that(a) )源码见 L1632-L1637。要点:
- 语义为逻辑与,
self(a) && that(a),在第一个返回false的谓词处短路; - 当两个参数都是
Refinement时,返回类型会收窄为两者的交集B & C; - 测试覆盖见 Predicate.test.ts:
const isPositive: Predicate.Predicate<number> = (n) => n > 0 const isLessThan2: Predicate.Predicate<number> = (n) => n < 2 const p = pipe(isPositive, Predicate.and(isLessThan2)) p(1) // => true p(-1) // => false(不满足 isPositive) p(3) // => false(不满足 isLessThan2)4.2or:任一满足即为真
export const or = dual( 2, <A>(self: Predicate<A>, that: Predicate<A>): Predicate<A> => (a) => self(a) || that(a) )源码见 L1586-L1591。要点:
- 语义为逻辑或,
self(a) || that(a),在第一个返回true的谓词处短路; - 当两个参数都是
Refinement时,返回类型收窄为两者的并集B | C; - 测试见 Predicate.test.ts:
const p = pipe(isPositive, Predicate.or(isNegative)) p(-1) // => true p(1) // => true p(0) // => false4.3not:取反
export function not<A>(self: Predicate<A>): Predicate<A> { return (a) => !self(a) }源码见 L1554-L1556。实现就是简单的布尔翻转,测试见 Predicate.test.ts:
const isNotString = Predicate.not(Predicate.isString) isNotString(1) // => true4.4compose:串联两个 Refinement,逐级收窄
export const compose = dual( 2, <A, B extends A, C extends B>(ab: Refinement<A, B>, bc: Refinement<B, C>): Refinement<A, C> => (a): a is C => ab(a) && bc(a) )源码见 L1420-L1429。这是四个 API 中类型能力最强的一个:它把Refinement<A, B>与Refinement<B, C>(或作用于B的普通Predicate)串联成Refinement<A, C>——第一次收窄的输出恰好是第二次检查的输入,最终完成从A到C的两级收窄。测试见 Predicate.test.ts:
const isString: Predicate.Refinement<unknown, string> = (u): u is string => typeof u === "string" const isNonEmptyString: Predicate.Refinement<string, NonEmptyString> = (s): s is NonEmptyString => s.length > 0 const refinement = pipe(isString, Predicate.compose(isNonEmptyString)) refinement("a") // => true refinement("") // => false refinement(null) // => falsecompose与and的区别值得注意:and适用于同一输入A上的多条件叠加;compose则用于类型逐级收窄的链式流程(如"先确认是 string,再确认非空")。二者一横一纵,覆盖了谓词组合的两个维度。
五、结构化组合:Struct/Tuple/mapInput
除布尔组合子外,Predicate还提供把多个谓词"提升"到结构化数据上的工具,这些在原文档没有展开,但对实战极其重要。
5.1Predicate.Struct:按字段名逐字段校验对象
源码见 L1508-L1525,实现要点:
- 遍历
Object.keys(fields),对每个键依次应用对应谓词,遇到第一个失败即返回false(短路); - 只检查指定的键,忽略额外的键;
- 任一字段谓词是
Refinement时,整体返回Refinement并对字段做类型收窄。
import { Predicate } from "effect" const userCheck = Predicate.Struct({ id: Predicate.isNumber, name: Predicate.isString }) userCheck({ id: 1, name: "Ada" }) // => true测试中的短路验证(Predicate.test.ts):当第一个字段谓词返回false时,第二个谓词根本不会被调用(calls === 1),并且{ a: 1, b: "ok", extra: true }这类带额外键的对象同样可以通过。
5.2Predicate.Tuple:按位置逐元素校验元组
源码见 L1459-L1475,与Struct对称,逐个下标应用谓词、同样短路:
const tupleCheck = Predicate.Tuple([(n: number) => n > 0, Predicate.isString]) tupleCheck([1, "ok"]) // => true tupleCheck([-1, "ok"]) // => false测试见 Predicate.test.ts。
5.3mapInput:先映射、再判断
源码见 L360-L363,mapInput(self, f)返回一个新谓词,等价于(b) => self(f(b))——先用f把B投影为A,再应用原谓词。典型用途是"检查字符串长度"这类需求:
const isLongerThan2 = Predicate.mapInput((s: string) => s.length)((n: number) => n > 2) isLongerThan2("hello") // => true测试见 Predicate.test.ts,同时验证了柯里化与管道两种调用方式。
六、实战:从unknown到安全访问(基于官方示例展开)
教程配套代码 10_predicate/01_basics.ts 展示了最基础的用法——对unknown值逐层守卫:
/** * @title Using the Predicate module */ import { Predicate } from "effect" const thing: unknown = { a: 1 } if (Predicate.isObject(thing)) { if (Predicate.isNumber(thing.a)) { console.log("number", thing.a) } }把它升级为真实场景(解析不可信的 JSON 数据并安全读取嵌套字段),结合本文前面所有知识点:
import { Predicate, pipe } from "effect" // 1. 把 JSON.parse 的结果先认定为 unknown,强制显式校验 const raw: unknown = JSON.parse(`{"user":{"id":1,"name":"Ada","tags":["ts"]}}`) // 2. 用组合子描述"形状" const hasUser = Predicate.hasProperty("user") const userIsObject = Predicate.compose(hasUser, Predicate.isObject) const isExpectedUser = pipe( userIsObject, Predicate.and(Predicate.Struct({ id: Predicate.isNumber, name: Predicate.isString })) ) // 3. 守卫通过后,类型自动收窄,可以安全访问 if (isExpectedUser(raw)) { console.log(raw.user.id, raw.user.name) // 类型安全 }同样的模式可以套在Either结果、外部 API 响应、环境变量解析等一切"运行时才知道真实形状"的数据上。从源码结构看,这正是 Effect 生态中众多模块(如Schema、Config、http客户端)底层校验的基础设施之一,教程文档也在 ai-docs/src/index.md 中强调:查找 Effect 相关知识时应以本仓库文档与源码为准。
七、组合子的完整能力矩阵
除了and/or/not/compose四个文档点名的组合子,模块还提供更多逻辑组合,全部有测试覆盖(Predicate.test.ts):
| API | 语义 | 短路行为 | 测试位置 |
|---|---|---|---|
and | 逻辑与,收窄为交集 | 首个false即停 | L136 |
or | 逻辑或,收窄为并集 | 首个true即停 | L126 |
not | 逻辑非 | 无 | L116 |
compose | 两级 Refinement 串联收窄 | 任一级失败即停 | L18 |
xor | 恰好一个为真 | 无 | L146 |
eqv | 两个谓词结果一致 | 无 | L155 |
implies | 蕴含关系 | 无 | L164 |
nor/nand | 或非 / 与非 | 无 | L173 / L182 |
every/some | 数组全满足 / 任一满足 | 短路 | L191 / L207 |
例如xor(L1667-L1670)实现为self(a) !== that(a),即两个谓词结果不同才为真。every/some则把谓词提升到数组层面,直接配合Array.prototype风格的语义使用。
八、实践建议与注意事项
- 从
unknown出发,显式收窄:JSON.parse、fetch响应、process.env取值的类型都应先落为unknown,再用Predicate守卫收窄,避免"类型断言掩盖运行时错误"; - 优先复用内置守卫:牢记原文档的"NEVER"规范——检查对象用
isObject、检查字符串用isString、检查可辨识联合用isTagged,不要重复造轮子; - 组合优于嵌套:多层
if可以重构为Predicate.and/Struct/compose的组合表达式,让校验逻辑成为可命名、可复用、可单测的独立值; - 利用类型收窄:组合时优先选用带
Refinement签名的守卫(内置守卫基本都满足),这样and得到交集、or得到并集、compose得到逐级收窄,编译器会替你把关后续访问; - 注意短路语义:
and、Struct、Tuple都在首个失败处短路,or在首个成功处短路。若你的谓词有副作用(不建议),务必理解求值顺序; - 关注测试与文档:完整的组合子测试在 packages/effect/test/Predicate.test.ts,源码内每个 API 都带有可运行的 JSDoc 示例,是学习与排查行为的最佳参考。
小结
Effect 的Predicate模块把"运行时类型守卫"从散落的typeof检查提升为一套可复用、可组合、类型安全的标准设施:内置数十个现成守卫覆盖 JS 常见值与标签联合,and/or/not/compose提供布尔级组合,Struct/Tuple/mapInput提供结构级提升。遵循原文档"不要自写isRecord/isString"的规范,配合本文的实战模式,你可以在任何需要处理unknown数据的代码路径上写出比手写if链更可靠、更易维护的校验逻辑。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考