From 9feabbb38f46b52f8e5633740623cdc7a5302d87 Mon Sep 17 00:00:00 2001 From: nikelborm Date: Fri, 29 May 2026 15:20:24 +0300 Subject: [PATCH] fix: Made field-less `Schema.Struct({})` respect `onExcessProperty: 'ignore' | 'preseve' | 'error'` --- .changeset/lazy-toes-hug.md | 76 +++++++++++++++++++ packages/effect/src/ParseResult.ts | 36 ++++++++- .../test/Schema/Schema/Record/Record.test.ts | 2 +- .../test/Schema/Schema/Struct/Struct.test.ts | 26 ++++++- 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 .changeset/lazy-toes-hug.md diff --git a/.changeset/lazy-toes-hug.md b/.changeset/lazy-toes-hug.md new file mode 100644 index 00000000000..fac7e3c6368 --- /dev/null +++ b/.changeset/lazy-toes-hug.md @@ -0,0 +1,76 @@ +--- +"effect": patch +--- + +Made field-less `Schema.Struct({})` respect `onExcessProperty: 'ignore' | 'preseve' | 'error'` + +Motivation: you remove one field, the resulting object gets fewer fields. You +remove another, fewer again. You remove the last; suddenly all the fields come +back, without respect to the default `onExcessProperty: "ignore"`. If the user +wanted to get all the fields, they have an option to set +`onExcessProperty: "preserve"`. + +I tried attempting to defend old behavior with "`{}` means +`typeof obj === 'object' && obj !== null` and effect's schema tries to be +closer to typescript's behaviour, by treating `Schema.Struct({})` as something +passing the condition". And this might hold in isolation. But considering the +context, by this logic, any fields which are not explicitly mentioned in +`Schema.Struct({ ... })` should be preserved in all decoded objects, not only +the ones that have `{}` signature, to follow typescript's assignability rules. +And this is not what happens. The behavior should be consistent between +field-less structs and field-ful. + +```ts +const arg = { hello: 'asd', redundant: 'asd' } +function fn(param: { hello: string }) {} +fn(arg) +``` + + +New behavior + +```ts +import * as Schema from 'effect/Schema' + +const Something = Schema.Struct({}) +const decodeSomething = Schema.decodeSync(Something); + +const obj = { hello: 'stripped by default, unless asked otherwise' } + +console.log(decodeSomething(obj)) +// {} + +console.log(decodeSomething(obj, { onExcessProperty: 'ignore' })) +// {} + +console.log(decodeSomething(obj, { onExcessProperty: 'preserve' })) +// { hello: "stripped by default, unless asked otherwise" } + +decodeSomething(obj, { onExcessProperty: 'error' }) +// error: {} +// └─ ["hello"] +// └─ is unexpected, expected: never +``` + +Old behavior: + +```ts +import * as Schema from 'effect/Schema' + +const Something = Schema.Struct({}) +const decodeSomething = Schema.decodeSync(Something); + +const obj = { hello: 'stripped by default, unless asked otherwise' } + +console.log(decodeSomething(obj)) +// { hello: 'stripped by default, unless asked otherwise' } + +console.log(decodeSomething(obj, { onExcessProperty: 'ignore' })) +// { hello: 'stripped by default, unless asked otherwise' } + +console.log(decodeSomething(obj, { onExcessProperty: 'preserve' })) +// { hello: "stripped by default, unless asked otherwise" } + +decodeSomething(obj, { onExcessProperty: 'error' }) +// doesn't throw +``` diff --git a/packages/effect/src/ParseResult.ts b/packages/effect/src/ParseResult.ts index c89f8295073..b30bfc2ef44 100644 --- a/packages/effect/src/ParseResult.ts +++ b/packages/effect/src/ParseResult.ts @@ -1102,7 +1102,41 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => { } case "TypeLiteral": { if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) { - return fromRefinement(ast, Predicate.isNotNullable) + return (input, options) => { + if (!Predicate.isNotNullable(input)) { + return Either.left(new Type(ast, input)) + } + if (Predicate.isRecord(input)) { + const onExcessPropertyError = options?.onExcessProperty === "error" + if (onExcessPropertyError) { + const reportAllErrors = options?.errors === "all" + const keys = Reflect.ownKeys(input) + const unexpectedPropertyIssues: Array<[number, ParseIssue]> = [] + let stepKey = 0 + for (const key of keys) { + const pointer = new Pointer( + key, + input, + new Unexpected(input[key], `is unexpected, expected: never`) + ) + if (reportAllErrors) { + unexpectedPropertyIssues.push([stepKey++, pointer]) + } else { + return Either.left(new Composite(ast, input, pointer, {})) + } + } + if (Arr.isNonEmptyArray(unexpectedPropertyIssues)) { + return Either.left( + new Composite(ast, input, sortByIndex(unexpectedPropertyIssues), {}) + ) + } + } + if (options?.onExcessProperty !== "preserve") { + return Either.right({}) + } + } + return Either.right(input) + } } const propertySignatures: Array = [] diff --git a/packages/effect/test/Schema/Schema/Record/Record.test.ts b/packages/effect/test/Schema/Schema/Record/Record.test.ts index 6fe9d7ec60f..a272b389f3f 100644 --- a/packages/effect/test/Schema/Schema/Record/Record.test.ts +++ b/packages/effect/test/Schema/Schema/Record/Record.test.ts @@ -77,7 +77,7 @@ describe("record", () => { it("Record(never, number)", async () => { const schema = S.Record({ key: S.Never, value: S.Number }) await Util.assertions.decoding.succeed(schema, {}) - await Util.assertions.decoding.succeed(schema, { a: 1 }) + await Util.assertions.decoding.succeed(schema, { a: 1 }, {}) }) it("Record(string, number)", async () => { diff --git a/packages/effect/test/Schema/Schema/Struct/Struct.test.ts b/packages/effect/test/Schema/Schema/Struct/Struct.test.ts index 0a42b8d3f1c..1ddf4664651 100644 --- a/packages/effect/test/Schema/Schema/Struct/Struct.test.ts +++ b/packages/effect/test/Schema/Schema/Struct/Struct.test.ts @@ -46,7 +46,7 @@ describe("Struct", () => { it("empty", async () => { const schema = S.Struct({}) await Util.assertions.decoding.succeed(schema, {}) - await Util.assertions.decoding.succeed(schema, { a: 1 }) + await Util.assertions.decoding.succeed(schema, { a: 1 }, {}) await Util.assertions.decoding.succeed(schema, []) await Util.assertions.decoding.fail( @@ -54,6 +54,17 @@ describe("Struct", () => { null, `Expected {}, actual null` ) + + await Util.assertions.decoding.fail( + schema, + { a: 1 }, + `{} +└─ ["a"] + └─ is unexpected, expected: never`, + { parseOptions: Util.onExcessPropertyError } + ) + + await Util.assertions.decoding.succeed(schema, { a: 1 }, { a: 1 }, { parseOptions: Util.onExcessPropertyPreserve }) }) it("required property signature", async () => { @@ -196,7 +207,7 @@ describe("Struct", () => { it("empty", async () => { const schema = S.Struct({}) await Util.assertions.encoding.succeed(schema, {}, {}) - await Util.assertions.encoding.succeed(schema, { a: 1 }, { a: 1 }) + await Util.assertions.encoding.succeed(schema, { a: 1 }, {}) await Util.assertions.encoding.succeed(schema, [], []) await Util.assertions.encoding.fail( @@ -204,6 +215,17 @@ describe("Struct", () => { null as any, `Expected {}, actual null` ) + + await Util.assertions.encoding.fail( + schema, + { a: 1 }, + `{} +└─ ["a"] + └─ is unexpected, expected: never`, + { parseOptions: Util.onExcessPropertyError } + ) + + await Util.assertions.encoding.succeed(schema, { a: 1 }, { a: 1 }, { parseOptions: Util.onExcessPropertyPreserve }) }) it("required property signature", async () => {