Effect Schema 类型健全性加固:StructWithRest 固定字段与 rest 索引签名的类型级校验
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
本篇技术解读聚焦于 Effect 生态effect与@effect/ai-openai两个包的一个 patch 级变更:Schema.StructWithRest现在会在类型层面校验固定字段(fixed fields)与 rest 索引签名(index signatures)的兼容性,从源头上避免构造出 decoded、encoded、make 三种形状互不兼容的 schema。文章将结合 Schema.ts 与 SchemaAST.ts 的源码实现,拆解ValidateRecords等类型工具的判定逻辑,并说明 OpenAI 生成 schema 是如何在更严格校验下继续接受任意附加字段的。读完本文,你将理解 Effect 对 "struct + record" 组合的类型健全性(soundness)设计,并掌握在自己项目中做同类静态检查的具体方法。
一、变更背景:StructWithRest 是什么,为什么需要类型级校验
Schema.StructWithRest是 Effect Schema 中用于"在固定结构上叠加若干索引签名"的构造器:第一个参数是一个 struct schema(固定字段),第二个参数是 record schema 数组(rest 索引签名)。其返回 schema 的解码类型是 struct 与所有 record 的交集。仓库文档注释中的最小示例(见 Schema.ts):
import { Schema } from "effect" const schema = Schema.StructWithRest( Schema.Struct({ id: Schema.Number }), [Schema.Record(Schema.String, Schema.Number)] ) // { readonly id: number, readonly [x: string]: number } type T = typeof schema.Type这种组合天然存在一个 TypeScript 陷阱:索引签名也会作用到固定键上。当id: number恰好满足[x: string]: number时交集是健全的;可一旦某个固定字段的类型在某个"形状"上与索引签名值类型不兼容(例如固定字段是NumberFromString——解码侧是number、编码侧是string),得到的交集就会变得过窄甚至退化为never,使得 schema 类型不再可信。这正是本次变更要消除的 unsoundness 来源。
二、变更内容解析(changeset 全文)
本次变更对应的 changeset 文件 .changeset/pre/fix-structwithrest-index-signatures.md 完整内容如下:
Validate
Schema.StructWithRestfixed fields against rest index signatures at the type level so schemas cannot be constructed with incompatible decoded, encoded, or make shapes. This keepsStructWithResttypes sound and updates the generated OpenAI conversation-items request schema to keep accepting arbitrary additional fields under the stricter validation.
可拆解为三个要点:
- 类型级校验:在类型层面校验
StructWithRest的固定字段与 rest 索引签名是否兼容,覆盖 decoded、encoded、make 三类形状; - 健全性目标:防止构造出交集过窄/不健全的 schema,保持
StructWithRest类型可信; - 生成代码适配:同步更新生成的 OpenAI conversation-items 请求 schema,使其在更严格校验下仍能接受任意附加字段。
该变更同时标记effect与@effect/ai-openai为patch级别,说明这是一次向后兼容的修复(新增类型能力、微调生成 schema),不破坏既有 API。此条记录同样被收录进 packages/effect/CHANGELOG.md 的历史条目中。
三、类型级校验机制源码拆解
本次变更的核心是新增/完善了Schema.StructWithRest命名空间下的一组类型工具(位于 Schema.ts)。
3.1 不兼容键的判定:IncompatibleKeys
type IncompatibleKeys<A, B, OK extends (keyof A & keyof B) = Extract<keyof A, keyof B>> = { [K in OK]: Required<Pick<A, K>>[K] extends B[K] ? never : K }[OK]对固定字段(A)与某条 record(B)的公共键逐个比较:若固定字段在该键上的类型不能被索引签名值类型B[K]吸收(extends不成立),该键即为不兼容键;反之则归为never,不影响结果。这里用Required<Pick<...>>是为了消除可选键带来的干扰。
3.2 四个形状侧面的交叉检查:IncompatibleSideKeys 与 IncompatibleRecords
type IncompatibleSideKeys< S extends Objects, Records extends StructWithRest.Records, Side extends "Type" | "Encoded" | "Iso" | "~type.make" > = { [I in keyof Records]: Records[I][Side] extends object ? IncompatibleKeys<S[Side], Records[I][Side]> : never }[number] type IncompatibleRecords<S extends Objects, Records extends StructWithRest.Records> = | IncompatibleSideKeys<S, Records, "Type"> | IncompatibleSideKeys<S, Records, "Encoded"> | IncompatibleSideKeys<S, Records, "Iso"> | IncompatibleSideKeys<S, Records, "~type.make">IncompatibleRecords并非只检查解码后的Type一个侧面,而是对四个侧面分别做固定字段与索引签名的兼容性判定:
| 侧面 | 含义 | 对应关系 |
|---|---|---|
Type | 解码后的结构类型 | changeset 中的 decoded shape |
Encoded | 编码后的结构类型 | changeset 中的 encoded shape |
Iso | 等价值表示(isomorphism)类型 | 4.0.0 新增的等价表示侧面 |
~type.make | 构造值时接受的输入类型 | changeset 中的 make shape |
这一点非常关键:固定字段在解码/编码/构造输入三个形状上的类型可能完全不同(如NumberFromString),只有四个侧面全部兼容,schema 才真正健全。IncompatibleSideKeys通过映射[I in keyof Records]对每条 rest record 分别判定,并把所有结果并集起来。
3.3 对外暴露的检查入口:ValidateRecords
type ValidateRecords<S extends Objects, Records extends StructWithRest.Records> = [IncompatibleRecords<S, Records>] extends [never] ? true : { "incompatible index signatures": IncompatibleRecords<S, Records> }ValidateRecords是面向使用者的检查入口:当所有侧面都没有不兼容键时,判定为字面量true;否则返回一个诊断对象{ "incompatible index signatures": ... },其中列出具体的不兼容键。用[T] extends [never]而非T extends never,是为了避免在联合类型上产生分布式条件类型,确保能精确探测空联合。
3.4 运行时实现:SchemaAST.structWithRest
类型层之外,运行时的合并逻辑位于 SchemaAST.ts:
export function structWithRest(ast: Objects, records: ReadonlyArray<Objects>): Objects { if (ast.encoding || records.some((r) => r.encoding)) { throw new Error("StructWithRest does not support encodings") } let propertySignatures = ast.propertySignatures let indexSignatures = ast.indexSignatures let checks = ast.checks for (const record of records) { propertySignatures = propertySignatures.concat(record.propertySignatures) indexSignatures = indexSignatures.concat(record.indexSignatures) checks = combineChecks(checks, record.checks) } return new Objects(propertySignatures, indexSignatures, undefined, checks) }可见运行时只是把固定字段的propertySignatures、rest record 的indexSignatures与校验器checks合并成一个新的ObjectsAST,不设防地接受任意组合——因此兼容性把关完全依赖类型层。这也是为什么本次变更把校验放在类型层而非运行时:它与既有StructWithRest的调用形态(构造器不拒绝、合并由structWithRest完成)是正交的。另有配套的历史修复可参考:此前的 changelog 已记录 "FixStructWithRestso index signatures do not re-parse or overwrite fixed properties",以及Arbitrary.schema在生成/收缩对象属性时尊重包括StructWithRest固定字段在内的索引签名(见 packages/effect/CHANGELOG.md),共同构成该功能健全性的一整套保障。
四、实战:如何在项目中执行兼容性检查
官方文档注释(Schema.ts)给出了完整的用法示例。检查通过的情况:
import { Schema } from "effect" const user = Schema.Struct({ id: Schema.String }) const stringExtras = [Schema.Record(Schema.String, Schema.String)] as const type UserCheck = Schema.StructWithRest.ValidateRecords<typeof user, typeof stringExtras> const userCheck: UserCheck = true // ✅ id: string 满足 [x: string]: string void userCheck检查不通过的情况——固定字段count是NumberFromString,其 decoded 形状是number,与Record(String, String)的string值类型冲突,交集会被迫收窄:
const counter = Schema.Struct({ count: Schema.NumberFromString }) type CounterCheck = Schema.StructWithRest.ValidateRecords<typeof counter, typeof stringExtras> // ^? { "incompatible index signatures": "count" }此时CounterCheck会展开为诊断对象{ "incompatible index signatures": "count" },直接在 IDE 中给出可读的报错信息,提示开发者调整固定字段类型或索引签名值类型。
需要注意的是(参见 Schema.ts 中的 Gotchas 说明):由于 TypeScript 索引签名同样作用于固定键,StructWithRest构造器本身在调用点并不拒绝不兼容组合,显式的类型级兼容性检查需要借助StructWithRest.ValidateRecords。建议在定义带 rest 的 schema 时,配套声明一个ValidateRecords类型的类型别名或赋值语句,把"固定字段 × 索引签名"的兼容性纳入编译期契约。
五、OpenAI 生成 schema 的适配实践
本次变更同时将@effect/ai-openai标记为 patch,其落点就是生成的 OpenAI conversation-items 请求 schema。在 packages/ai/openai/src/Generated.ts 中,CreateConversationItemsRequestJson被实现为:
export type CreateConversationItemsRequestJson = { readonly "items": ReadonlyArray<InputItem> readonly [x: string]: unknown } export const CreateConversationItemsRequestJson = Schema.StructWithRest( Schema.Struct({ "items": Schema.Array(InputItem).annotate({ "description": "The items to add to the conversation. You may add up to 20 items at a time.\n" }).check(Schema.isMaxLength(20)) }), [Schema.Record(Schema.String, Schema.Unknown)] )这里 rest 索引签名的值类型被声明为Schema.Unknown,与固定字段items: ReadonlyArray<InputItem>在Type、Encoded、Iso、~type.make四个侧面上全部兼容,因此在更严格的类型级校验下依然成立——这正是 changeset 中"keep accepting arbitrary additional fields under the stricter validation"的实现方式:用Unknown值类型的 record 表达"接受任意附加字段",而非依赖不健全的窄交集。
这一做法对调用方有直接启示:当你的业务数据需要"固定字段 + 任意扩展字段"时,rest record 的值类型应显式放宽(Unknown或合适的宽类型),让固定字段天然可被索引签名吸收,从而既满足类型健全性,又不丢失扩展能力。在同一文件的Model、OpenAIFile、ListMessagesResponse等众多 schema 中同样大量使用StructWithRest(见 Generated.ts),它们共同演示了生成型 schema 在严格校验下的标准写法。
六、总结
本次 patch 级变更是 Effect Schema 在类型健全性上的一次精准加固:
- 新增能力:
StructWithRest.ValidateRecords(以及背后的IncompatibleKeys/IncompatibleSideKeys/IncompatibleRecords)在类型层面对固定字段与 rest 索引签名做四侧面(Type/Encoded/Iso/~type.make)兼容性检查,不兼容时产出可读诊断对象; - 行为不变:运行时合并逻辑(
SchemaAST.structWithRest)与构造器调用形态保持不变,检查以显式类型工具的形式供使用者纳入编译期契约; - 生态同步:
@effect/ai-openai的 OpenAI conversation-items 请求 schema 改用Record(String, Unknown)作为 rest,在严格校验下继续接受任意附加字段。
对 Effect 用户而言,这条变更的实践价值在于:凡是使用StructWithRest组合"固定字段 + 动态扩展"的场景,都应在定义处辅以ValidateRecords静态检查,避免在解码/编码/构造三个形状上埋下类型不健全的隐患。
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考