From 182abac27a2c8701df6f1ce158488c506ff9cfee Mon Sep 17 00:00:00 2001 From: Mathias Schreck Date: Fri, 22 Mar 2024 17:17:35 +0100 Subject: [PATCH] Format invalid_union issues --- .../zod-error-formatter/union.test.ts | 54 +++++ .../zod-error-formatter/format-issue.test.ts | 10 + source/zod-error-formatter/format-issue.ts | 4 +- .../issue-specific/invalid-union.test.ts | 191 ++++++++++++++++++ .../issue-specific/invalid-union.ts | 114 +++++++++++ source/zod-error-formatter/list.ts | 18 +- 6 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 integration-tests/zod-error-formatter/union.test.ts create mode 100644 source/zod-error-formatter/issue-specific/invalid-union.test.ts create mode 100644 source/zod-error-formatter/issue-specific/invalid-union.ts diff --git a/integration-tests/zod-error-formatter/union.test.ts b/integration-tests/zod-error-formatter/union.test.ts new file mode 100644 index 0000000..a1ed650 --- /dev/null +++ b/integration-tests/zod-error-formatter/union.test.ts @@ -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' + ]); +}); diff --git a/source/zod-error-formatter/format-issue.test.ts b/source/zod-error-formatter/format-issue.test.ts index d88a0e1..6f4cfc0 100644 --- a/source/zod-error-formatter/format-issue.test.ts +++ b/source/zod-error-formatter/format-issue.test.ts @@ -108,3 +108,13 @@ test('returns the formatted issue when an invalid_union_discriminator issue is g }); assert.strictEqual(formattedIssue, 'at foo: invalid discriminator value, expected "a"'); }); + +test('returns the formatted issue when an invalid_union issue is given', () => { + const formattedIssue = formatIssue({ + code: 'invalid_union', + path: ['foo'], + message: '', + unionErrors: [] + }); + assert.strictEqual(formattedIssue, 'at foo: invalid value doesn’t match expected union'); +}); diff --git a/source/zod-error-formatter/format-issue.ts b/source/zod-error-formatter/format-issue.ts index fac7824..2142f28 100644 --- a/source/zod-error-formatter/format-issue.ts +++ b/source/zod-error-formatter/format-issue.ts @@ -5,6 +5,7 @@ import { formatInvalidLiteralIssueMessage } from './issue-specific/invalid-liter import { formatInvalidStringIssueMessage } from './issue-specific/invalid-string.js'; import { formatInvalidTypeIssueMessage } from './issue-specific/invalid-type.js'; import { formatInvalidUnionDiscriminatorIssueMessage } from './issue-specific/invalid-union-discriminator.js'; +import { formatInvalidUnionIssueMessage } from './issue-specific/invalid-union.js'; import { formatNotMultipleOfIssueMessage } from './issue-specific/not-multiple-of.js'; import { formatTooBigIssueMessage } from './issue-specific/too-big.js'; import { formatTooSmallIssueMessage } from './issue-specific/too-small.js'; @@ -26,7 +27,8 @@ const issueCodeToFormatterMap: FormatterMap = { not_multiple_of: formatNotMultipleOfIssueMessage, invalid_enum_value: formatInvalidEnumValueIssueMessage, invalid_string: formatInvalidStringIssueMessage, - invalid_union_discriminator: formatInvalidUnionDiscriminatorIssueMessage + invalid_union_discriminator: formatInvalidUnionDiscriminatorIssueMessage, + invalid_union: formatInvalidUnionIssueMessage }; export function formatIssue(issue: ZodIssue): string { diff --git a/source/zod-error-formatter/issue-specific/invalid-union.test.ts b/source/zod-error-formatter/issue-specific/invalid-union.test.ts new file mode 100644 index 0000000..b0c6156 --- /dev/null +++ b/source/zod-error-formatter/issue-specific/invalid-union.test.ts @@ -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'); +}); diff --git a/source/zod-error-formatter/issue-specific/invalid-union.ts b/source/zod-error-formatter/issue-specific/invalid-union.ts new file mode 100644 index 0000000..d358550 --- /dev/null +++ b/source/zod-error-formatter/issue-specific/invalid-union.ts @@ -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; + +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'; +} diff --git a/source/zod-error-formatter/list.ts b/source/zod-error-formatter/list.ts index 19e27a6..6d25cc4 100644 --- a/source/zod-error-formatter/list.ts +++ b/source/zod-error-formatter/list.ts @@ -1,4 +1,4 @@ -import type { Primitive } from 'zod'; +import type { Primitive, ZodParsedType } from 'zod'; import { isNonEmptyArray, type NonEmptyArray } from './non-empty-array.js'; function joinList(values: NonEmptyArray, separator: string, lastItemSeparator: string): string { @@ -13,7 +13,17 @@ function joinList(values: NonEmptyArray, separator: string, lastItemSepa return `${joinedInitialList}${lastItemSeparator}${lastItem}`; } -function stringify(value: Primitive): string { +type ParsedType = { type: ZodParsedType; }; +export type ListValue = ParsedType | Primitive; + +export function isParsedType(value: ListValue): value is ParsedType { + return typeof value === 'object' && value !== null && Object.hasOwn(value, 'type'); +} + +function stringify(value: ListValue): string { + if (isParsedType(value)) { + return value.type; + } if (value === undefined) { return 'undefined'; } @@ -24,7 +34,7 @@ function stringify(value: Primitive): string { return JSON.stringify(value); } -export function formatList(values: readonly Primitive[], lastItemSeparator: string): string { +export function formatList(values: readonly ListValue[], lastItemSeparator: string): string { const escapedValues = values.map(stringify); if (!isNonEmptyArray(escapedValues)) { return 'unknown'; @@ -32,7 +42,7 @@ export function formatList(values: readonly Primitive[], lastItemSeparator: stri return joinList(escapedValues, ', ', lastItemSeparator); } -export function formatOneOfList(values: readonly Primitive[]): string { +export function formatOneOfList(values: readonly ListValue[]): string { const formattedList = formatList(values, ' or '); if (values.length > 1) {