Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .changeset/happy-facts-jam.md
Original file line number Diff line number Diff line change
@@ -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
```
36 changes: 35 additions & 1 deletion packages/effect/src/SchemaAST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2087,8 +2087,42 @@ export class Objects extends Base {
// ---------------------------------------------
// handle empty struct
// ---------------------------------------------
const makeUnexpectedKeyPointer = (
key: (string | symbol),
obj: Record<PropertyKey, unknown>
) => 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 ?
Expand Down
34 changes: 34 additions & 0 deletions packages/effect/test/schema/Schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down