From f7f13e02bbe690300fa832a70bb8fadf420eb587 Mon Sep 17 00:00:00 2001 From: nikelborm Date: Thu, 16 Jul 2026 20:16:08 +0300 Subject: [PATCH] fix: Made field-less Schema.Struct({}) respect onExcessProperty: 'ignore' | 'preserve' | 'error' --- .changeset/happy-facts-jam.md | 75 ++++++++++++++++++++++ packages/effect/src/SchemaAST.ts | 36 ++++++++++- packages/effect/test/schema/Schema.test.ts | 34 ++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 .changeset/happy-facts-jam.md diff --git a/.changeset/happy-facts-jam.md b/.changeset/happy-facts-jam.md new file mode 100644 index 00000000000..f4678cf3595 --- /dev/null +++ b/.changeset/happy-facts-jam.md @@ -0,0 +1,75 @@ +--- +"effect": patch +--- + +Made field-less `Schema.Struct({})` respect `onExcessProperty: 'ignore' | 'preserve' | '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: Unexpected key with value "stripped by default, unless asked otherwise" +// at ["hello"] +``` + +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/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 9e1b1271413..26b6e9aedef 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -2087,8 +2087,42 @@ export class Objects extends Base { // --------------------------------------------- // handle empty struct // --------------------------------------------- + const makeUnexpectedKeyPointer = ( + key: (string | symbol), + obj: Record + ) => new SchemaIssue.Pointer([key], new SchemaIssue.UnexpectedKey(ast, obj[key])) + if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) { - return fromRefinement(ast, Predicate.isNotNullish) + return (oinput, options) => { + if (oinput._tag === "None") { + return Effect.succeedNone + } + const input = oinput.value + if (!Predicate.isNotNullish(input)) { + return Effect.fail(new SchemaIssue.InvalidType(ast, oinput)) + } + // Only plain objects have "excess properties"; other non-nullish values + // (strings, numbers, arrays) pass through unchanged since {} accepts them. + if (!Predicate.isObject(input)) { + return Effect.succeed(oinput) + } + // "preserve" keeps the input (and all its properties) as-is. + if (options.onExcessProperty === "preserve") { + return Effect.succeed(oinput) + } + // "error": every own key is unexpected for an empty struct. + if (options.onExcessProperty === "error") { + const keys = Reflect.ownKeys(input) + if (Arr.isArrayNonEmpty(keys)) { + const issues = options.errors === "all" + ? Arr.map(keys, (key) => makeUnexpectedKeyPointer(key, input)) + : [makeUnexpectedKeyPointer(keys[0], input)] as const + return Effect.fail(new SchemaIssue.Composite(ast, oinput, issues)) + } + } + // "ignore" (or "error" with no excess keys): strip everything to an empty object. + return Effect.succeedSome({} as unknown) + } } const parseIndexes = indexCount > 0 ? diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index c8875b7c78d..ff10e201b60 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -606,6 +606,40 @@ Unexpected key with value "c" }) }) + it("Struct({}): empty struct strips excess properties", async () => { + const schema = Schema.Struct({}) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.succeed({}, {}) + await decoding.succeed({ a: 1 }, {}) + await decoding.fail(null, `Expected object | array, got null`) + + const decodingError = asserts.decoding({ parseOptions: { onExcessProperty: "error" } }) + await decodingError.succeed({}, {}) + await decodingError.fail( + { a: 1 }, + `Unexpected key with value 1 + at ["a"]` + ) + await asserts + .decoding({ parseOptions: { onExcessProperty: "error", errors: "all" } }) + .fail( + { a: 1, b: 2 }, + `Unexpected key with value 1 + at ["a"] +Unexpected key with value 2 + at ["b"]` + ) + + const decodingPreserve = asserts.decoding({ parseOptions: { onExcessProperty: "preserve" } }) + await decodingPreserve.succeed({ a: 1 }, { a: 1 }) + + const encoding = asserts.encoding() + await encoding.succeed({}, {}) + await encoding.succeed({ a: 1 }, {}) + }) + it("should corectly handle __proto__", async () => { const schema = Schema.Struct({ ["__proto__"]: Schema.String