Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/platform/packages/shared/kbn-config-schema/index.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: I also put up a PoC for a discriminated union type here #246095

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
TypeOptions,
URIOptions,
UnionTypeOptions,
DiscriminatedUnionTypeOptions,
} from './src/types';
import {
AnyType,
Expand All @@ -54,6 +55,7 @@ import {
StringType,
Type,
UnionType,
DiscriminatedUnionType,
URIType,
StreamType,
Lazy,
Expand Down Expand Up @@ -224,6 +226,21 @@ function oneOf<RTS extends Array<Type<any>>>(
return new UnionType(types, options);
}

type ExtractTypeFromObjectType<T extends Array<ObjectType<any>>> = {
[K in keyof T]: T[K]['type'];
};

type UnionOfObjectTypes<T extends Array<ObjectType<any>>> = ExtractTypeFromObjectType<T>[number];

/** @deprecated This is an experimental feature */
function discriminatedOneOf<T extends Array<ObjectType<any>>>(
discriminator: string,
types: T,
options?: DiscriminatedUnionTypeOptions<UnionOfObjectTypes<T>>
): Type<UnionOfObjectTypes<T>> {
return new DiscriminatedUnionType(discriminator, types, options);
}
Comment on lines +236 to +242
Copy link
Copy Markdown
Contributor

@nickofthyme nickofthyme Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This typing does not enforce that the schema objects contain the discriminator.

Enforcing this makes the types harder as I could not find a variadic type solution, so I ended up with this.

function oneOfKind<
  Discriminator extends string,
  A extends PropsWithDiscriminator<Discriminator, Props>,
  B extends PropsWithDiscriminator<Discriminator, Props>
>(
  discriminator: Discriminator,
  types: [ObjectType<A>, ObjectType<B>],
  options?: UnionTypeOptions<ObjectResultUnionType<A | B>>
): Type<ObjectResultUnionType<A | B>>;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree that's a nice improvement!


function allOf<
A extends Props,
B extends Props,
Expand Down Expand Up @@ -428,6 +445,7 @@ export const schema = {
number,
object,
oneOf,
discriminatedOneOf,
recordOf,
stream,
siblingRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* 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: 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]"`
);
});

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!') }),
Comment thread
jloleysens marked this conversation as resolved.
]);

expect(type.validate({ type: 'whathaveyou', bar: 'catch all!' })).toEqual({
type: 'whathaveyou',
bar: 'catch all!',
});
});

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', [
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 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 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 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() }),
]);

expect(() => type.validate({ type: 'foo1', nothing: 12 })).toThrowErrorMatchingInlineSnapshot(
`"[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]"`
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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<T> = TypeOptions<T> & {
meta?: Omit<TypeMeta, 'id'>;
};

export class DiscriminatedUnionType<RTS extends Array<Type<any>>, T> extends Type<T> {
private readonly discriminator: string;

constructor(discriminator: string, types: RTS, options?: DiscriminatedUnionTypeOptions<T>) {
let schema = internals.alternatives(types.map((type) => type.getSchema())).match('one');
Copy link
Copy Markdown
Contributor

@nickofthyme nickofthyme Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this is discriminated based on the discriminator key, this is just a stricter union.

This approach requires exactly one of the provided branches to match the value. If zero or more than one match, the validation fails.

Compared to using alternatives.conditional, which evaluates conditions in the switch array, picks the first matching branch based on the discriminator (or otherwise if provided). Only that branch is applied all others are ignored.

See my solution here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach requires exactly one of the provided branches to match the value. If zero or more than one match, the validation fails.

True, this is an exclusive or hidden under a constructor that will ask for a discriminator key - I believe this is essentially what a discriminated union is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah but it's not splitting the various schemas by the discriminator. The only discrimination you are doing is within the error handling.

Thus why I think this internal schema is no different than the current oneOf except stricter in that it would not allow multiple matches, much like zod.xor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also from a performance perspective, the internals.alternatives(...).match('one') will not short-circuit when a match is found because it needs to validate exclusivity.

But with alternatives.conditional this is not the case as the exclusivity is implied by the exhaustive nature of the discriminator, or more simply it only ever checks one of the schemas.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, from an internal implementation perspective it could match this behaviour more optimally, but viewed as a black box they'd work the same, but good point regarding performance!

schema = schema.meta({ [META_FIELD_X_OAS_DISCRIMINATOR]: discriminator });

super(schema, options);
this.discriminator = discriminator;
}

protected handleError(type: string, { value, details }: Record<string, any>, 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.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:',
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);
})
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down