Skip to content

Commit 9bfd3b0

Browse files
authored
fix: surface best-matching variant for union validation errors (#172) (#213)
* fix: surface best-matching variant for union validation errors (#172) Zod v4 reports union failures as one issue per variant. For QueryDslQueryContainer (61 variants) and similar discriminated unions, this produced unactionable output: text mode collapsed to "Invalid input → at query", and JSON mode emitted 61 nested error objects where the one meaningful error was buried among "received undefined" noise from non-matching discriminators. simplifyZodIssues walks ZodError.issues and, for each invalid_union, picks the variant whose errors reach the deepest path -- the reliable signal that the discriminator matched and validation failed on a real nested field. The chosen variant replaces the union issue inline; recursion handles nested unions (e.g. bool.must clauses). formatIssuesText renders the flat list as readable "✖ message → at path" lines, replacing z.prettifyError on the validation path. * fix: add SPDX header to zod-error.ts
1 parent d2b4a43 commit 9bfd3b0

4 files changed

Lines changed: 342 additions & 2 deletions

File tree

src/factory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { ResolvedConfig, CommandPolicy } from './config/types.ts'
1111
import { getResolvedConfig } from './config/store.ts'
1212
import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts'
1313
import type { SchemaArgDefinition } from './lib/schema-args.ts'
14+
import { simplifyZodIssues, formatIssuesText } from './lib/zod-error.ts'
1415
import { renderText, formatHandlerError } from './output.ts'
1516

1617
/** pre-built schema for coercing string → number, reused per option invocation */
@@ -730,8 +731,8 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
730731
parsed.rawBodyValues = rawBodyValues
731732
}
732733
} else {
734+
const issues = simplifyZodIssues(result.error.issues)
733735
if (jsonFormat === true) {
734-
const issues = result.error.issues
735736
process.stderr.write(JSON.stringify({
736737
error: {
737738
code: 'input_validation_failed',
@@ -742,7 +743,7 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
742743
// throw to prevent handler execution - mirrors cmd.error() behaviour
743744
throw Object.assign(new Error('input_validation_failed'), { exitCode: 1 })
744745
}
745-
return cmd.error(`input validation failed:\n${z.prettifyError(result.error)}`)
746+
return cmd.error(`input validation failed:\n${formatIssuesText(issues)}`)
746747
}
747748
}
748749
if (allRaw['dryRun'] === true) {

src/lib/zod-error.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { z } from 'zod'
7+
8+
/**
9+
* Tools for turning Zod v4 validation errors into concise, actionable output.
10+
*
11+
* Union schemas (e.g. the 61-variant `QueryDslQueryContainer`) produce a single
12+
* `invalid_union` issue whose `errors` array contains one sub-list per variant.
13+
* All-but-one of those sub-lists are `"received undefined"` noise from variants
14+
* whose discriminator key isn't present. `simplifyZodIssues` replaces each
15+
* `invalid_union` with the sub-list of the variant most likely to have been
16+
* intended, picked by depth heuristic (see `scoreVariant`). The result is a
17+
* flat list of issues suitable for rendering to text or emitting as JSON.
18+
*/
19+
20+
type Issue = z.core.$ZodIssue
21+
type InvalidUnionIssue = Extract<Issue, { code: 'invalid_union' }>
22+
23+
function isInvalidUnion (issue: Issue): issue is InvalidUnionIssue {
24+
return issue.code === 'invalid_union'
25+
}
26+
27+
/**
28+
* Scores a variant's issue list as a candidate for "the user meant this variant".
29+
*
30+
* Higher is better. The dominant signal is the deepest path reached: a matched
31+
* discriminator lets validation descend into nested fields, producing longer
32+
* paths than the shallow `"received undefined"` emitted when a variant's
33+
* discriminator key is absent from the input.
34+
*/
35+
function scoreVariant (issues: readonly Issue[]): number {
36+
if (issues.length === 0) return -Infinity
37+
let maxDepth = 0
38+
let undefinedCount = 0
39+
for (const issue of issues) {
40+
if (issue.path.length > maxDepth) maxDepth = issue.path.length
41+
if (issue.code === 'invalid_type' && issue.message.includes('received undefined')) {
42+
undefinedCount++
43+
}
44+
}
45+
// Depth dominates. Fewer undefined messages and fewer total issues break ties.
46+
return maxDepth * 1000 - undefinedCount * 10 - issues.length
47+
}
48+
49+
function pickBestVariant (variants: readonly (readonly Issue[])[]): readonly Issue[] | undefined {
50+
if (variants.length === 0) return undefined
51+
let bestIdx = 0
52+
let bestScore = scoreVariant(variants[0] as Issue[])
53+
for (let i = 1; i < variants.length; i++) {
54+
const s = scoreVariant(variants[i] as Issue[])
55+
if (s > bestScore) {
56+
bestScore = s
57+
bestIdx = i
58+
}
59+
}
60+
return variants[bestIdx]
61+
}
62+
63+
function prefixPath (issue: Issue, prefix: readonly PropertyKey[]): Issue {
64+
if (prefix.length === 0) return issue
65+
return { ...issue, path: [...prefix, ...issue.path] } as Issue
66+
}
67+
68+
/**
69+
* Recursively collapses `invalid_union` issues to their most likely intended
70+
* variant. Non-union issues pass through unchanged (with paths preserved).
71+
*
72+
* If a union has no variants (defensive) or picking a best variant is not
73+
* possible, the original `invalid_union` issue is preserved so information
74+
* isn't silently lost.
75+
*/
76+
export function simplifyZodIssues (issues: readonly Issue[]): Issue[] {
77+
const out: Issue[] = []
78+
for (const issue of issues) {
79+
if (isInvalidUnion(issue) && Array.isArray(issue.errors) && issue.errors.length > 0) {
80+
const best = pickBestVariant(issue.errors)
81+
if (best !== undefined && best.length > 0) {
82+
const prefixed = best.map(sub => prefixPath(sub, issue.path))
83+
out.push(...simplifyZodIssues(prefixed))
84+
continue
85+
}
86+
}
87+
out.push(issue)
88+
}
89+
return out
90+
}
91+
92+
function formatPath (path: readonly PropertyKey[]): string {
93+
if (path.length === 0) return '(root)'
94+
let s = ''
95+
for (const seg of path) {
96+
if (typeof seg === 'number') {
97+
s += `[${seg}]`
98+
} else {
99+
s += s === '' ? String(seg) : `.${String(seg)}`
100+
}
101+
}
102+
return s
103+
}
104+
105+
/**
106+
* Renders a flat list of issues as human-readable text. Each issue is two lines:
107+
* ✖ <message>
108+
* → at <path>
109+
*/
110+
export function formatIssuesText (issues: readonly Issue[]): string {
111+
if (issues.length === 0) return '✖ Invalid input'
112+
return issues
113+
.map(i => `✖ ${i.message}\n → at ${formatPath(i.path)}`)
114+
.join('\n')
115+
}

test/factory.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,71 @@ describe('defineCommand', () => {
14181418
assert.match(err, /input validation failed/)
14191419
assert.match(err, /address\.zipCode/)
14201420
})
1421+
1422+
it('union-typed field surfaces the matching variant instead of all variants (#172)', async () => {
1423+
// Mirrors QueryDslQueryContainer-style validation: many variants, only
1424+
// one of which the user's input aligns with.
1425+
const schema = z.object({
1426+
query: z.union([
1427+
z.object({ bool: z.object({ must: z.array(z.unknown()) }) }),
1428+
z.object({ match_all: z.object({}) }),
1429+
z.object({ term: z.object({ category: z.object({ value: z.string() }) }) }),
1430+
z.object({ range: z.object({ field: z.string() }) }),
1431+
])
1432+
})
1433+
const filePath = join(tmpDir, 'union-error.json')
1434+
writeFileSync(filePath, JSON.stringify({ query: { term: { category: 'canyon' } } }))
1435+
const cmd = defineCommand({
1436+
name: 'search',
1437+
description: 'Search',
1438+
input: schema,
1439+
handler: () => ({}),
1440+
})
1441+
const err = await captureErrAsync(cmd, ['--input-file', filePath])
1442+
assert.match(err, /input validation failed/)
1443+
assert.match(err, /query\.term\.category/)
1444+
assert.match(err, /expected object/i)
1445+
// None of the shallow "received undefined" discriminator noise leaks out
1446+
assert.ok(!/received undefined/.test(err),
1447+
`discriminator noise leaked: ${err}`)
1448+
// And none of the non-matching variant keys surface as errors
1449+
assert.ok(!/bool|match_all|range/.test(err),
1450+
`non-matching variants leaked: ${err}`)
1451+
})
1452+
1453+
it('JSON mode emits a single actionable issue for union failures (#172)', async () => {
1454+
const schema = z.object({
1455+
query: z.union([
1456+
z.object({ bool: z.object({ must: z.array(z.unknown()) }) }),
1457+
z.object({ match_all: z.object({}) }),
1458+
z.object({ term: z.object({ category: z.object({ value: z.string() }) }) }),
1459+
z.object({ range: z.object({ field: z.string() }) }),
1460+
])
1461+
})
1462+
const filePath = join(tmpDir, 'union-json-error.json')
1463+
writeFileSync(filePath, JSON.stringify({ query: { term: { category: 'canyon' } } }))
1464+
const cmd = defineCommand({
1465+
name: 'search',
1466+
description: 'Search',
1467+
input: schema,
1468+
handler: () => ({}),
1469+
})
1470+
const { stderr } = await invokeCapturingStreams(cmd, ['--json'], ['--input-file', filePath])
1471+
const parsed = JSON.parse(stderr) as {
1472+
error: {
1473+
code: string
1474+
message: string
1475+
issues: Array<{ code: string; path: Array<string|number>; message: string }>
1476+
}
1477+
}
1478+
assert.equal(parsed.error.code, 'input_validation_failed')
1479+
assert.equal(parsed.error.issues.length, 1,
1480+
`expected exactly one issue, got ${parsed.error.issues.length}: ${JSON.stringify(parsed.error.issues)}`)
1481+
const [issue] = parsed.error.issues
1482+
assert.equal(issue.code, 'invalid_type')
1483+
assert.deepEqual(issue.path, ['query', 'term', 'category'])
1484+
assert.match(issue.message, /expected object/i)
1485+
})
14211486
})
14221487

14231488
describe('relaxed validation for JSON body fields (#156)', () => {

test/lib/zod-error.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { describe, it } from 'node:test'
7+
import assert from 'node:assert/strict'
8+
import { z } from 'zod'
9+
import { simplifyZodIssues, formatIssuesText } from '../../src/lib/zod-error.ts'
10+
11+
/**
12+
* Helper: run a schema against input and return the raw issues produced by Zod.
13+
* Using real Zod errors keeps tests honest about the issue shapes we rely on.
14+
*/
15+
function issuesFor (schema: z.ZodType, input: unknown): readonly z.core.$ZodIssue[] {
16+
const r = schema.safeParse(input)
17+
if (r.success) throw new Error('expected validation to fail')
18+
return r.error.issues
19+
}
20+
21+
describe('simplifyZodIssues', () => {
22+
it('passes non-union issues through unchanged', () => {
23+
const schema = z.object({ name: z.string() })
24+
const raw = issuesFor(schema, { name: 42 })
25+
const simplified = simplifyZodIssues(raw)
26+
assert.equal(simplified.length, 1)
27+
assert.equal(simplified[0].code, 'invalid_type')
28+
assert.deepEqual(simplified[0].path, ['name'])
29+
})
30+
31+
it('collapses invalid_union to the variant whose errors reach deepest', () => {
32+
// Mimics a discriminated-ish union: only one branch's shape matches
33+
// the shape of the input, so the correct variant to surface is `term`.
34+
const schema = z.object({
35+
query: z.union([
36+
z.object({ bool: z.object({ must: z.array(z.unknown()) }) }),
37+
z.object({ match_all: z.object({}) }),
38+
z.object({ term: z.object({ category: z.object({ value: z.string() }) }) }),
39+
])
40+
})
41+
const raw = issuesFor(schema, { query: { term: { category: 'canyon' } } })
42+
const simplified = simplifyZodIssues(raw)
43+
// The deepest issue is "expected object, received string" at query.term.category
44+
const deepest = simplified.find(i => i.path.length >= 3)
45+
assert.ok(deepest, `expected a deep issue, got: ${JSON.stringify(simplified)}`)
46+
assert.deepEqual(deepest.path, ['query', 'term', 'category'])
47+
assert.equal(deepest.code, 'invalid_type')
48+
assert.match(deepest.message, /expected object/i)
49+
// No 'received undefined' noise from non-matching variants
50+
for (const i of simplified) {
51+
assert.ok(!/received undefined/.test(i.message),
52+
`leaked discriminator noise: ${i.message} at ${JSON.stringify(i.path)}`)
53+
}
54+
})
55+
56+
it('flattens nested unions recursively', () => {
57+
// outer union picks `outer.nested` variant -> inner union picks the variant
58+
// whose object matches the input shape.
59+
const schema = z.object({
60+
outer: z.union([
61+
z.object({ simple: z.string() }),
62+
z.object({
63+
nested: z.union([
64+
z.object({ a: z.object({ value: z.string() }) }),
65+
z.object({ b: z.object({ value: z.string() }) }),
66+
])
67+
}),
68+
])
69+
})
70+
const raw = issuesFor(schema, { outer: { nested: { a: 'bad' } } })
71+
const simplified = simplifyZodIssues(raw)
72+
const deepest = simplified.find(i => i.path.includes('a'))
73+
assert.ok(deepest, `expected nested-a issue, got: ${JSON.stringify(simplified)}`)
74+
assert.deepEqual(deepest.path, ['outer', 'nested', 'a'])
75+
// No invalid_union should remain after flattening
76+
assert.ok(simplified.every(i => i.code !== 'invalid_union'),
77+
'invalid_union leaked through')
78+
})
79+
80+
it('prepends union issue path to chosen variant sub-issues', () => {
81+
const schema = z.object({
82+
wrapper: z.object({
83+
q: z.union([
84+
z.object({ term: z.object({ field: z.string() }) }),
85+
z.object({ match: z.object({ field: z.string() }) }),
86+
])
87+
})
88+
})
89+
const raw = issuesFor(schema, { wrapper: { q: { term: { field: 42 } } } })
90+
const simplified = simplifyZodIssues(raw)
91+
const target = simplified.find(i => i.code === 'invalid_type')
92+
assert.ok(target)
93+
assert.deepEqual(target.path, ['wrapper', 'q', 'term', 'field'])
94+
})
95+
96+
it('preserves the original issue when no variants are available', () => {
97+
// A union with empty errors array (the rare "multiple match" case). We
98+
// construct it manually since there is no schema shape that produces it
99+
// in a stable way.
100+
const fabricated: z.core.$ZodIssue = {
101+
code: 'invalid_union',
102+
path: ['root'],
103+
message: 'Invalid input',
104+
errors: []
105+
} as z.core.$ZodIssue
106+
const simplified = simplifyZodIssues([fabricated])
107+
assert.equal(simplified.length, 1)
108+
assert.equal(simplified[0].code, 'invalid_union')
109+
})
110+
111+
it('handles multiple top-level issues independently', () => {
112+
const schema = z.object({
113+
a: z.union([z.object({ x: z.string() }), z.object({ y: z.string() })]),
114+
b: z.number(),
115+
})
116+
const raw = issuesFor(schema, { a: { x: 1 }, b: 'bad' })
117+
const simplified = simplifyZodIssues(raw)
118+
// Both fields represented: b directly, a via its best variant
119+
assert.ok(simplified.some(i => i.path[0] === 'b' && i.code === 'invalid_type'))
120+
assert.ok(simplified.some(i => i.path[0] === 'a'))
121+
})
122+
})
123+
124+
describe('formatIssuesText', () => {
125+
it('renders one issue as two lines', () => {
126+
const out = formatIssuesText([
127+
{ code: 'invalid_type', path: ['name'], message: 'expected string, received number' } as z.core.$ZodIssue
128+
])
129+
assert.equal(out, '✖ expected string, received number\n → at name')
130+
})
131+
132+
it('joins multiple issues with newlines', () => {
133+
const out = formatIssuesText([
134+
{ code: 'invalid_type', path: ['name'], message: 'A' } as z.core.$ZodIssue,
135+
{ code: 'invalid_type', path: ['count'], message: 'B' } as z.core.$ZodIssue,
136+
])
137+
assert.equal(out.split('\n').length, 4)
138+
assert.match(out, /at name/)
139+
assert.match(out, /at count/)
140+
})
141+
142+
it('formats nested paths with dots and array indices with brackets', () => {
143+
const out = formatIssuesText([
144+
{ code: 'invalid_type', path: ['a', 'b', 0, 'c'], message: 'boom' } as z.core.$ZodIssue
145+
])
146+
assert.match(out, /at a\.b\[0\]\.c/)
147+
})
148+
149+
it('renders empty list as generic message', () => {
150+
assert.equal(formatIssuesText([]), '✖ Invalid input')
151+
})
152+
153+
it('renders root-level issues with "(root)" path', () => {
154+
const out = formatIssuesText([
155+
{ code: 'invalid_type', path: [], message: 'broken' } as z.core.$ZodIssue
156+
])
157+
assert.match(out, /at \(root\)/)
158+
})
159+
})

0 commit comments

Comments
 (0)