From 4ec74ba1dd4e96f9bdf6d8065b88f204e9a59b0c Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 12 Dec 2025 17:17:03 +0100 Subject: [PATCH 1/7] discriminated union type --- .../src/types/discriminated_union.test.ts | 153 ++++++++++++++++++ .../src/types/discriminated_union.ts | 111 +++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts create mode 100644 src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts new file mode 100644 index 0000000000000..0663010fb98e2 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '../..'; + +test('handles single object', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), age: schema.number() }), + ]); + + expect(() => type.validate({ type: 'foo', age: 'foo' })).toThrowErrorMatchingInlineSnapshot( + `"[age]: Error: expected value of type [number] but got [string]"` + ); +}); + +test('handles multiple objects', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), foo: schema.string() }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + ]); + + expect(type.validate({ type: 'foo', foo: 'test' })).toEqual({ type: 'foo', foo: 'test' }); +}); + +test('handles catch-all pattern', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), foo: schema.string() }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + schema.object({ type: schema.string(), bar: schema.literal('catch all!') }), + ]); + + expect(type.validate({ type: 'whathaveyou', bar: 'catch all!' })).toEqual({ + type: 'whathaveyou', + bar: 'catch all!', + }); +}); + +test('handles multiple objects with the same type', () => { + // This defeats the purpose of the discriminator, but it will work + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), age: schema.string() }), + schema.object({ type: schema.literal('foo'), age: schema.number() }), + ]); + + expect(type.validate({ type: 'foo', age: 'foo' })).toEqual({ type: 'foo', age: 'foo' }); +}); + +test('includes namespace in failure', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), age: schema.string() }), + schema.object({ type: schema.literal('bar'), age: schema.number() }), + ]); + + expect(() => + type.validate({ type: 'foo', age: 12 }, {}, 'foo-namespace') + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace.age]: Error: expected value of type [string] but got [number]"` + ); +}); + +test('fails if nested discriminatedOneOf types fail due to discriminator', () => { + const type = schema.discriminatedOneOf('discriminator1', [ + schema.object({ + discriminator1: schema.literal('foo'), + foo: schema.discriminatedOneOf('discriminator2', [ + schema.object({ discriminator2: schema.literal('foo'), foo: schema.string() }), + schema.object({ discriminator2: schema.literal('bar'), foo: schema.number() }), + ]), + }), + schema.object({ discriminator1: schema.literal('foo'), foo: schema.number() }), + ]); + + expect(() => + type.validate({ discriminator1: 'foo', foo: { discriminator2: 'baz', foo: 12 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[foo]: Error: value [baz] did not match any of the allowed values for [discriminator2]: [Error: expected value to equal [foo], Error: expected value to equal [bar]]"` + ); +}); + +test('fails if nested discriminatedOneOf types fail', () => { + const type = schema.discriminatedOneOf('discriminator1', [ + schema.object({ + discriminator1: schema.literal('1'), + foo1: schema.discriminatedOneOf('discriminator2', [ + schema.object({ discriminator2: schema.literal('foo'), foo2: schema.string() }), + schema.object({ discriminator2: schema.literal('bar'), foo2: schema.number() }), + ]), + }), + schema.object({ discriminator1: schema.literal('2'), foo: schema.number() }), + ]); + + expect(() => + type.validate({ + discriminator1: '1', + foo1: { discriminator2: 'bar', foo2: 'should be a number' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[foo1.foo2]: Error: Error: expected value of type [number] but got [string]"` + ); +}); + +test('fails when no discriminator is provided', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), foo: schema.string() }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + ]); + + expect(() => type.validate({ foo: 12 })).toThrowErrorMatchingInlineSnapshot( + `"value [undefined] did not match any of the allowed values for [type]: [Error: expected value to equal [foo], Error: expected value to equal [bar]]"` + ); +}); + +test('fails when discriminator is provided, but is not any allowed value', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), foo: schema.string() }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + ]); + + expect(() => type.validate({ type: 'foo1', foo: 12 })).toThrowErrorMatchingInlineSnapshot( + `"value [foo1] did not match any of the allowed values for [type]: [Error: expected value to equal [foo], Error: expected value to equal [bar]]"` + ); +}); + +test('fails when discriminator matches but the rest of the type fails', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), foo: schema.string() }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + ]); + + expect(() => type.validate({ type: 'foo', foo: 12 })).toThrowErrorMatchingInlineSnapshot( + `"[foo]: Error: expected value of type [string] but got [number]"` + ); +}); + +test('fails weirdly if discriminator keys are not ordered consistently', () => { + // Unfortunately I can't see a good way of handling this case gracefully, + // so hopefully developers will specify their discriminator key first in the schema. + // Alternatively, we can try to find a way to not abort validation early for this type. + const type = schema.discriminatedOneOf('type', [ + schema.object({ foo: schema.string(), type: schema.literal('foo') }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + ]); + + expect(() => type.validate({ type: 'foo1', nothing: 12 })).toThrowErrorMatchingInlineSnapshot( + `"[foo]: Error: expected value of type [string] but got [undefined]"` + ); +}); diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts new file mode 100644 index 0000000000000..2b4d160c220a8 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import typeDetect from 'type-detect'; +import { get } from 'lodash'; +import { SchemaTypeError, SchemaTypesError } from '../errors'; +import { internals } from '../internals'; +import { Type, type TypeOptions, type TypeMeta } from './type'; +import { META_FIELD_X_OAS_DISCRIMINATOR } from '../oas_meta_fields'; + +export type DiscriminatedUnionTypeOptions = TypeOptions & { + meta?: Omit; +}; + +export class DiscriminatedUnionType>, T> extends Type { + private readonly discriminator?: string; + + constructor(discriminator: string, types: RTS, options?: DiscriminatedUnionTypeOptions) { + let schema = internals.alternatives(types.map((type) => type.getSchema())).match('one'); + schema = schema.meta({ [META_FIELD_X_OAS_DISCRIMINATOR]: discriminator }); + + super(schema, options); + this.discriminator = discriminator; + } + + protected handleError(type: string, { value, details }: Record, path: string[]) { + switch (type) { + case 'any.required': + return `expected at least one defined value but got [${typeDetect(value)}]`; + case 'alternatives.any': + if (!this.discriminator) return; + const nonDiscriminatorDetails: AlternativeAnyErrorDetail[] = []; + const isDiscriminatorError = details.every((detail: AlternativeAnyErrorDetail) => { + if (detail.details.length !== 1) { + nonDiscriminatorDetails.push(detail); + return false; + } + const errorPath = get(detail, 'details.0.context.error.path'); + if (errorPath[errorPath.length - 1] !== this.discriminator) { + nonDiscriminatorDetails.push(detail); + return false; + } + return true; + }); + if (isDiscriminatorError) { + return new SchemaTypeError( + `value [${value?.[this.discriminator]}] did not match any of the allowed values for [${ + this.discriminator + }]: [${details.map((detail: AlternativeAnyErrorDetail) => detail.message).join(', ')}]`, + path + ); + } else if ( + nonDiscriminatorDetails.length === 1 && + get(nonDiscriminatorDetails, '0.details.length') === 1 + ) { + const childPath = get(nonDiscriminatorDetails, '0.details.0.context.error.path'); + return new SchemaTypeError(nonDiscriminatorDetails[0].message, childPath); + } + return errorDetailToSchemaTypeError( + 'types that failed validation:', + nonDiscriminatorDetails.flatMap((detail) => detail.details), + path + ); + case 'alternatives.match': + return errorDetailToSchemaTypeError( + 'types that failed validation:', + details as AlternativeMatchErrorDetail[], + path + ); + } + } +} + +interface ErrorDetail { + context: { + error: SchemaTypeError; + }; +} + +interface AlternativeAnyErrorDetail { + message: string; + details: ErrorDetail[]; +} + +type AlternativeMatchErrorDetail = ErrorDetail; + +function errorDetailToSchemaTypeError( + message: string, + details: ErrorDetail[], + path: string[] +): SchemaTypeError { + return new SchemaTypesError( + message, + path, + details.map((detail: AlternativeMatchErrorDetail, index: number) => { + const e = detail.context.error; + const childPathWithIndex = e.path.slice(); + childPathWithIndex.splice(path.length, 0, index.toString()); + + return e instanceof SchemaTypesError + ? new SchemaTypesError(e.message, childPathWithIndex, e.errors) + : new SchemaTypeError(e.message, childPathWithIndex); + }) + ); +} From 604d0d76ee96c8251c8525a813aff5be6388ffa2 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 12 Dec 2025 17:17:18 +0100 Subject: [PATCH 2/7] define type and export it --- .../packages/shared/kbn-config-schema/src/types/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/index.ts b/src/platform/packages/shared/kbn-config-schema/src/types/index.ts index 0826c0a1e6ed6..91b89a8da54b8 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/index.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/index.ts @@ -43,6 +43,8 @@ export type { StringOptions } from './string_type'; export { StringType } from './string_type'; export type { UnionTypeOptions } from './union_type'; export { UnionType } from './union_type'; +export type { DiscriminatedUnionTypeOptions } from './discriminated_union'; +export { DiscriminatedUnionType } from './discriminated_union'; export type { URIOptions } from './uri_type'; export { URIType } from './uri_type'; export { NeverType } from './never_type'; From f2dc8c8c009b5219115c4719f117e78324130e79 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 12 Dec 2025 17:17:29 +0100 Subject: [PATCH 3/7] added oas meta field --- .../packages/shared/kbn-config-schema/src/oas_meta_fields.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts b/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts index b9368f6b80b50..0f534f72c9728 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts @@ -17,5 +17,6 @@ export const META_FIELD_X_OAS_MAX_LENGTH = 'x-oas-max-length' as const; export const META_FIELD_X_OAS_GET_ADDITIONAL_PROPERTIES = 'x-oas-get-additional-properties' as const; export const META_FIELD_X_OAS_DEPRECATED = 'x-oas-deprecated' as const; +export const META_FIELD_X_OAS_DISCRIMINATOR = 'x-oas-discriminator' as const; export const META_FIELD_X_OAS_ANY = 'x-oas-any-type' as const; export const META_FIELD_X_OAS_DISCONTINUED = 'x-oas-discontinued' as const; From 248f917c8336469dc2dd68b567eec4cf5aac0b09 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 12 Dec 2025 17:18:00 +0100 Subject: [PATCH 4/7] define the discriminatedOneOf function using generic variadic approach --- .../packages/shared/kbn-config-schema/index.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/platform/packages/shared/kbn-config-schema/index.ts b/src/platform/packages/shared/kbn-config-schema/index.ts index a00069021634a..b3b1de104f3a0 100644 --- a/src/platform/packages/shared/kbn-config-schema/index.ts +++ b/src/platform/packages/shared/kbn-config-schema/index.ts @@ -33,6 +33,7 @@ import type { TypeOptions, URIOptions, UnionTypeOptions, + DiscriminatedUnionTypeOptions, } from './src/types'; import { AnyType, @@ -54,6 +55,7 @@ import { StringType, Type, UnionType, + DiscriminatedUnionType, URIType, StreamType, Lazy, @@ -224,6 +226,21 @@ function oneOf>>( return new UnionType(types, options); } +type ExtractTypeFromObjectType>> = { + [K in keyof T]: T[K]['type']; +}; + +type UnionOfObjectTypes>> = ExtractTypeFromObjectType[number]; + +/** @deprecated This is an experimental feature */ +function discriminatedOneOf>>( + discriminator: string, + types: T, + options?: DiscriminatedUnionTypeOptions> +): Type> { + return new DiscriminatedUnionType(discriminator, types, options); +} + function allOf< A extends Props, B extends Props, @@ -428,6 +445,7 @@ export const schema = { number, object, oneOf, + discriminatedOneOf, recordOf, stream, siblingRef, From 3f2c8d646cc72342e918a60bdbd236855f2d0979 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 15 Dec 2025 11:52:31 +0100 Subject: [PATCH 5/7] test --- .../src/types/discriminated_union.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts index 0663010fb98e2..b2fc6d2d3c69b 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts @@ -14,6 +14,14 @@ test('handles single object', () => { schema.object({ type: schema.literal('foo'), age: schema.number() }), ]); + expect(type.validate({ type: 'foo', age: 24 })).toEqual({ type: 'foo', age: 24 }); +}); + +test('fails as expected with single object', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), age: schema.number() }), + ]); + expect(() => type.validate({ type: 'foo', age: 'foo' })).toThrowErrorMatchingInlineSnapshot( `"[age]: Error: expected value of type [number] but got [string]"` ); From c74ecad40960d2ea56115903d2d196a8c2388f51 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 15 Dec 2025 11:54:32 +0100 Subject: [PATCH 6/7] reorder tests --- .../src/types/discriminated_union.test.ts | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts index b2fc6d2d3c69b..213da836a2265 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts @@ -72,47 +72,6 @@ test('includes namespace in failure', () => { ); }); -test('fails if nested discriminatedOneOf types fail due to discriminator', () => { - const type = schema.discriminatedOneOf('discriminator1', [ - schema.object({ - discriminator1: schema.literal('foo'), - foo: schema.discriminatedOneOf('discriminator2', [ - schema.object({ discriminator2: schema.literal('foo'), foo: schema.string() }), - schema.object({ discriminator2: schema.literal('bar'), foo: schema.number() }), - ]), - }), - schema.object({ discriminator1: schema.literal('foo'), foo: schema.number() }), - ]); - - expect(() => - type.validate({ discriminator1: 'foo', foo: { discriminator2: 'baz', foo: 12 } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[foo]: Error: value [baz] did not match any of the allowed values for [discriminator2]: [Error: expected value to equal [foo], Error: expected value to equal [bar]]"` - ); -}); - -test('fails if nested discriminatedOneOf types fail', () => { - const type = schema.discriminatedOneOf('discriminator1', [ - schema.object({ - discriminator1: schema.literal('1'), - foo1: schema.discriminatedOneOf('discriminator2', [ - schema.object({ discriminator2: schema.literal('foo'), foo2: schema.string() }), - schema.object({ discriminator2: schema.literal('bar'), foo2: schema.number() }), - ]), - }), - schema.object({ discriminator1: schema.literal('2'), foo: schema.number() }), - ]); - - expect(() => - type.validate({ - discriminator1: '1', - foo1: { discriminator2: 'bar', foo2: 'should be a number' }, - }) - ).toThrowErrorMatchingInlineSnapshot( - `"[foo1.foo2]: Error: Error: expected value of type [number] but got [string]"` - ); -}); - test('fails when no discriminator is provided', () => { const type = schema.discriminatedOneOf('type', [ schema.object({ type: schema.literal('foo'), foo: schema.string() }), @@ -159,3 +118,44 @@ test('fails weirdly if discriminator keys are not ordered consistently', () => { `"[foo]: Error: expected value of type [string] but got [undefined]"` ); }); + +test('fails if nested discriminatedOneOf types fail due to discriminator', () => { + const type = schema.discriminatedOneOf('discriminator1', [ + schema.object({ + discriminator1: schema.literal('foo'), + foo: schema.discriminatedOneOf('discriminator2', [ + schema.object({ discriminator2: schema.literal('foo'), foo: schema.string() }), + schema.object({ discriminator2: schema.literal('bar'), foo: schema.number() }), + ]), + }), + schema.object({ discriminator1: schema.literal('foo'), foo: schema.number() }), + ]); + + expect(() => + type.validate({ discriminator1: 'foo', foo: { discriminator2: 'baz', foo: 12 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[foo]: Error: value [baz] did not match any of the allowed values for [discriminator2]: [Error: expected value to equal [foo], Error: expected value to equal [bar]]"` + ); +}); + +test('fails if nested discriminatedOneOf types fail', () => { + const type = schema.discriminatedOneOf('discriminator1', [ + schema.object({ + discriminator1: schema.literal('1'), + foo1: schema.discriminatedOneOf('discriminator2', [ + schema.object({ discriminator2: schema.literal('foo'), foo2: schema.string() }), + schema.object({ discriminator2: schema.literal('bar'), foo2: schema.number() }), + ]), + }), + schema.object({ discriminator1: schema.literal('2'), foo: schema.number() }), + ]); + + expect(() => + type.validate({ + discriminator1: '1', + foo1: { discriminator2: 'bar', foo2: 'should be a number' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[foo1.foo2]: Error: Error: expected value of type [number] but got [string]"` + ); +}); From 4266cb2f059af91f6d7b9eeffdc693e9edb4df2d Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 23 Dec 2025 12:57:18 +0100 Subject: [PATCH 7/7] expanded error handling, additional tests --- .../src/types/discriminated_union.test.ts | 44 ++++++++++++++++++- .../src/types/discriminated_union.ts | 9 +++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts index 213da836a2265..ffe8b6f759167 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.test.ts @@ -49,6 +49,34 @@ test('handles catch-all pattern', () => { }); }); +test('fails with less helpful error message when multiple catch-all patterns are provided', () => { + // Schema authors should avoid this antipattern + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), foo: schema.string() }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + schema.object({ type: schema.string(), bar: schema.literal('catch all!') }), + schema.object({ type: schema.string(), bar: schema.literal('catch all again!') }), + ]); + + expect(() => type.validate({ type: 123, bar: 'catch all!' })).toThrowErrorMatchingInlineSnapshot( + `"value [123] did not match any of the allowed values for [type]: [Error: expected value to equal [foo], Error: expected value to equal [bar], Error: expected value of type [string] but got [number], Error: expected value of type [string] but got [number]]"` + ); +}); + +test('fails with less helpful error message when multiple discriminators are matched', () => { + // Schema authors should avoid this antipattern + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), foo: schema.string() }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + schema.object({ type: schema.string(), bar: schema.number() }), + schema.object({ type: schema.string(), bar: schema.number() }), + ]); + + expect(() => type.validate({ type: 'catchall', bar: 123 })).toThrowErrorMatchingInlineSnapshot( + `"value [catchall] matched more than one allowed type of [type]"` + ); +}); + test('handles multiple objects with the same type', () => { // This defeats the purpose of the discriminator, but it will work const type = schema.discriminatedOneOf('type', [ @@ -105,10 +133,24 @@ test('fails when discriminator matches but the rest of the type fails', () => { ); }); +test('fails when discriminator matches but the rest of the type fails (nested object)', () => { + const type = schema.discriminatedOneOf('type', [ + schema.object({ type: schema.literal('foo'), object: schema.object({ foo: schema.string() }) }), + schema.object({ type: schema.literal('bar'), bar: schema.number() }), + ]); + + expect(() => + type.validate({ type: 'foo', object: { foo: 12 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[object.foo]: Error: expected value of type [string] but got [number]"` + ); +}); + test('fails weirdly if discriminator keys are not ordered consistently', () => { // Unfortunately I can't see a good way of handling this case gracefully, // so hopefully developers will specify their discriminator key first in the schema. - // Alternatively, we can try to find a way to not abort validation early for this type. + // Alternatively, we can try to find a way to not abort validation early for discriminatedOneOf + // types to introspect the error details better (likely a performance hit). const type = schema.discriminatedOneOf('type', [ schema.object({ foo: schema.string(), type: schema.literal('foo') }), schema.object({ type: schema.literal('bar'), bar: schema.number() }), diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts index 2b4d160c220a8..77e5e9029a88b 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union.ts @@ -19,7 +19,7 @@ export type DiscriminatedUnionTypeOptions = TypeOptions & { }; export class DiscriminatedUnionType>, T> extends Type { - private readonly discriminator?: string; + private readonly discriminator: string; constructor(discriminator: string, types: RTS, options?: DiscriminatedUnionTypeOptions) { let schema = internals.alternatives(types.map((type) => type.getSchema())).match('one'); @@ -67,6 +67,13 @@ export class DiscriminatedUnionType>, T> extends Typ nonDiscriminatorDetails.flatMap((detail) => detail.details), path ); + case 'alternatives.one': + return new SchemaTypeError( + `value [${value?.[this.discriminator]}] matched more than one allowed type of [${ + this.discriminator + }]`, + path + ); case 'alternatives.match': return errorDetailToSchemaTypeError( 'types that failed validation:',