Skip to content

fix: surface union branch errors instead of discarding them - #76

Open
1222hxy wants to merge 1 commit into
shigma:mainfrom
1222hxy:fix/union-error-detail
Open

fix: surface union branch errors instead of discarding them#76
1222hxy wants to merge 1 commit into
shigma:mainfrom
1222hxy:fix/union-error-detail

Conversation

@1222hxy

@1222hxy 1222hxy commented Aug 13, 2026

Copy link
Copy Markdown

Problem

The union resolver collects each branch's ValidationError into messages, then throws without ever reading it:

Schema.extend('union', (data, { list, toString }, options, strict) => {
const messages: any[] = []
for (const inner of list!) {
try {
return Schema.resolve(data, inner, options, strict)
} catch (error) {
messages.push(error)
}
}
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
})

const messages: any[] = []
for (const inner of list!) {
  try {
    return Schema.resolve(data, inner, options, strict)
  } catch (error) {
    messages.push(error)     // never read
  }
}
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)

messages is write-only. Linters do not flag it because push counts as a use.

For a discriminated union this discards exactly the information the caller needs. Given:

const config = Schema.union([
  Schema.object({ a: Schema.const('foo'), b: Schema.number().required() }),
  Schema.object({ a: Schema.const('bar'), c: Schema.string().required() }),
])

config({ a: 'foo' })

Before — every branch's full signature, but not the one thing that is wrong:

expected { a: "foo", b: number } | { a: "bar", c: string } but got {"a":"foo"}

After:

expected { a: "foo", b: number } | { a: "bar", c: string } but got {"a":"foo"}
  - $.b missing required value
  - $.a expected bar but got foo

Both detail lines were already computed one line earlier and thrown away.

Why only branches that failed deeper

A branch that failed at the union's own path only restates the mismatch the top-level line already reports, so reporting it is pure noise. A branch that failed at a deeper path names the offending property, which the caller cannot recover otherwise.

Filtering on path depth keeps the change free for the cases that gain nothing:

Schema.union([1, 2])('3')
// expected 1 | 2 but got "3"        (unchanged — both branches failed at the union's own path)

Schema.union([Schema.object({ a: Schema.number() }), Schema.number()])('foo')
// expected { a?: number } | number but got "foo"    (unchanged)

Absolute paths are preserved through nesting, so the detail stays actionable inside objects and arrays:

$.x expected ... but got {"a":"foo"}
  - $.x.b missing required value

$[0] expected ... but got {"a":"foo"}
  - $[0].b missing required value

Duplicate messages are collapsed.

Scope

Only the message of an already-thrown error changes. Successful validation, the set of inputs that throw, and the error type are all untouched.

Tests

Added error detail to packages/core/tests/union.spec.ts covering the surfaced property errors, absolute paths under nesting, and the unchanged primitive-union message.

yarn yakumo test core goes from 45 to 46 passing with no new failures.

Note on CI

main is currently red independently of this change, so a red check here is expected:

  • packages/core/tests/i18n.spec.ts fails on main (it outlived cf0b7e5 "refa: remove i18n dependency").
  • The test script passes -r esbuild-register, which current yakumo rejects with unknown option: "r".

I left both alone to keep this PR to one concern, but happy to fix either here or separately if useful.

The union resolver collected each branch's ValidationError into `messages`
and then threw without ever reading it, so a failing discriminated union
reported every branch's full signature but never the offending property.

Only branches that failed at a deeper path are reported: a branch that
failed at the union's own path merely restates the mismatch the top-level
message already carries, so primitive unions keep their current message.

Co-authored-by: Cursor <cursoragent@cursor.com>
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