Skip to content

Commit 484a869

Browse files
mike-taylor99Michael Taylor
andauthored
Add support for open discriminators in JSON Schema emitter (#9127)
## Summary Adds support for **open discriminators** in the JSON Schema emitter. When a discriminator type includes `string` (indicating it can accept values beyond the explicitly defined ones), the emitter now generates a catch-all variant that validates unknown discriminator values against the base model schema. This applies only when using the `polymorphic-models-strategy` option as it is intended for validating discriminated unions. ## Problem When defining a discriminated union with an "open" discriminator like: ```typespec union ToolType { string, file_search: "file_search", function: "function", } @Discriminator("type") model Tool { type: ToolType; } model FileSearchTool extends Tool { type: ToolType.file_search; } model FunctionTool extends Tool { type: ToolType.function; } ``` The previous behavior would only generate oneOf variants for the known types (file_search, function), causing validation to fail for any unknown tool types - even though the string in the union indicates they should be allowed. ## Solution When the discriminator type includes string, the emitter now generates a catch-all variant: ```typespec { "type": "object", "properties": { "type": { "type": "string", "not": { "enum": ["file_search", "function"] } } }, "required": ["type"] } ``` The catch all: - Matches any discriminator value not in the known set - Validates against the base model schema only - Preserves full type checking for known discriminator values ## Testing Added 3 test cases: - Open discriminator with type: string - expects catch-all variant - Open discriminator with union containing string variant - expects catch-all variant - Closed discriminator (no string in union) - expects no catch-all variant (negative test) Co-authored-by: Michael Taylor <mictaylor@microsoft.com>
1 parent 115912f commit 484a869

File tree

3 files changed

+272
-1
lines changed

3 files changed

+272
-1
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: internal
3+
packages:
4+
- "@typespec/json-schema"
5+
---
6+
7+
add support for open discriminators

packages/json-schema/src/json-schema-emitter.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
getPattern,
5252
getRelativePathFromDirectory,
5353
getSummary,
54+
isStringType,
5455
isType,
5556
joinPaths,
5657
serializeValueAsJson,
@@ -776,14 +777,30 @@ export class JsonSchemaEmitter extends TypeEmitter<Record<string, any>, JSONSche
776777
strategy: "oneOf" | "anyOf",
777778
): EmitterOutput<object> {
778779
const variants = new ArrayBuilder<Record<string, unknown>>();
780+
const knownDiscriminatorValues: string[] = [];
779781

780-
// Collect all derived models
782+
// Collect all derived models and their discriminator values
781783
for (const derived of model.derivedModels) {
782784
if (!includeDerivedModel(derived)) continue;
783785

784786
// Add reference to each derived model
785787
const derivedRef = this.emitter.emitTypeReference(derived);
786788
variants.push(derivedRef);
789+
790+
// Collect discriminator values for catch-all generation
791+
const values = this.#getDiscriminatorValues(derived, discriminator.propertyName);
792+
knownDiscriminatorValues.push(...values);
793+
}
794+
795+
// Check if this is an open discriminator (discriminator property type includes string)
796+
// If so, add a catch-all variant that matches any unknown discriminator value
797+
if (this.#isOpenDiscriminator(model, discriminator.propertyName)) {
798+
const catchAllVariant = this.#createCatchAllVariant(
799+
model,
800+
discriminator.propertyName,
801+
knownDiscriminatorValues,
802+
);
803+
variants.push(catchAllVariant);
787804
}
788805

789806
// For discriminated unions, emit the oneOf/anyOf with base model properties
@@ -800,6 +817,109 @@ export class JsonSchemaEmitter extends TypeEmitter<Record<string, any>, JSONSche
800817
return this.#createDeclaration(model, name, schema);
801818
}
802819

820+
/**
821+
* Check if the discriminator property type is "open" (includes the string scalar type).
822+
* This means the discriminated union can accept unknown discriminator values.
823+
*/
824+
#isOpenDiscriminator(model: Model, discriminatorPropertyName: string): boolean {
825+
const prop = model.properties.get(discriminatorPropertyName);
826+
if (!prop) return false;
827+
828+
return this.#typeIncludesString(prop.type);
829+
}
830+
831+
/**
832+
* Check if a type includes the string scalar (not just string literals).
833+
*/
834+
#typeIncludesString(type: Type): boolean {
835+
const program = this.emitter.getProgram();
836+
837+
switch (type.kind) {
838+
case "Scalar":
839+
return isStringType(program, type);
840+
case "Union":
841+
// Check if any variant is the string scalar
842+
for (const variant of type.variants.values()) {
843+
if (this.#typeIncludesString(variant.type)) {
844+
return true;
845+
}
846+
}
847+
return false;
848+
default:
849+
return false;
850+
}
851+
}
852+
853+
/**
854+
* Get string discriminator values from a model's discriminator property.
855+
*/
856+
#getDiscriminatorValues(model: Model, discriminatorPropertyName: string): string[] {
857+
const prop = model.properties.get(discriminatorPropertyName);
858+
if (!prop) return [];
859+
860+
return this.#getStringLiteralValues(prop.type);
861+
}
862+
863+
/**
864+
* Extract string literal values from a type.
865+
*/
866+
#getStringLiteralValues(type: Type): string[] {
867+
switch (type.kind) {
868+
case "String":
869+
return [type.value];
870+
case "Union":
871+
return [...type.variants.values()].flatMap((v) => this.#getStringLiteralValues(v.type));
872+
case "UnionVariant":
873+
return this.#getStringLiteralValues(type.type);
874+
case "EnumMember":
875+
return typeof type.value !== "number" ? [type.value ?? type.name] : [];
876+
default:
877+
return [];
878+
}
879+
}
880+
881+
/**
882+
* Create a catch-all variant for open discriminated unions.
883+
* This variant matches any discriminator value not in the known values.
884+
*/
885+
#createCatchAllVariant(
886+
model: Model,
887+
discriminatorPropertyName: string,
888+
knownValues: string[],
889+
): Record<string, unknown> {
890+
const properties = new ObjectBuilder();
891+
892+
// Emit all base model properties
893+
for (const [propName, prop] of model.properties) {
894+
if (propName === discriminatorPropertyName) {
895+
// Override discriminator property to match any string NOT in known values
896+
setProperty(properties, propName, {
897+
type: "string",
898+
not: { enum: knownValues },
899+
});
900+
} else {
901+
const result = this.emitter.emitModelProperty(prop);
902+
setProperty(properties, propName, result);
903+
}
904+
}
905+
906+
// If discriminator property is not explicitly defined, add it
907+
if (!model.properties.has(discriminatorPropertyName)) {
908+
setProperty(properties, discriminatorPropertyName, {
909+
type: "string",
910+
not: { enum: knownValues },
911+
});
912+
}
913+
914+
const required = this.#requiredModelProperties(model);
915+
916+
return {
917+
type: "object",
918+
properties: properties,
919+
...(required && { required }),
920+
};
921+
}
922+
803923
intrinsic(intrinsic: IntrinsicType, name: string): EmitterOutput<object> {
804924
switch (intrinsic.name) {
805925
case "null":

packages/json-schema/test/discriminator.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,4 +467,148 @@ describe("discriminated union with polymorphic-models-strategy option", () => {
467467
deepStrictEqual(petSchema.oneOf[0], { $ref: "#/$defs/Cat" });
468468
deepStrictEqual(petSchema.oneOf[1], { $ref: "#/$defs/Dog" });
469469
});
470+
471+
it("generates catch-all variant for open discriminators", async () => {
472+
const schemas = await emitSchema(
473+
`
474+
@discriminator("type")
475+
model Tool {
476+
name: string;
477+
type: string;
478+
}
479+
480+
model FileSearch extends Tool {
481+
type: "file_search";
482+
query: string;
483+
}
484+
485+
model Function extends Tool {
486+
type: "function";
487+
functionName: string;
488+
}
489+
`,
490+
{ "polymorphic-models-strategy": "oneOf" },
491+
);
492+
493+
const toolSchema = schemas["Tool.json"];
494+
ok(toolSchema, "Tool schema should exist");
495+
ok(toolSchema.oneOf, "Tool schema should have oneOf");
496+
497+
// Should have 3 variants: 2 known types + 1 catch-all
498+
strictEqual(toolSchema.oneOf.length, 3, "oneOf should have 3 options (2 known + catch-all)");
499+
500+
// First two should be references to known types
501+
deepStrictEqual(toolSchema.oneOf[0], { $ref: "FileSearch.json" });
502+
deepStrictEqual(toolSchema.oneOf[1], { $ref: "Function.json" });
503+
504+
// Third should be the catch-all variant
505+
const catchAll = toolSchema.oneOf[2];
506+
ok(catchAll, "Catch-all variant should exist");
507+
strictEqual(catchAll.type, "object");
508+
ok(catchAll.properties, "Catch-all should have properties");
509+
510+
// Catch-all discriminator property should have "not: { enum: [...known values...] }"
511+
const typeProperty = catchAll.properties.type;
512+
ok(typeProperty, "Catch-all should have type property");
513+
strictEqual(typeProperty.type, "string");
514+
ok(typeProperty.not, "Type property should have 'not' constraint");
515+
deepStrictEqual(
516+
typeProperty.not.enum,
517+
["file_search", "function"],
518+
"Should exclude known values",
519+
);
520+
521+
// Should include base properties
522+
ok(catchAll.properties.name, "Catch-all should have name property");
523+
deepStrictEqual(catchAll.required, ["name", "type"]);
524+
});
525+
526+
it("generates catch-all variant for union discriminators that include string", async () => {
527+
const schemas = await emitSchema(
528+
`
529+
union ToolType {
530+
string,
531+
file_search: "file_search",
532+
function: "function",
533+
}
534+
535+
@discriminator("type")
536+
model Tool {
537+
name: string;
538+
type: ToolType;
539+
}
540+
541+
model FileSearch extends Tool {
542+
type: ToolType.file_search;
543+
query: string;
544+
}
545+
546+
model Function extends Tool {
547+
type: ToolType.function;
548+
functionName: string;
549+
}
550+
`,
551+
{ "polymorphic-models-strategy": "oneOf" },
552+
);
553+
554+
const toolSchema = schemas["Tool.json"];
555+
ok(toolSchema, "Tool schema should exist");
556+
ok(toolSchema.oneOf, "Tool schema should have oneOf");
557+
558+
// Should have 3 variants: 2 known types + 1 catch-all
559+
strictEqual(toolSchema.oneOf.length, 3, "oneOf should have 3 options (2 known + catch-all)");
560+
561+
// Third should be the catch-all variant
562+
const catchAll = toolSchema.oneOf[2];
563+
ok(catchAll, "Catch-all variant should exist");
564+
strictEqual(catchAll.type, "object");
565+
566+
// Catch-all discriminator property should exclude known values
567+
const typeProperty = catchAll.properties.type;
568+
ok(typeProperty.not, "Type property should have 'not' constraint");
569+
deepStrictEqual(
570+
typeProperty.not.enum,
571+
["file_search", "function"],
572+
"Should exclude known values",
573+
);
574+
});
575+
576+
it("does not generate catch-all for closed discriminators", async () => {
577+
const schemas = await emitSchema(
578+
`
579+
union PetKind {
580+
cat: "cat",
581+
dog: "dog",
582+
}
583+
584+
@discriminator("kind")
585+
model Pet {
586+
name: string;
587+
kind: PetKind;
588+
}
589+
590+
model Cat extends Pet {
591+
kind: PetKind.cat;
592+
meow: int32;
593+
}
594+
595+
model Dog extends Pet {
596+
kind: PetKind.dog;
597+
bark: string;
598+
}
599+
`,
600+
{ "polymorphic-models-strategy": "oneOf" },
601+
);
602+
603+
const petSchema = schemas["Pet.json"];
604+
ok(petSchema, "Pet schema should exist");
605+
ok(petSchema.oneOf, "Pet schema should have oneOf");
606+
607+
// Should have only 2 variants (no catch-all for closed union)
608+
strictEqual(petSchema.oneOf.length, 2, "oneOf should have 2 options (no catch-all)");
609+
610+
// Both should be references to known types
611+
deepStrictEqual(petSchema.oneOf[0], { $ref: "Cat.json" });
612+
deepStrictEqual(petSchema.oneOf[1], { $ref: "Dog.json" });
613+
});
470614
});

0 commit comments

Comments
 (0)