From bddf6b2736de7f6234de3476f1c6db11971086d8 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sat, 31 Aug 2024 14:46:50 +0200 Subject: [PATCH] Improve performance and issues for nested variants --- library/CHANGELOG.md | 1 + library/src/schemas/variant/types.ts | 4 +- .../_discriminators/_discriminators.test.ts | 52 -- .../utils/_discriminators/_discriminators.ts | 27 -- .../variant/utils/_discriminators/index.ts | 1 - library/src/schemas/variant/utils/index.ts | 1 - library/src/schemas/variant/variant.test.ts | 427 ++++++++++++++++- library/src/schemas/variant/variant.ts | 159 ++++--- .../src/schemas/variant/variantAsync.test.ts | 445 +++++++++++++++++- library/src/schemas/variant/variantAsync.ts | 166 ++++--- .../routes/api/(async)/variantAsync/index.mdx | 2 + .../routes/api/(schemas)/variant/index.mdx | 35 ++ 12 files changed, 1122 insertions(+), 198 deletions(-) delete mode 100644 library/src/schemas/variant/utils/_discriminators/_discriminators.test.ts delete mode 100644 library/src/schemas/variant/utils/_discriminators/_discriminators.ts delete mode 100644 library/src/schemas/variant/utils/_discriminators/index.ts delete mode 100644 library/src/schemas/variant/utils/index.ts diff --git a/library/CHANGELOG.md b/library/CHANGELOG.md index 9c093644e..3e16d48ce 100644 --- a/library/CHANGELOG.md +++ b/library/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) - Change `reference` property of all action base types to be less strict (issue #799) +- Change implementation of `variant` and `variantAsync` to improve performance and issues generation for nested variants with different discriminators (pull request #809) ## v0.40.0 (August 29, 2024) diff --git a/library/src/schemas/variant/types.ts b/library/src/schemas/variant/types.ts index e8d6a7044..387dc8cae 100644 --- a/library/src/schemas/variant/types.ts +++ b/library/src/schemas/variant/types.ts @@ -50,7 +50,7 @@ export interface VariantIssue extends BaseIssue { /** * Variant option schema type. */ -interface VariantOptionSchema +export interface VariantOptionSchema extends BaseSchema> { readonly type: 'variant'; readonly reference: typeof variant; @@ -62,7 +62,7 @@ interface VariantOptionSchema /** * Variant option schema async type. */ -interface VariantOptionSchemaAsync +export interface VariantOptionSchemaAsync extends BaseSchemaAsync> { readonly type: 'variant'; readonly reference: typeof variantAsync; diff --git a/library/src/schemas/variant/utils/_discriminators/_discriminators.test.ts b/library/src/schemas/variant/utils/_discriminators/_discriminators.test.ts deleted file mode 100644 index 41b8c028c..000000000 --- a/library/src/schemas/variant/utils/_discriminators/_discriminators.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { boolean } from '../../../boolean/boolean.ts'; -import { literal } from '../../../literal/literal.ts'; -import { number } from '../../../number/index.ts'; -import { object } from '../../../object/object.ts'; -import { string } from '../../../string/index.ts'; -import { variant } from '../../variant.ts'; -import { _discriminators } from './_discriminators.ts'; - -describe('discriminators', () => { - test('should return empty set', () => { - expect(_discriminators('type', [])).toStrictEqual([]); - }); - - test('should return set with one key', () => { - expect( - _discriminators('type', [object({ type: literal('foo') })]) - ).toStrictEqual(['"foo"']); - }); - - test('should return set with multiple keys', () => { - expect( - _discriminators('type', [ - object({ type: string() }), - object({ type: string() }), - ]) - ).toStrictEqual(['string', 'string']); - - expect( - _discriminators('type', [ - object({ type: literal('foo') }), - variant('type', [object({ type: literal('foo') })]), - ]) - ).toStrictEqual(['"foo"', '"foo"']); - - expect( - _discriminators('type', [ - object({ type: literal('foo') }), - object({ type: literal('bar') }), - object({ type: literal('baz') }), - ]) - ).toStrictEqual(['"foo"', '"bar"', '"baz"']); - - expect( - _discriminators('type', [ - object({ type: string() }), - object({ type: number() }), - variant('other', [object({ type: boolean(), other: literal('foo') })]), - ]) - ).toStrictEqual(['string', 'number', 'boolean']); - }); -}); diff --git a/library/src/schemas/variant/utils/_discriminators/_discriminators.ts b/library/src/schemas/variant/utils/_discriminators/_discriminators.ts deleted file mode 100644 index 7a6cad037..000000000 --- a/library/src/schemas/variant/utils/_discriminators/_discriminators.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { VariantOptions, VariantOptionsAsync } from '../../types.ts'; - -/** - * Returns the expected discriminators of a variant schema. - * - * @param key The discriminator key. - * @param options The variant options. - * @param list The expected discriminators. - * - * @returns The expected discriminators. - * - * @internal - */ -export function _discriminators( - key: string, - options: VariantOptions | VariantOptionsAsync, - list: string[] = [] -): string[] { - for (const schema of options) { - if (schema.type === 'variant') { - _discriminators(key, schema.options, list); - } else { - list.push(schema.entries[key].expects); - } - } - return list; -} diff --git a/library/src/schemas/variant/utils/_discriminators/index.ts b/library/src/schemas/variant/utils/_discriminators/index.ts deleted file mode 100644 index e752335fe..000000000 --- a/library/src/schemas/variant/utils/_discriminators/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './_discriminators.ts'; diff --git a/library/src/schemas/variant/utils/index.ts b/library/src/schemas/variant/utils/index.ts deleted file mode 100644 index be51d2538..000000000 --- a/library/src/schemas/variant/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './_discriminators/index.ts'; diff --git a/library/src/schemas/variant/variant.test.ts b/library/src/schemas/variant/variant.test.ts index ed7b4db40..d6af9a835 100644 --- a/library/src/schemas/variant/variant.test.ts +++ b/library/src/schemas/variant/variant.test.ts @@ -242,15 +242,51 @@ describe('variant', () => { } satisfies UntypedDataset>); }); + test('for nested missing discriminator', () => { + const schema = variant('type', [ + object({ type: literal('foo') }), + variant('other', [ + object({ type: literal('bar'), other: string() }), + object({ type: literal('bar'), other: boolean() }), + object({ type: literal('baz'), other: number() }), + ]), + ]); + const input = { type: 'bar' }; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: undefined, + expected: '(string | boolean)', + received: 'undefined', + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'other', + value: undefined, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + test('for nested invalid discriminator', () => { const schema = variant('type', [ object({ type: literal('foo') }), variant('other', [ object({ type: literal('bar'), other: string() }), + object({ type: literal('bar'), other: boolean() }), object({ type: literal('baz'), other: number() }), ]), ]); - const input = { type: 'bar', other: null }; + const input = { type: 'bar', other: 123 }; expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ typed: false, value: input, @@ -260,7 +296,7 @@ describe('variant', () => { kind: 'schema', type: 'variant', input: input.other, - expected: '(string | number)', + expected: '(string | boolean)', received: `${input.other}`, path: [ { @@ -276,6 +312,393 @@ describe('variant', () => { } satisfies UntypedDataset>); }); + test('for first missing invalid discriminator', () => { + const schema = variant('type', [ + variant('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variant('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = {}; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: undefined, + expected: '("foo" | "bar")', + received: 'undefined', + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'type', + value: undefined, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested missing discriminator', () => { + const schema = variant('type', [ + variant('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variant('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar' }; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: undefined, + expected: '"bar-1"', + received: 'undefined', + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType1', + value: undefined, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', () => { + const schema = variant('type', [ + variant('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variant('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType2: 'baz-2' }; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '"bar-2"', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', () => { + const schema = variant('type', [ + variant('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variant('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType1: 'invalid', subType2: 'invalid' }; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType1, + expected: '"bar-1"', + received: `"${input.subType1}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType1', + value: input.subType1, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', () => { + const schema = variant('type', [ + variant('subType1', [ + variant('subType2', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + subType2: literal('foo-2'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + subType2: literal('bar-2'), + other2: string(), + }), + ]), + ]), + variant('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foz-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('baz-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType1: 'bar-1', subType2: 'invalid' }; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '("bar-2" | "baz-2")', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', () => { + const schema = variant('type', [ + variant('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foz-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('baz-2'), + other4: string(), + }), + ]), + variant('subType1', [ + variant('subType2', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + subType2: literal('foo-2'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + subType2: literal('bar-2'), + other2: string(), + }), + ]), + ]), + ]); + const input = { type: 'bar', subType1: 'bar-1', subType2: 'invalid' }; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '("baz-2" | "bar-2")', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', () => { + const schema = variant('type', [ + variant('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + variant('subType2', [ + object({ + type: literal('bar'), + subType1: literal('baz-1'), + subType2: literal('baz-2'), + other5: string(), + }), + ]), + ]), + variant('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType2: 'baz-2' }; + expect(schema._run({ typed: false, value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '"bar-2"', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + test('for untyped object', () => { const schema = variant('type', [ object({ type: literal('foo'), other: bigint() }), diff --git a/library/src/schemas/variant/variant.ts b/library/src/schemas/variant/variant.ts index da20f8049..5164648f2 100644 --- a/library/src/schemas/variant/variant.ts +++ b/library/src/schemas/variant/variant.ts @@ -11,8 +11,8 @@ import type { InferVariantIssue, VariantIssue, VariantOptions, + VariantOptionSchema, } from './types.ts'; -import { _discriminators } from './utils/index.ts'; /** * Variant schema type. @@ -93,7 +93,6 @@ export function variant( VariantOptions, ErrorMessage | undefined > { - let expectedDiscriminators: string | undefined; return { kind: 'schema', type: 'variant', @@ -109,72 +108,124 @@ export function variant( // If root type is valid, check nested types if (input && typeof input === 'object') { - // Get discriminator from input - // @ts-expect-error - const discriminator: unknown = input[this.key]; - - // If key is in input, parse schema of each option - if (this.key in input) { - // Create output dataset variable - let outputDataset: Dataset> | undefined; - - // Parse only if it is a variant schema or if discriminator matches - for (const schema of this.options) { - if ( - schema.type === 'variant' || - !schema.entries[this.key]._run( - { typed: false, value: discriminator }, - config - ).issues - ) { - // Parse input with schema of option - const optionDataset = schema._run( - { typed: false, value: input }, - config - ); - - // If valid option is found, return its dataset - if (!optionDataset.issues) { - return optionDataset; + // Create output dataset variable + let outputDataset: Dataset> | undefined; + + // Create variables to store invalid discriminator information + let maxDiscriminatorPriority = 0; + let invalidDiscriminatorKey = this.key; + let expectedDiscriminators: string[] = []; + + // Create recursive function to parse nested variant options + const parseOptions = ( + variant: VariantOptionSchema, + allKeys: Set + ) => { + for (const schema of variant.options) { + // If it is a variant schema, parse its options recursively + if (schema.type === 'variant') { + parseOptions(schema, new Set(allKeys).add(schema.key)); + + // Otherwise, check discriminators and parse object schema + } else { + // Create variables to store local discriminator information + let keysAreValid = true; + let currentPriority = 0; + + // Check if all discriminator keys are valid and collect + // information about invalid discriminator keys if not + for (const currentKey of allKeys) { + // If any discriminator is invalid, mark keys as invalid + if ( + schema.entries[currentKey]._run( + // @ts-expect-error + { typed: false, value: input[currentKey] }, + config + ).issues + ) { + keysAreValid = false; + + // If invalid discriminator key is not equal to current key + // and if current key has a higher priority or same priority + // but is the first one present in input, reset invalid + // discriminator information + if ( + invalidDiscriminatorKey !== currentKey && + (maxDiscriminatorPriority < currentPriority || + (maxDiscriminatorPriority === currentPriority && + currentKey in input && + !(invalidDiscriminatorKey in input))) + ) { + maxDiscriminatorPriority = currentPriority; + invalidDiscriminatorKey = currentKey; + expectedDiscriminators = []; + } + + // If invalid discriminator key is equal to current key, + // store its expected value + if (invalidDiscriminatorKey === currentKey) { + expectedDiscriminators.push( + schema.entries[currentKey].expects + ); + } + + // Break loop on first invalid discriminator key + break; + } + + // Increase priority for next discriminator key + currentPriority++; } - // Otherwise, replace output dataset if necessary - // Hint: Only the first untyped or typed dataset is returned, and - // typed datasets take precedence over untyped ones. - if ( - !outputDataset || - (!outputDataset.typed && optionDataset.typed) - ) { - outputDataset = optionDataset; + // If all discriminators are valid, parse input with schema of option + if (keysAreValid) { + const optionDataset = schema._run( + { typed: false, value: input }, + config + ); + + // Store output dataset if necessary + // Hint: Only the first untyped or typed dataset is returned, and + // typed datasets take precedence over untyped ones. + if ( + !outputDataset || + (!outputDataset.typed && optionDataset.typed) + ) { + outputDataset = optionDataset; + } } } - } - // If any output dataset is available, return it - if (outputDataset) { - return outputDataset; + // If valid option is found, break loop + // Hint: The `break` statement is intentionally placed at the end of + // the loop to break any outer loops in case of recursive execution. + if (outputDataset && !outputDataset.issues) { + break; + } } - } + }; + + // Parse input with nested variant options recursively + parseOptions(this, new Set([this.key])); - // Otherwise, cache expected discriminators if necessary - if (!expectedDiscriminators) { - expectedDiscriminators = _joinExpects( - _discriminators(this.key, this.options), - '|' - ); + // If any output dataset is available, return it + if (outputDataset) { + return outputDataset; } - // And add discriminator issue + // Otherwise, add discriminator issue _addIssue(this, 'type', dataset, config, { - input: discriminator, - expected: expectedDiscriminators, + // @ts-expect-error + input: input[invalidDiscriminatorKey], + expected: _joinExpects(expectedDiscriminators, '|'), path: [ { type: 'object', origin: 'value', input: input as Record, - key: this.key, - value: discriminator, + key: invalidDiscriminatorKey, + // @ts-expect-error + value: input[invalidDiscriminatorKey], }, ], }); @@ -184,7 +235,7 @@ export function variant( _addIssue(this, 'type', dataset, config); } - // Return output dataset + // Finally, return output dataset return dataset as Dataset< InferOutput[number]>, VariantIssue | BaseIssue diff --git a/library/src/schemas/variant/variantAsync.test.ts b/library/src/schemas/variant/variantAsync.test.ts index 8524bea89..fae71a389 100644 --- a/library/src/schemas/variant/variantAsync.test.ts +++ b/library/src/schemas/variant/variantAsync.test.ts @@ -253,15 +253,53 @@ describe('variantAsync', () => { } satisfies UntypedDataset>); }); + test('for nested missing discriminator', async () => { + const schema = variantAsync('type', [ + object({ type: literal('foo') }), + variantAsync('other', [ + object({ type: literal('bar'), other: string() }), + object({ type: literal('bar'), other: boolean() }), + object({ type: literal('baz'), other: number() }), + ]), + ]); + const input = { type: 'bar' }; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: undefined, + expected: '(string | boolean)', + received: 'undefined', + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'other', + value: undefined, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + test('for nested invalid discriminator', async () => { const schema = variantAsync('type', [ object({ type: literal('foo') }), variantAsync('other', [ object({ type: literal('bar'), other: string() }), - objectAsync({ type: literal('baz'), other: number() }), + object({ type: literal('bar'), other: boolean() }), + object({ type: literal('baz'), other: number() }), ]), ]); - const input = { type: 'bar', other: null }; + const input = { type: 'bar', other: 123 }; expect( await schema._run({ typed: false, value: input }, {}) ).toStrictEqual({ @@ -273,7 +311,7 @@ describe('variantAsync', () => { kind: 'schema', type: 'variant', input: input.other, - expected: '(string | number)', + expected: '(string | boolean)', received: `${input.other}`, path: [ { @@ -289,6 +327,407 @@ describe('variantAsync', () => { } satisfies UntypedDataset>); }); + test('for first missing invalid discriminator', async () => { + const schema = variantAsync('type', [ + variantAsync('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = {}; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: undefined, + expected: '("foo" | "bar")', + received: 'undefined', + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'type', + value: undefined, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested missing discriminator', async () => { + const schema = variantAsync('type', [ + variantAsync('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar' }; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: undefined, + expected: '"bar-1"', + received: 'undefined', + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType1', + value: undefined, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', async () => { + const schema = variantAsync('type', [ + variantAsync('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType2: 'baz-2' }; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '"bar-2"', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', async () => { + const schema = variantAsync('type', [ + variantAsync('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + ]), + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType1: 'invalid', subType2: 'invalid' }; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType1, + expected: '"bar-1"', + received: `"${input.subType1}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType1', + value: input.subType1, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', async () => { + const schema = variantAsync('type', [ + variantAsync('subType1', [ + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + subType2: literal('foo-2'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + subType2: literal('bar-2'), + other2: string(), + }), + ]), + ]), + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foz-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('baz-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType1: 'bar-1', subType2: 'invalid' }; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '("bar-2" | "baz-2")', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', async () => { + const schema = variantAsync('type', [ + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foz-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('baz-2'), + other4: string(), + }), + ]), + variantAsync('subType1', [ + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + subType2: literal('foo-2'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + subType2: literal('bar-2'), + other2: string(), + }), + ]), + ]), + ]); + const input = { type: 'bar', subType1: 'bar-1', subType2: 'invalid' }; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '("baz-2" | "bar-2")', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + + test('for first nested invalid discriminator', async () => { + const schema = variantAsync('type', [ + variantAsync('subType1', [ + object({ + type: literal('foo'), + subType1: literal('foo-1'), + other1: string(), + }), + object({ + type: literal('bar'), + subType1: literal('bar-1'), + other2: string(), + }), + variantAsync('subType2', [ + object({ + type: literal('bar'), + subType1: literal('baz-1'), + subType2: literal('baz-2'), + other5: string(), + }), + ]), + ]), + variantAsync('subType2', [ + object({ + type: literal('foo'), + subType2: literal('foo-2'), + other3: string(), + }), + object({ + type: literal('bar'), + subType2: literal('bar-2'), + other4: string(), + }), + ]), + ]); + const input = { type: 'bar', subType2: 'baz-2' }; + expect( + await schema._run({ typed: false, value: input }, {}) + ).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + ...baseInfo, + kind: 'schema', + type: 'variant', + input: input.subType2, + expected: '"bar-2"', + received: `"${input.subType2}"`, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subType2', + value: input.subType2, + }, + ], + }, + ], + } satisfies UntypedDataset>); + }); + test('for untyped object', async () => { const schema = variantAsync('type', [ object({ type: literal('foo'), other: bigint() }), diff --git a/library/src/schemas/variant/variantAsync.ts b/library/src/schemas/variant/variantAsync.ts index ed8fbb4b4..962277d92 100644 --- a/library/src/schemas/variant/variantAsync.ts +++ b/library/src/schemas/variant/variantAsync.ts @@ -11,8 +11,9 @@ import type { InferVariantIssue, VariantIssue, VariantOptionsAsync, + VariantOptionSchema, + VariantOptionSchemaAsync, } from './types.ts'; -import { _discriminators } from './utils/index.ts'; /** * Variant schema async type. @@ -93,7 +94,6 @@ export function variantAsync( VariantOptionsAsync, ErrorMessage | undefined > { - let expectedDiscriminators: string | undefined; return { kind: 'schema', type: 'variant', @@ -109,74 +109,128 @@ export function variantAsync( // If root type is valid, check nested types if (input && typeof input === 'object') { - // Get discriminator from input - // @ts-expect-error - const discriminator: unknown = input[this.key]; - - // If key is in input, parse schema of each option - if (this.key in input) { - // Create output dataset variable - let outputDataset: Dataset> | undefined; - - // Parse only if it is a variant schema or if discriminator matches - for (const schema of this.options) { - if ( - schema.type === 'variant' || - !( - await schema.entries[this.key]._run( - { typed: false, value: discriminator }, - config - ) - ).issues - ) { - // Parse input with schema of option - const optionDataset = await schema._run( - { typed: false, value: input }, - config - ); - - // If valid option is found, return its dataset - if (!optionDataset.issues) { - return optionDataset; + // Create output dataset variable + let outputDataset: Dataset> | undefined; + + // Create variables to store invalid discriminator information + let maxDiscriminatorPriority = 0; + let invalidDiscriminatorKey = this.key; + let expectedDiscriminators: string[] = []; + + // Create recursive function to parse nested variant options + const parseOptions = async ( + variant: + | VariantOptionSchema + | VariantOptionSchemaAsync, + allKeys: Set + ) => { + for (const schema of variant.options) { + // If it is a variant schema, parse its options recursively + if (schema.type === 'variant') { + await parseOptions(schema, new Set(allKeys).add(schema.key)); + + // Otherwise, check discriminators and parse object schema + } else { + // Create variables to store local discriminator information + let keysAreValid = true; + let currentPriority = 0; + + // Check if all discriminator keys are valid and collect + // information about invalid discriminator keys if not + for (const currentKey of allKeys) { + // If any discriminator is invalid, mark keys as invalid + if ( + ( + await schema.entries[currentKey]._run( + // @ts-expect-error + { typed: false, value: input[currentKey] }, + config + ) + ).issues + ) { + keysAreValid = false; + + // If invalid discriminator key is not equal to current key + // and if current key has a higher priority or same priority + // but is the first one present in input, reset invalid + // discriminator information + if ( + invalidDiscriminatorKey !== currentKey && + (maxDiscriminatorPriority < currentPriority || + (maxDiscriminatorPriority === currentPriority && + currentKey in input && + !(invalidDiscriminatorKey in input))) + ) { + maxDiscriminatorPriority = currentPriority; + invalidDiscriminatorKey = currentKey; + expectedDiscriminators = []; + } + + // If invalid discriminator key is equal to current key, + // store its expected value + if (invalidDiscriminatorKey === currentKey) { + expectedDiscriminators.push( + schema.entries[currentKey].expects + ); + } + + // Break loop on first invalid discriminator key + break; + } + + // Increase priority for next discriminator key + currentPriority++; } - // Otherwise, replace output dataset if necessary - // Hint: Only the first untyped or typed dataset is returned, and - // typed datasets take precedence over untyped ones. - if ( - !outputDataset || - (!outputDataset.typed && optionDataset.typed) - ) { - outputDataset = optionDataset; + // If all discriminators are valid, parse input with schema of option + if (keysAreValid) { + const optionDataset = await schema._run( + { typed: false, value: input }, + config + ); + + // Store output dataset if necessary + // Hint: Only the first untyped or typed dataset is returned, and + // typed datasets take precedence over untyped ones. + if ( + !outputDataset || + (!outputDataset.typed && optionDataset.typed) + ) { + outputDataset = optionDataset; + } } } - } - // If any output dataset is available, return it - if (outputDataset) { - return outputDataset; + // If valid option is found, break loop + // Hint: The `break` statement is intentionally placed at the end of + // the loop to break any outer loops in case of recursive execution. + if (outputDataset && !outputDataset.issues) { + break; + } } - } + }; + + // Parse input with nested variant options recursively + await parseOptions(this, new Set([this.key])); - // Otherwise, cache expected discriminators if necessary - if (!expectedDiscriminators) { - expectedDiscriminators = _joinExpects( - _discriminators(this.key, this.options), - '|' - ); + // If any output dataset is available, return it + if (outputDataset) { + return outputDataset; } - // And add discriminator issue + // Otherwise, add discriminator issue _addIssue(this, 'type', dataset, config, { - input: discriminator, - expected: expectedDiscriminators, + // @ts-expect-error + input: input[invalidDiscriminatorKey], + expected: _joinExpects(expectedDiscriminators, '|'), path: [ { type: 'object', origin: 'value', input: input as Record, - key: this.key, - value: discriminator, + key: invalidDiscriminatorKey, + // @ts-expect-error + value: input[invalidDiscriminatorKey], }, ], }); @@ -186,7 +240,7 @@ export function variantAsync( _addIssue(this, 'type', dataset, config); } - // Return output dataset + // Finally, return output dataset return dataset as Dataset< InferOutput[number]>, VariantIssue | BaseIssue diff --git a/website/src/routes/api/(async)/variantAsync/index.mdx b/website/src/routes/api/(async)/variantAsync/index.mdx index a775ad1f6..2086678d8 100644 --- a/website/src/routes/api/(async)/variantAsync/index.mdx +++ b/website/src/routes/api/(async)/variantAsync/index.mdx @@ -36,6 +36,8 @@ With `variantAsync` you can validate if the input matches one of the given objec > It is allowed to specify the exact same or a similar discriminator multiple times. However, in such cases `variantAsync` will only return the output of the first untyped or typed variant option result. Typed results take precedence over untyped ones. +> For deeply nested `variant` schemas with several different discriminator keys, `variant` will return an issue for the first most likely object schemas on invalid input. The order of the discriminator keys and the presence of a discriminator in the input are taken into account. + ## Returns - `Schema` diff --git a/website/src/routes/api/(schemas)/variant/index.mdx b/website/src/routes/api/(schemas)/variant/index.mdx index bc8fcf381..b53d76a02 100644 --- a/website/src/routes/api/(schemas)/variant/index.mdx +++ b/website/src/routes/api/(schemas)/variant/index.mdx @@ -35,6 +35,8 @@ With `variant` you can validate if the input matches one of the given object `op > It is allowed to specify the exact same or a similar discriminator multiple times. However, in such cases `variant` will only return the output of the first untyped or typed variant option result. Typed results take precedence over untyped ones. +> For deeply nested `variant` schemas with several different discriminator keys, `variant` will return an issue for the first most likely object schemas on invalid input. The order of the discriminator keys and the presence of a discriminator in the input are taken into account. + ## Returns - `Schema` @@ -78,6 +80,39 @@ const NestedVariantSchema = v.variant('type', [ ]); ``` +### Complex variant schema + +You can also use `variant` to validate complex objects with multiple different discriminator keys. + +```ts +const ComplexVariantSchema = v.variant('kind', [ + v.variant('type', [ + v.object({ + kind: v.literal('fruit'), + type: v.literal('apple'), + item: v.object({ … }), + }), + v.object({ + kind: v.literal('fruit'), + type: v.literal('banana'), + item: v.object({ … }), + }), + ]), + v.variant('type', [ + v.object({ + kind: v.literal('vegetable'), + type: v.literal('carrot'), + item: v.object({ … }), + }), + v.object({ + kind: v.literal('vegetable'), + type: v.literal('tomato'), + item: v.object({ … }), + }), + ]), +]); +``` + ## Related The following APIs can be combined with `variant`.