-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #9 from enormora/union
Format invalid_union issues
- Loading branch information
Showing
6 changed files
with
386 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { test } from '@sondr3/minitest'; | ||
import assert from 'node:assert'; | ||
import { z } from 'zod'; | ||
import { safeParse } from '../../source/zod-error-formatter/formatter.js'; | ||
|
||
test('formats messages for invalid union schemas with primitives correctly', () => { | ||
const schema = z.union([z.string(), z.number()]); | ||
const result = safeParse(schema, true); | ||
|
||
assert.strictEqual(result.success, false); | ||
assert.deepStrictEqual(result.error.issues, [ | ||
'invalid value: expected one of string or number, but got boolean' | ||
]); | ||
}); | ||
|
||
test('formats messages for invalid union schemas with literals correctly', () => { | ||
const schema = z.union([z.literal('a'), z.literal(1)]); | ||
const result = safeParse(schema, true); | ||
|
||
assert.strictEqual(result.success, false); | ||
assert.deepStrictEqual(result.error.issues, [ | ||
'invalid value: expected one of "a" or 1, but got boolean' | ||
]); | ||
}); | ||
|
||
test('formats messages for invalid union schemas with literals and primitives correctly', () => { | ||
const schema = z.union([z.literal('a'), z.number()]); | ||
const result = safeParse(schema, true); | ||
|
||
assert.strictEqual(result.success, false); | ||
assert.deepStrictEqual(result.error.issues, [ | ||
'invalid value: expected one of "a" or number, but got boolean' | ||
]); | ||
}); | ||
|
||
test('formats messages for invalid union schemas with objects correctly', () => { | ||
const schema = z.union([z.object({ a: z.string() }), z.number()]); | ||
const result = safeParse(schema, true); | ||
|
||
assert.strictEqual(result.success, false); | ||
assert.deepStrictEqual(result.error.issues, [ | ||
'invalid value: expected one of object or number, but got boolean' | ||
]); | ||
}); | ||
|
||
test('formats messages for invalid union schemas with only objects correctly', () => { | ||
const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]); | ||
const result = safeParse(schema, {}); | ||
|
||
assert.strictEqual(result.success, false); | ||
assert.deepStrictEqual(result.error.issues, [ | ||
'invalid value doesn’t match expected union' | ||
]); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
191 changes: 191 additions & 0 deletions
191
source/zod-error-formatter/issue-specific/invalid-union.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
import { test } from '@sondr3/minitest'; | ||
import assert from 'node:assert'; | ||
import { ZodError } from 'zod'; | ||
import { formatInvalidUnionIssueMessage } from './invalid-union.js'; | ||
|
||
test('formats the invalid union issue correctly when there are no union errors', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [] | ||
}); | ||
assert.strictEqual(message, 'invalid value doesn’t match expected union'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly when there are only union errors without issues', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [new ZodError([]), new ZodError([])] | ||
}); | ||
assert.strictEqual(message, 'invalid value doesn’t match expected union'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly when there is only one union error with one invalid type issue', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'string', received: 'null' }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected string, but got null'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly when there are only invalid type issues', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'string', received: 'null' }]), | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'number', received: 'null' }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected one of string or number, but got null'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly given only invalid type issues in nested unions', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'string', received: 'null' }]), | ||
new ZodError([{ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ | ||
code: 'invalid_type', | ||
path: [], | ||
message: '', | ||
expected: 'number', | ||
received: 'null' | ||
}]) | ||
] | ||
}]), | ||
new ZodError([{ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ | ||
code: 'invalid_type', | ||
path: [], | ||
message: '', | ||
expected: 'boolean', | ||
received: 'null' | ||
}]) | ||
] | ||
}]) | ||
] | ||
}]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected one of string, number or boolean, but got null'); | ||
}); | ||
|
||
test('formats the issue correctly when there are only invalid type issues but all have the same expected type', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'string', received: 'null' }]), | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'string', received: 'null' }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected string, but got null'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly when there are only invalid literal issues', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_literal', path: [], message: '', expected: 'foo', received: 'bar' }]), | ||
new ZodError([{ code: 'invalid_literal', path: [], message: '', expected: 'baz', received: 'bar' }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected one of "foo" or "baz", but got string'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly when there is a invalid literal issues with null as expected', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_literal', path: [], message: '', expected: null, received: 'bar' }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected null, but got string'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly when there is a invalid literal with non-primitive as expected', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_literal', path: [], message: '', expected: {}, received: 'bar' }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected "{}", but got string'); | ||
}); | ||
|
||
test('formats the invalid union issue correctly when there are only invalid literal or invalid type issues', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'number', received: 'boolean' }]), | ||
new ZodError([{ code: 'invalid_literal', path: [], message: '', expected: 'foo', received: true }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value: expected one of number or "foo", but got boolean'); | ||
}); | ||
|
||
test('formats the issue correctly when there are multiple issues but not all are invalid type issues', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: [], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_type', path: [], message: '', expected: 'string', received: 'null' }]), | ||
new ZodError([{ code: 'custom', path: [], message: '' }]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value doesn’t match expected union'); | ||
}); | ||
|
||
test('formats the issue correctly given multiple invalid type issues but some of them are on a different path', () => { | ||
const message = formatInvalidUnionIssueMessage({ | ||
code: 'invalid_union', | ||
path: ['foo'], | ||
message: '', | ||
unionErrors: [ | ||
new ZodError([{ code: 'invalid_type', path: ['foo'], message: '', expected: 'string', received: 'null' }]), | ||
new ZodError([{ | ||
code: 'invalid_type', | ||
path: ['foo', 'bar'], | ||
message: '', | ||
expected: 'number', | ||
received: 'null' | ||
}]) | ||
] | ||
}); | ||
assert.strictEqual(message, 'invalid value doesn’t match expected union'); | ||
}); |
114 changes: 114 additions & 0 deletions
114
source/zod-error-formatter/issue-specific/invalid-union.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import { | ||
getParsedType, | ||
type Primitive, | ||
type ZodError, | ||
type ZodInvalidLiteralIssue, | ||
type ZodInvalidTypeIssue, | ||
type ZodInvalidUnionIssue, | ||
type ZodIssue | ||
} from 'zod'; | ||
import { formatOneOfList, isParsedType, type ListValue } from '../list.js'; | ||
|
||
function flattenAllIssues(errors: readonly ZodError[]): readonly ZodIssue[] { | ||
return errors.flatMap((error) => { | ||
return error.issues.flatMap((issue) => { | ||
if (issue.code === 'invalid_union') { | ||
return flattenAllIssues(issue.unionErrors); | ||
} | ||
return issue; | ||
}); | ||
}); | ||
} | ||
|
||
function isSamePath(pathA: readonly (number | string)[], pathB: readonly (number | string)[]): boolean { | ||
if (pathA.length !== pathB.length) { | ||
return false; | ||
} | ||
|
||
return pathA.every((element, index) => { | ||
return element === pathB[index]; | ||
}); | ||
} | ||
|
||
type SupportedIssueType = ZodInvalidLiteralIssue | ZodInvalidTypeIssue; | ||
// eslint-disable-next-line @typescript-eslint/ban-types -- we don’t have type-fest here | ||
type BaseZodIssue = Omit<ZodIssue, 'fatal' | 'message'>; | ||
|
||
function isSupportedIssueWithSamePath( | ||
issue: BaseZodIssue, | ||
expectedPath: readonly (number | string)[] | ||
): issue is SupportedIssueType { | ||
return ['invalid_type', 'invalid_literal'].includes(issue.code) && isSamePath(issue.path, expectedPath); | ||
} | ||
|
||
function filterSupportedIssuesWithSamePath( | ||
issues: readonly BaseZodIssue[], | ||
expectedPath: readonly (number | string)[] | ||
): readonly SupportedIssueType[] { | ||
return issues.filter((issue): issue is SupportedIssueType => { | ||
return isSupportedIssueWithSamePath(issue, expectedPath); | ||
}); | ||
} | ||
|
||
function determineReceivedValue(issue: SupportedIssueType): string { | ||
if (issue.code === 'invalid_type') { | ||
return issue.received; | ||
} | ||
|
||
return getParsedType(issue.received); | ||
} | ||
|
||
function isPrimitive(value: unknown): value is Primitive { | ||
return ['string', 'number', 'symbol', 'bigint', 'boolean', 'undefined'].includes(typeof value) || value === null; | ||
} | ||
|
||
function determineExpectedValue(issue: SupportedIssueType): ListValue { | ||
if (issue.code === 'invalid_type') { | ||
return { type: issue.expected }; | ||
} | ||
|
||
if (isPrimitive(issue.expected)) { | ||
return issue.expected; | ||
} | ||
|
||
return JSON.stringify(issue.expected); | ||
} | ||
|
||
function hasValue(values: readonly ListValue[], expectedValue: ListValue): boolean { | ||
return values.some((value) => { | ||
if (isParsedType(value) && isParsedType(expectedValue)) { | ||
return value.type === expectedValue.type; | ||
} | ||
return value === expectedValue; | ||
}); | ||
} | ||
|
||
function removeDuplicateListValues(values: readonly ListValue[]): readonly ListValue[] { | ||
const uniqueValues: ListValue[] = []; | ||
|
||
for (const value of values) { | ||
if (!hasValue(uniqueValues, value)) { | ||
uniqueValues.push(value); | ||
} | ||
} | ||
|
||
return uniqueValues; | ||
} | ||
|
||
export function formatInvalidUnionIssueMessage(issue: ZodInvalidUnionIssue): string { | ||
const memberIssues = flattenAllIssues(issue.unionErrors); | ||
const supportedIssues = filterSupportedIssuesWithSamePath(memberIssues, issue.path); | ||
|
||
if (memberIssues.length === supportedIssues.length) { | ||
const [firstIssue] = supportedIssues; | ||
|
||
if (firstIssue !== undefined) { | ||
const expectedValues = removeDuplicateListValues(supportedIssues.map(determineExpectedValue)); | ||
const receivedValue = determineReceivedValue(firstIssue); | ||
|
||
return `invalid value: expected ${formatOneOfList(expectedValues)}, but got ${receivedValue}`; | ||
} | ||
} | ||
|
||
return 'invalid value doesn’t match expected union'; | ||
} |
Oops, something went wrong.