From 9c573d39a41e2f011fa131bc025b07b45892d592 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 07:42:07 +0200 Subject: [PATCH 1/9] Fix Schema encoded-side checks when flipping and projecting ASTs --- .changeset/fix-schema-encoding-checks.md | 5 + .changeset/fix-schema-flip-checks.md | 5 + packages/effect/src/SchemaAST.ts | 56 +++++++-- packages/effect/src/SchemaParser.ts | 22 +++- packages/effect/test/schema/Schema.test.ts | 134 +++++++++++++++++++++ 5 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-schema-encoding-checks.md create mode 100644 .changeset/fix-schema-flip-checks.md diff --git a/.changeset/fix-schema-encoding-checks.md b/.changeset/fix-schema-encoding-checks.md new file mode 100644 index 0000000000..0bd86777a8 --- /dev/null +++ b/.changeset/fix-schema-encoding-checks.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix Schema encoded-side checks when flipping and projecting ASTs. diff --git a/.changeset/fix-schema-flip-checks.md b/.changeset/fix-schema-flip-checks.md new file mode 100644 index 0000000000..be9c689b76 --- /dev/null +++ b/.changeset/fix-schema-flip-checks.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix Schema flip to preserve check side when structural AST nodes are rebuilt without child changes. diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 7b6e8e15f0..1d2cc19181 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -680,7 +680,7 @@ export class Declaration extends Base { } private _rebuild(recur: (ast: AST) => AST, checks: Checks | undefined, encodingChecks: Checks | undefined) { const tps = mapOrSame(this.typeParameters, recur) - return tps === this.typeParameters ? + return tps === this.typeParameters && checks === this.checks && encodingChecks === this.encodingChecks ? this : new Declaration(tps, this.run, this.annotations, checks, undefined, this.context, encodingChecks) } @@ -1704,7 +1704,8 @@ export class Arrays extends Base { private _rebuild(recur: (ast: AST) => AST, checks: Checks | undefined, encodingChecks: Checks | undefined) { const elements = mapOrSame(this.elements, recur) const rest = mapOrSame(this.rest, recur) - return elements === this.elements && rest === this.rest ? + return elements === this.elements && rest === this.rest && checks === this.checks && + encodingChecks === this.encodingChecks ? this : new Arrays( this.isMutable, @@ -2245,7 +2246,8 @@ export class Objects extends Base { : new IndexSignature(p, t, merge) }) - return props === this.propertySignatures && indexes === this.indexSignatures + return props === this.propertySignatures && indexes === this.indexSignatures && checks === this.checks && + encodingChecks === this.encodingChecks ? this : new Objects( props, @@ -2661,7 +2663,7 @@ export class Union extends Base { } private _rebuild(recur: (ast: AST) => AST, checks: Checks | undefined, encodingChecks: Checks | undefined) { const types = mapOrSame(this.types, recur) - return types === this.types ? + return types === this.types && checks === this.checks && encodingChecks === this.encodingChecks ? this : new Union(types, this.mode, this.annotations, checks, undefined, this.context, encodingChecks) } @@ -3361,6 +3363,45 @@ export function isMutable(ast: AST): boolean { return ast.context?.isMutable ?? false } +type ASTWithEncodingChecks = Declaration | Arrays | Objects | Union + +function replaceEncodingChecks(ast: A, encodingChecks: Checks | undefined): A { + if (getEncodingChecks(ast) === encodingChecks) { + return ast + } + return modifyOwnPropertyDescriptors(ast, (d) => { + d.encodingChecks.value = encodingChecks + }) +} + +function preservesTypeShape(before: AST, after: AST): boolean { + switch (before._tag) { + case "Declaration": + return after._tag === "Declaration" && after.typeParameters === before.typeParameters + case "Arrays": + return after._tag === "Arrays" && after.elements === before.elements && after.rest === before.rest + case "Objects": + return after._tag === "Objects" && after.propertySignatures === before.propertySignatures && + after.indexSignatures === before.indexSignatures + case "Union": + return after._tag === "Union" && after.types === before.types + default: + return false + } +} + +function projectEncodingChecksToType(before: A, after: A): A { + const encodingChecks = getEncodingChecks(after) + if (!encodingChecks) { + return after + } + const withoutEncodingChecks = replaceEncodingChecks(after as ASTWithEncodingChecks, undefined) as A + if (!preservesTypeShape(before, after)) { + return withoutEncodingChecks + } + return replaceChecks(withoutEncodingChecks, combineChecks(after.checks, encodingChecks)) +} + /** * Strips all encoding transformations from an AST, returning the decoded * (type-level) representation. @@ -3392,12 +3433,7 @@ export const toType = memoize((ast: A): A => { } const out: any = ast const type = out.recur?.(toType) ?? out - if (getEncodingChecks(type)) { - return modifyOwnPropertyDescriptors(type, (d) => { - d.encodingChecks.value = undefined - }) - } - return type + return projectEncodingChecksToType(ast, type) }) /** diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index cdcf28e78b..c64f3b0c47 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1047,23 +1047,33 @@ const recur = memoize( } parser ??= ast.getParser(recur) - let sroa = srou ? Effect.flatMapEager(srou, (ou) => parser(ou, options)) : parser(ou, options) + let sroua: Effect.Effect, Option.Option], SchemaIssue.Issue, unknown> + if (srou) { + sroua = Effect.flatMapEager( + srou, + (localOu) => Effect.mapEager(parser(localOu, options), (oa) => [localOu, oa] as const) + ) + } else { + sroua = Effect.mapEager(parser(ou, options), (oa) => [ou, oa] as const) + } if (encodingChecks && !options?.disableChecks) { - sroa = Effect.flatMapEager(sroa, (oa) => { - if (Option.isSome(ou) && Option.isSome(oa)) { + sroua = Effect.flatMapEager(sroua, ([localOu, oa]) => { + if (Option.isSome(localOu) && Option.isSome(oa)) { const issues: Array = [] - SchemaAST.collectIssues(encodingChecks, ou.value, issues, ast, options) + SchemaAST.collectIssues(encodingChecks, localOu.value, issues, ast, options) if (Arr.isArrayNonEmpty(issues)) { - return Effect.fail(new SchemaIssue.Composite(ast, ou, issues)) + return Effect.fail(new SchemaIssue.Composite(ast, localOu, issues)) } } - return Effect.succeed(oa) + return Effect.succeed([localOu, oa] as const) }) } + let sroa = Effect.mapEager(sroua, ([, oa]) => oa) + if (ast.checks && !options?.disableChecks) { const checks = ast.checks if (options?.errors === "all" && isStructural && Option.isSome(ou)) { diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index f01f93c0b6..8578a0e19d 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -2441,6 +2441,140 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` await encoding.succeed("123", 123) }) + it("Struct & flip & check & flip should apply the check to the encoded side", async () => { + const schema = Schema.Struct({ a: Schema.String }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((o) => o.a.length > 1, { expected: "a length > 1" })), + Schema.flip + ) + assertTrue(SchemaAST.isObjects(schema.ast)) + strictEqual(schema.ast.checks, undefined) + strictEqual(schema.ast.encodingChecks?.length, 1) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.fail( + { a: "a" }, + `Expected a length > 1, got {"a":"a"}` + ) + await decoding.succeed({ a: "aa" }) + + const encoding = asserts.encoding() + await encoding.fail( + { a: "a" }, + `Expected a length > 1, got {"a":"a"}` + ) + await encoding.succeed({ a: "aa" }) + }) + + it("Tuple & flip & check & flip should apply the check to the encoded side", async () => { + const schema = Schema.Tuple([Schema.String]).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((tuple) => tuple[0].length > 1, { expected: "head length > 1" })), + Schema.flip + ) + assertTrue(SchemaAST.isArrays(schema.ast)) + strictEqual(schema.ast.checks, undefined) + strictEqual(schema.ast.encodingChecks?.length, 1) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.fail( + ["a"], + `Expected head length > 1, got ["a"]` + ) + await decoding.succeed(["aa"]) + + const encoding = asserts.encoding() + await encoding.fail( + ["a"], + `Expected head length > 1, got ["a"]` + ) + await encoding.succeed(["aa"]) + }) + + it("Union & flip & check & flip should apply the check to the encoded side", async () => { + const schema = Schema.Union([Schema.Literal("a"), Schema.Literal("aa")]).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((s) => s === "aa", { expected: `"aa"` })), + Schema.flip + ) + assertTrue(SchemaAST.isUnion(schema.ast)) + strictEqual(schema.ast.checks, undefined) + strictEqual(schema.ast.encodingChecks?.length, 1) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.fail( + "a", + `Expected "aa", got "a"` + ) + await decoding.succeed("aa") + + const encoding = asserts.encoding() + await encoding.fail( + "a", + `Expected "aa", got "a"` + ) + await encoding.succeed("aa") + }) + + it("Declaration & flip & check & flip should apply the check to the encoded side", async () => { + const schema = Schema.declare( + (u): u is string => typeof u === "string", + { expected: "string declaration" } + ).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((s) => s.length > 1, { expected: "a length > 1" })), + Schema.flip + ) + assertTrue(SchemaAST.isDeclaration(schema.ast)) + strictEqual(schema.ast.checks, undefined) + strictEqual(schema.ast.encodingChecks?.length, 1) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.fail( + "a", + `Expected a length > 1, got "a"` + ) + await decoding.succeed("aa") + + const encoding = asserts.encoding() + await encoding.fail( + "a", + `Expected a length > 1, got "a"` + ) + await encoding.succeed("aa") + }) + + it("Struct & flip & check & flip & encoding should check the local encoded value", async () => { + const local = Schema.Struct({ a: Schema.String }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((o) => typeof o.a === "string" && o.a.length > 1, { + expected: "a length > 1" + })), + Schema.flip + ) + const schema = Schema.Struct({ b: Schema.String }).pipe( + Schema.decodeTo(local, { + decode: SchemaGetter.transform<{ readonly a: string }, { readonly b: string }>((o) => ({ a: o.b })), + encode: SchemaGetter.transform<{ readonly b: string }, { readonly a: string }>((o) => ({ b: o.a })) + }) + ) + assertTrue(SchemaAST.isObjects(schema.ast)) + strictEqual(schema.ast.encoding?.length, 1) + strictEqual(schema.ast.encodingChecks?.length, 1) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.fail( + { b: "a" }, + `Expected a length > 1, got {"a":"a"}` + ) + await decoding.succeed({ b: "aa" }, { a: "aa" }) + }) + it("should work with withConstructorDefault", async () => { const schema = Schema.Struct({ a: Schema.FiniteFromString.pipe(Schema.withConstructorDefault(Effect.succeed(-1))) From 95ddf681919bd4d09ed6853fb8a4460f4e88232a Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 08:10:01 +0200 Subject: [PATCH 2/9] wip --- packages/effect/src/SchemaParser.ts | 96 +++++++++++----------- packages/effect/test/schema/Schema.test.ts | 44 +++++++++- 2 files changed, 89 insertions(+), 51 deletions(-) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index c64f3b0c47..cdde7891fc 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1047,68 +1047,64 @@ const recur = memoize( } parser ??= ast.getParser(recur) - let sroua: Effect.Effect, Option.Option], SchemaIssue.Issue, unknown> - if (srou) { - sroua = Effect.flatMapEager( - srou, - (localOu) => Effect.mapEager(parser(localOu, options), (oa) => [localOu, oa] as const) - ) - } else { - sroua = Effect.mapEager(parser(ou, options), (oa) => [ou, oa] as const) - } + const parseLocal = (localOu: Option.Option) => { + let sroa = parser(localOu, options) - if (encodingChecks && !options?.disableChecks) { - sroua = Effect.flatMapEager(sroua, ([localOu, oa]) => { - if (Option.isSome(localOu) && Option.isSome(oa)) { - const issues: Array = [] + if (encodingChecks && !options?.disableChecks) { + sroa = Effect.flatMapEager(sroa, (oa) => { + if (Option.isSome(localOu) && Option.isSome(oa)) { + const issues: Array = [] - SchemaAST.collectIssues(encodingChecks, localOu.value, issues, ast, options) + SchemaAST.collectIssues(encodingChecks, localOu.value, issues, ast, options) - if (Arr.isArrayNonEmpty(issues)) { - return Effect.fail(new SchemaIssue.Composite(ast, localOu, issues)) + if (Arr.isArrayNonEmpty(issues)) { + return Effect.fail(new SchemaIssue.Composite(ast, localOu, issues)) + } } + return Effect.succeed(oa) + }) + } + + if (ast.checks && !options?.disableChecks) { + const checks = ast.checks + if (options?.errors === "all" && isStructural && Option.isSome(localOu)) { + sroa = mapSchemaIssueEffect(sroa, (issue) => { + const issues: Array = [] + SchemaAST.collectIssues( + checks.filter((check) => check.annotations?.[SchemaAST.STRUCTURAL_ANNOTATION_KEY]), + localOu.value, + issues, + ast, + options + ) + const out: SchemaIssue.Issue = Arr.isArrayNonEmpty(issues) + ? issue._tag === "Composite" && issue.ast === ast + ? new SchemaIssue.Composite(ast, issue.actual, [...issue.issues, ...issues]) + : new SchemaIssue.Composite(ast, localOu, [issue, ...issues]) + : issue + return out + }) } - return Effect.succeed([localOu, oa] as const) - }) - } + sroa = Effect.flatMapEager(sroa, (oa) => { + if (Option.isSome(oa)) { + const value = oa.value + const issues: Array = [] - let sroa = Effect.mapEager(sroua, ([, oa]) => oa) + SchemaAST.collectIssues(checks, value, issues, ast, options) - if (ast.checks && !options?.disableChecks) { - const checks = ast.checks - if (options?.errors === "all" && isStructural && Option.isSome(ou)) { - sroa = mapSchemaIssueEffect(sroa, (issue) => { - const issues: Array = [] - SchemaAST.collectIssues( - checks.filter((check) => check.annotations?.[SchemaAST.STRUCTURAL_ANNOTATION_KEY]), - ou.value, - issues, - ast, - options - ) - const out: SchemaIssue.Issue = Arr.isArrayNonEmpty(issues) - ? issue._tag === "Composite" && issue.ast === ast - ? new SchemaIssue.Composite(ast, issue.actual, [...issue.issues, ...issues]) - : new SchemaIssue.Composite(ast, ou, [issue, ...issues]) - : issue - return out + if (Arr.isArrayNonEmpty(issues)) { + return Effect.fail(new SchemaIssue.Composite(ast, oa, issues)) + } + } + return Effect.succeed(oa) }) } - sroa = Effect.flatMapEager(sroa, (oa) => { - if (Option.isSome(oa)) { - const value = oa.value - const issues: Array = [] - - SchemaAST.collectIssues(checks, value, issues, ast, options) - if (Arr.isArrayNonEmpty(issues)) { - return Effect.fail(new SchemaIssue.Composite(ast, oa, issues)) - } - } - return Effect.succeed(oa) - }) + return sroa } + const sroa = srou ? Effect.flatMapEager(srou, parseLocal) : parseLocal(ou) + return sroa } } diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 8578a0e19d..d32e39b433 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -2548,7 +2548,7 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` await encoding.succeed("aa") }) - it("Struct & flip & check & flip & encoding should check the local encoded value", async () => { + it("Struct & flip & check & flip with encoding chain should check the local value", async () => { const local = Schema.Struct({ a: Schema.String }).pipe( Schema.flip, Schema.check(Schema.makeFilter((o) => typeof o.a === "string" && o.a.length > 1, { @@ -2573,6 +2573,48 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` `Expected a length > 1, got {"a":"a"}` ) await decoding.succeed({ b: "aa" }, { a: "aa" }) + + const encoding = asserts.encoding() + await encoding.fail( + { a: "a" }, + `Expected a length > 1, got {"a":"a"}` + ) + await encoding.succeed({ a: "aa" }, { b: "aa" }) + }) + + it(`Struct & encoding chain & structural checks should check the local value with errors: "all"`, async () => { + const local = Schema.Struct({ a: Schema.Finite }).check(Schema.isMaxProperties(1)) + const schema = Schema.Struct({ b: Schema.Number, c: Schema.String }).pipe( + Schema.decodeTo(local, { + decode: SchemaGetter.transform< + { readonly a: number }, + { readonly b: number; readonly c: string } + >((o) => ({ a: o.b })), + encode: SchemaGetter.transform< + { readonly b: number; readonly c: string }, + { readonly a: number } + >((o) => ({ b: o.a, c: "" })) + }) + ) + assertTrue(SchemaAST.isObjects(schema.ast)) + strictEqual(schema.ast.encoding?.length, 1) + strictEqual(schema.ast.checks?.length, 1) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding({ parseOptions: { errors: "all" } }) + await decoding.fail( + { b: NaN, c: "extra" }, + `Expected a finite number, got NaN + at ["a"]` + ) + + const encoding = asserts.encoding({ parseOptions: { errors: "all" } }) + await encoding.fail( + { a: NaN }, + `Expected a finite number, got NaN + at ["a"]` + ) + await encoding.succeed({ a: 1 }, { b: 1, c: "" }) }) it("should work with withConstructorDefault", async () => { From 9e703f5ddfcb4e8d05ddcd54cc17da18736a699c Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 08:25:47 +0200 Subject: [PATCH 3/9] more tests --- packages/effect/test/schema/SchemaAST.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/effect/test/schema/SchemaAST.test.ts b/packages/effect/test/schema/SchemaAST.test.ts index 6ef8837894..5ee3eda3c1 100644 --- a/packages/effect/test/schema/SchemaAST.test.ts +++ b/packages/effect/test/schema/SchemaAST.test.ts @@ -68,6 +68,36 @@ describe("SchemaAST", () => { strictEqual(SchemaAST.isStringTree(circular), false) }) + describe("toType", () => { + it("promotes encodingChecks when contained type shape is preserved", () => { + const schema = Schema.Struct({ a: Schema.String }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((o) => o.a.length > 1)), + Schema.flip + ) + + const ast = SchemaAST.toType(schema.ast) + + strictEqual(SchemaAST.isObjects(ast), true) + strictEqual(ast.checks?.length, 1) + strictEqual(ast.encodingChecks, undefined) + }) + + it("drops encodingChecks when contained type shape changes", () => { + const schema = Schema.Struct({ a: Schema.FiniteFromString }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((o) => o.a.length > 1)), + Schema.flip + ) + + const ast = SchemaAST.toType(schema.ast) + + strictEqual(SchemaAST.isObjects(ast), true) + strictEqual(ast.checks, undefined) + strictEqual(ast.encodingChecks, undefined) + }) + }) + describe("collectSentinels", () => { describe("Declaration", () => { it("~sentinels", () => { From 6c72a211223a73ec0b152ca792d94eb21213b9de Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 08:40:22 +0200 Subject: [PATCH 4/9] wip --- packages/effect/src/SchemaAST.ts | 52 ++++++----------------------- packages/effect/src/SchemaParser.ts | 1 + 2 files changed, 12 insertions(+), 41 deletions(-) diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 1d2cc19181..6b9aeb7ed4 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -3363,45 +3363,6 @@ export function isMutable(ast: AST): boolean { return ast.context?.isMutable ?? false } -type ASTWithEncodingChecks = Declaration | Arrays | Objects | Union - -function replaceEncodingChecks(ast: A, encodingChecks: Checks | undefined): A { - if (getEncodingChecks(ast) === encodingChecks) { - return ast - } - return modifyOwnPropertyDescriptors(ast, (d) => { - d.encodingChecks.value = encodingChecks - }) -} - -function preservesTypeShape(before: AST, after: AST): boolean { - switch (before._tag) { - case "Declaration": - return after._tag === "Declaration" && after.typeParameters === before.typeParameters - case "Arrays": - return after._tag === "Arrays" && after.elements === before.elements && after.rest === before.rest - case "Objects": - return after._tag === "Objects" && after.propertySignatures === before.propertySignatures && - after.indexSignatures === before.indexSignatures - case "Union": - return after._tag === "Union" && after.types === before.types - default: - return false - } -} - -function projectEncodingChecksToType(before: A, after: A): A { - const encodingChecks = getEncodingChecks(after) - if (!encodingChecks) { - return after - } - const withoutEncodingChecks = replaceEncodingChecks(after as ASTWithEncodingChecks, undefined) as A - if (!preservesTypeShape(before, after)) { - return withoutEncodingChecks - } - return replaceChecks(withoutEncodingChecks, combineChecks(after.checks, encodingChecks)) -} - /** * Strips all encoding transformations from an AST, returning the decoded * (type-level) representation. @@ -3432,8 +3393,17 @@ export const toType = memoize((ast: A): A => { return toType(replaceEncoding(ast, undefined)) } const out: any = ast - const type = out.recur?.(toType) ?? out - return projectEncodingChecksToType(ast, type) + const type: A = out.recur?.(toType) ?? out + const encodingChecks = getEncodingChecks(type) + if (encodingChecks) { + return modifyOwnPropertyDescriptors(type, (d) => { + ;(d as any).encodingChecks.value = undefined + if (type === ast) { + d.checks.value = combineChecks(type.checks, encodingChecks) + } + }) + } + return type }) /** diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index cdde7891fc..514e722486 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1009,6 +1009,7 @@ const recur = memoize( let parser: Parser const encodingChecks = SchemaAST.getEncodingChecks(ast) const resolvedChecks = ast.checks ?? encodingChecks + // TODO: is this correct? const astOptions = (resolvedChecks ? resolvedChecks[resolvedChecks.length - 1].annotations : ast.annotations) ?.["parseOptions"] if (!ast.context && !ast.encoding && !ast.checks && !encodingChecks) { From 065622e39e6bc483e59e6b89c3802d93d881f983 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 08:53:15 +0200 Subject: [PATCH 5/9] switch to isContainer --- packages/effect/src/SchemaAST.ts | 39 ++++++++++++++++------------- packages/effect/src/SchemaParser.ts | 2 +- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 6b9aeb7ed4..1ad04c1e59 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -372,6 +372,22 @@ export const isObjects = makeGuard("Objects") */ export const isUnion = makeGuard("Union") +/** @internal */ +export type Container = Declaration | Arrays | Objects | Union + +/** @internal */ +export function isContainer(ast: AST): ast is Container { + switch (ast._tag) { + case "Declaration": + case "Arrays": + case "Objects": + case "Union": + return true + default: + return false + } +} + /** * Narrows an {@link AST} to {@link Suspend}. * @@ -2862,19 +2878,6 @@ export class Suspend extends Base { } } -/** @internal */ -export function getEncodingChecks(ast: AST): Checks | undefined { - switch (ast._tag) { - case "Declaration": - case "Arrays": - case "Objects": - case "Union": - return ast.encodingChecks - default: - return undefined - } -} - // ----------------------------------------------------------------------------- // Checks // ----------------------------------------------------------------------------- @@ -3393,15 +3396,15 @@ export const toType = memoize((ast: A): A => { return toType(replaceEncoding(ast, undefined)) } const out: any = ast - const type: A = out.recur?.(toType) ?? out - const encodingChecks = getEncodingChecks(type) - if (encodingChecks) { + const type = out.recur?.(toType) ?? out + if (isContainer(type) && type.encodingChecks) { + const encodingChecks = type.encodingChecks return modifyOwnPropertyDescriptors(type, (d) => { - ;(d as any).encodingChecks.value = undefined + d.encodingChecks.value = undefined if (type === ast) { d.checks.value = combineChecks(type.checks, encodingChecks) } - }) + }) as A } return type }) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 514e722486..fc9f91a06a 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1007,7 +1007,7 @@ export interface Parser { const recur = memoize( (ast: SchemaAST.AST): Parser => { let parser: Parser - const encodingChecks = SchemaAST.getEncodingChecks(ast) + const encodingChecks = SchemaAST.isContainer(ast) ? ast.encodingChecks : undefined const resolvedChecks = ast.checks ?? encodingChecks // TODO: is this correct? const astOptions = (resolvedChecks ? resolvedChecks[resolvedChecks.length - 1].annotations : ast.annotations) From 039a8ee51efcd253f675459ec97be56f80069428 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 09:04:40 +0200 Subject: [PATCH 6/9] changeset --- .changeset/fix-schema-encoding-checks.md | 8 +++++++- .changeset/fix-schema-flip-checks.md | 5 ----- 2 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fix-schema-flip-checks.md diff --git a/.changeset/fix-schema-encoding-checks.md b/.changeset/fix-schema-encoding-checks.md index 0bd86777a8..ba26c3a362 100644 --- a/.changeset/fix-schema-encoding-checks.md +++ b/.changeset/fix-schema-encoding-checks.md @@ -2,4 +2,10 @@ "effect": patch --- -Fix Schema encoded-side checks when flipping and projecting ASTs. +Fix Schema handling of encoded-side checks for container ASTs. + +Checks added after `flip` are now preserved as `encodingChecks` across +`Declaration`, `Arrays`, `Objects`, and `Union`, even when rebuilding the AST +does not change child nodes. `toType` now projects those checks consistently, +and parsing applies encoded-side checks to the local encoded value when an +encoding chain is present. diff --git a/.changeset/fix-schema-flip-checks.md b/.changeset/fix-schema-flip-checks.md deleted file mode 100644 index be9c689b76..0000000000 --- a/.changeset/fix-schema-flip-checks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"effect": patch ---- - -Fix Schema flip to preserve check side when structural AST nodes are rebuilt without child changes. From 256398036ae449c70409aaea65b19410071025be Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 09:07:25 +0200 Subject: [PATCH 7/9] simplify --- packages/effect/src/SchemaAST.ts | 22 +++------------------- packages/effect/src/SchemaParser.ts | 2 +- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 1ad04c1e59..3c7420dd13 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -372,22 +372,6 @@ export const isObjects = makeGuard("Objects") */ export const isUnion = makeGuard("Union") -/** @internal */ -export type Container = Declaration | Arrays | Objects | Union - -/** @internal */ -export function isContainer(ast: AST): ast is Container { - switch (ast._tag) { - case "Declaration": - case "Arrays": - case "Objects": - case "Union": - return true - default: - return false - } -} - /** * Narrows an {@link AST} to {@link Suspend}. * @@ -3397,14 +3381,14 @@ export const toType = memoize((ast: A): A => { } const out: any = ast const type = out.recur?.(toType) ?? out - if (isContainer(type) && type.encodingChecks) { - const encodingChecks = type.encodingChecks + const encodingChecks = type.encodingChecks + if (encodingChecks) { return modifyOwnPropertyDescriptors(type, (d) => { d.encodingChecks.value = undefined if (type === ast) { d.checks.value = combineChecks(type.checks, encodingChecks) } - }) as A + }) } return type }) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index fc9f91a06a..e4eb105d9b 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1007,7 +1007,7 @@ export interface Parser { const recur = memoize( (ast: SchemaAST.AST): Parser => { let parser: Parser - const encodingChecks = SchemaAST.isContainer(ast) ? ast.encodingChecks : undefined + const encodingChecks = (ast as any).encodingChecks const resolvedChecks = ast.checks ?? encodingChecks // TODO: is this correct? const astOptions = (resolvedChecks ? resolvedChecks[resolvedChecks.length - 1].annotations : ast.annotations) From bb4c87d3c5dd01c07dd7a632ff3d542c53854a0d Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 09:35:59 +0200 Subject: [PATCH 8/9] optimize --- packages/effect/src/SchemaParser.ts | 25 +++++++++++----------- packages/effect/test/schema/Schema.test.ts | 25 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index e4eb105d9b..d0f2b3589b 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1007,12 +1007,14 @@ export interface Parser { const recur = memoize( (ast: SchemaAST.AST): Parser => { let parser: Parser + const checks = ast.checks + const encoding = ast.encoding + const links = encoding + const len = links?.length ?? 0 const encodingChecks = (ast as any).encodingChecks - const resolvedChecks = ast.checks ?? encodingChecks - // TODO: is this correct? - const astOptions = (resolvedChecks ? resolvedChecks[resolvedChecks.length - 1].annotations : ast.annotations) + const astOptions = (checks ? checks[checks.length - 1].annotations : ast.annotations) ?.["parseOptions"] - if (!ast.context && !ast.encoding && !ast.checks && !encodingChecks) { + if (!ast.context && !encoding && !checks && !encodingChecks) { return (ou, options) => { parser ??= ast.getParser(recur) if (astOptions) { @@ -1023,15 +1025,15 @@ const recur = memoize( } const isStructural = SchemaAST.isArrays(ast) || SchemaAST.isObjects(ast) || (SchemaAST.isDeclaration(ast) && ast.typeParameters.length > 0) + const structuralChecks = checks && isStructural ? + checks.filter((check) => check.annotations?.[SchemaAST.STRUCTURAL_ANNOTATION_KEY]) : + undefined return (ou, options) => { if (astOptions) { options = { ...options, ...astOptions } } - const encoding = ast.encoding let srou: Effect.Effect, SchemaIssue.Issue, unknown> | undefined - if (encoding) { - const links = encoding - const len = links.length + if (links) { for (let i = len - 1; i >= 0; i--) { const link = links[i] const to = link.to @@ -1066,13 +1068,12 @@ const recur = memoize( }) } - if (ast.checks && !options?.disableChecks) { - const checks = ast.checks - if (options?.errors === "all" && isStructural && Option.isSome(localOu)) { + if (checks && !options?.disableChecks) { + if (options?.errors === "all" && structuralChecks && structuralChecks.length > 0 && Option.isSome(localOu)) { sroa = mapSchemaIssueEffect(sroa, (issue) => { const issues: Array = [] SchemaAST.collectIssues( - checks.filter((check) => check.annotations?.[SchemaAST.STRUCTURAL_ANNOTATION_KEY]), + structuralChecks, localOu.value, issues, ast, diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index d32e39b433..31fddafe37 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -111,6 +111,31 @@ Expected an integer, got -1.2` at ["b"]["c"]` ) }) + + it("should not read parseOptions from encodingChecks", async () => { + const schema = Schema.Struct({ + a: Schema.String, + b: Schema.String + }).pipe( + Schema.flip, + Schema.check(Schema.isMaxProperties(1)), + Schema.annotate({ parseOptions: { errors: "first" } }), + Schema.flip + ) + assertTrue(SchemaAST.isObjects(schema.ast)) + strictEqual(schema.ast.checks, undefined) + strictEqual(schema.ast.encodingChecks?.length, 1) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding({ parseOptions: { errors: "all" } }) + await decoding.fail( + {}, + `Missing key + at ["a"] +Missing key + at ["b"]` + ) + }) }) describe("parse options", () => { From 62ca0c70e26981b5aab4b11556fb3326e1bf7cd0 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 22 Jun 2026 09:51:38 +0200 Subject: [PATCH 9/9] wip --- .changeset/fix-schema-encoding-checks.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-schema-encoding-checks.md b/.changeset/fix-schema-encoding-checks.md index ba26c3a362..ba56790cbb 100644 --- a/.changeset/fix-schema-encoding-checks.md +++ b/.changeset/fix-schema-encoding-checks.md @@ -8,4 +8,5 @@ Checks added after `flip` are now preserved as `encodingChecks` across `Declaration`, `Arrays`, `Objects`, and `Union`, even when rebuilding the AST does not change child nodes. `toType` now projects those checks consistently, and parsing applies encoded-side checks to the local encoded value when an -encoding chain is present. +encoding chain is present without allowing encoded-side `parseOptions` +annotations to affect the current parser side.