Skip to content

fix(set): coerce set elements without duplicating originals#1299

Open
spokodev wants to merge 1 commit into
ianstormtaylor:mainfrom
spokodev:fix/set-element-coercion-duplication
Open

fix(set): coerce set elements without duplicating originals#1299
spokodev wants to merge 1 commit into
ianstormtaylor:mainfrom
spokodev:fix/set-element-coercion-duplication

Conversation

@spokodev

Copy link
Copy Markdown

Problem

Coercing a set() whose element struct coerces returns a Set that contains both the original and the coerced elements, and the result fails the struct's own validation:

const toNumber = coerce(number(), string(), (v) => parseFloat(v))

const out = create(new Set(['1', '2']), set(toNumber))
// actual:   Set { '1', '2', 1, 2 }
// expected: Set { 1, 2 }

is(out, set(number())) // false
// re-validating throws: Expected a number, but received: '1'

This breaks the documented create() / validate(..., { coerce: true }) guarantee that the returned value is guaranteed to match the struct, and that coercion works recursively. array() (writes back by index) and map() (writes back by key) already handle this correctly; only set() is affected.

Root cause

The coercion write-back in run() (src/utils.ts) does value.add(coercedValue) for sets. Set.add() can only insert a member, it can never replace one positionally, so the coerced element is appended next to the still-present original. Arrays overwrite by index and maps overwrite by key, but a set has no positional handle, so both '1' and 1 survive.

Fix

  • In run(), clear the set once on the first coerced write-back so only coerced members remain, then add() accumulates them.
  • In set().entries (src/structs/types.ts), snapshot the members with [...value] before yielding, so clearing the live Set during write-back cannot corrupt the iteration.

The change is scoped to the set branch and is a no-op when there is no element struct or no coercion.

Tests

Added test/validation/set/coerce-element.ts (matches the existing fixture style):

const toNumber = coerce(number(), string(), (v) => parseFloat(v))
export const Struct = set(toNumber)
export const data = new Set(['1', '2'])
export const output = new Set([1, 2])
export const create = true

Red before the fix (Set{'1','2',1,2}), green after. Full suite passes: 226/226.

Also verified by hand: empty set, set() with no element struct, set nested in an object, coercion collapsing two inputs to one member (dedup), per-element failure path, and non-coercing rejection.

A set() whose element struct coerces returned a Set containing both the
original and coerced elements, e.g. create(new Set(['1','2']), set(toNumber))
yielded Set{'1','2',1,2}. The result then failed the struct's own
validation, breaking the create()/validate() guarantee that the returned
value matches the struct.

Root cause: the coercion write-back in run() uses Set.add(), which can only
insert a member and never replace one positionally, so each coerced element
accumulated alongside its original. Clear the set once on the first coerced
write-back so only coerced members remain, and snapshot the set entries via
[...value] so clearing the live Set during write-back cannot corrupt
iteration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant