Skip to content

Commit

Permalink
Merge pull request #9 from enormora/union
Browse files Browse the repository at this point in the history
Format invalid_union issues
  • Loading branch information
lo1tuma authored Mar 22, 2024
2 parents aba6992 + 182abac commit 7def1d8
Show file tree
Hide file tree
Showing 6 changed files with 386 additions and 5 deletions.
54 changes: 54 additions & 0 deletions integration-tests/zod-error-formatter/union.test.ts
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'
]);
});
10 changes: 10 additions & 0 deletions source/zod-error-formatter/format-issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
4 changes: 3 additions & 1 deletion source/zod-error-formatter/format-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
191 changes: 191 additions & 0 deletions source/zod-error-formatter/issue-specific/invalid-union.test.ts
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 source/zod-error-formatter/issue-specific/invalid-union.ts
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';
}
Loading

0 comments on commit 7def1d8

Please sign in to comment.