diff --git a/.chronus/changes/tcgc-inline-discriminated-union-conversion.md b/.chronus/changes/tcgc-inline-discriminated-union-conversion.md new file mode 100644 index 0000000000..e40c9ead07 --- /dev/null +++ b/.chronus/changes/tcgc-inline-discriminated-union-conversion.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +Convert inline-discriminated unions with explicit nominal model hierarchies to polymorphic SDK models by default. Set `convertDiscriminatedUnionsToModels: false` in `createSdkContext` to preserve union representations. Ordinary unions and object-envelope unions retain their existing behavior. diff --git a/packages/typespec-client-generator-core/src/context.ts b/packages/typespec-client-generator-core/src/context.ts index 90a2e5c4f5..145d5507f6 100644 --- a/packages/typespec-client-generator-core/src/context.ts +++ b/packages/typespec-client-generator-core/src/context.ts @@ -71,6 +71,7 @@ export function createTCGCContext( previewStringRegex: /-preview$/, disableUsageAccessPropagationToBase: false, + convertDiscriminatedUnionsToModels: true, generateProtocolMethods: true, generateConvenienceMethods: true, __referencedTypeCache: new Map< @@ -173,6 +174,8 @@ export interface CreateSdkContextOptions { exportTCGCoutput?: boolean; // this flag is for emitter to export TCGC output as yaml file flattenUnionAsEnum?: boolean; // this flag is for emitter to decide whether tcgc should flatten union as enum enableLegacyHierarchyBuilding?: boolean; // this flag is for emitter to decide whether tcgc should respect the `@hierarchyBuilding` decorator + /** Convert eligible inline-discriminated unions to model hierarchies. Default: true. */ + convertDiscriminatedUnionsToModels?: boolean; } export async function createSdkContext< @@ -238,6 +241,7 @@ export async function createSdkContext< disableUsageAccessPropagationToBase: options?.disableUsageAccessPropagationToBase ?? false, flattenUnionAsEnum: options?.flattenUnionAsEnum ?? true, enableLegacyHierarchyBuilding: options?.enableLegacyHierarchyBuilding ?? true, + convertDiscriminatedUnionsToModels: options?.convertDiscriminatedUnionsToModels ?? true, }; if (context.options["examples-dir"]) { diff --git a/packages/typespec-client-generator-core/src/decorators.ts b/packages/typespec-client-generator-core/src/decorators.ts index ee471f1e96..3b95ec7560 100644 --- a/packages/typespec-client-generator-core/src/decorators.ts +++ b/packages/typespec-client-generator-core/src/decorators.ts @@ -712,7 +712,12 @@ export function getUsage(context: TCGCContext, entity: Model | Enum | Union): Us switch (entity.kind) { case "Union": const type = getSdkUnion(context, entity); - if (type.kind === "enum" || type.kind === "union" || type.kind === "nullable") { + if ( + type.kind === "model" || + type.kind === "enum" || + type.kind === "union" || + type.kind === "nullable" + ) { return type.usage; } return UsageFlags.None; @@ -771,7 +776,12 @@ export function getAccess( return getSdkEnum(context, entity).access; case "Union": { const type = getSdkUnion(context, entity); - if (type.kind === "enum" || type.kind === "union" || type.kind === "nullable") { + if ( + type.kind === "model" || + type.kind === "enum" || + type.kind === "union" || + type.kind === "nullable" + ) { return type.access; } return "public"; diff --git a/packages/typespec-client-generator-core/src/diagnostics/union-model-conversion-conflict.md b/packages/typespec-client-generator-core/src/diagnostics/union-model-conversion-conflict.md new file mode 100644 index 0000000000..8b856ef672 --- /dev/null +++ b/packages/typespec-client-generator-core/src/diagnostics/union-model-conversion-conflict.md @@ -0,0 +1,72 @@ +This diagnostic is issued when an inline-discriminated union's discriminator property or tag mappings conflict with another union or an existing model discriminator in the same hierarchy. + +## Impact + +- **Area:** SDK union type conversion. The union resolves to its declared base SDK model, but incompatible discriminator metadata prevents generating a valid polymorphic hierarchy. TCGC reports an error instead of overwriting existing discriminator metadata. +- **Not affected:** The TypeSpec union declaration, service routes, and wire payloads are unchanged. + +## ❌ Incorrect Usage + +```typespec +@service +namespace Example; + +model PetBase { + name: string; +} + +model Cat extends PetBase { + meows: boolean; +} + +@discriminated(#{ envelope: "none" }) +union Pet extends PetBase { + cat: Cat, + feline: Cat, // one model cannot have two polymorphic discriminator values +} + +op get(): Pet; +``` + +## Diagnostic Message + +TCGC reports: + +```text +Discriminated union 'Pet' has conflicting discriminator metadata for its base model hierarchy. Use consistent discriminator properties and tag mappings, or set `convertDiscriminatedUnionsToModels` to `false` to retain union representations. +``` + +## ✅ How to Fix + +Use consistent discriminator properties and tag mappings throughout the shared hierarchy. Give each alternative with a different wire tag its own model. + +```typespec +@service +namespace Example; + +model PetBase { + name: string; +} + +model Cat extends PetBase { + meows: boolean; +} + +model Dog extends PetBase { + barks: boolean; +} + +@discriminated(#{ envelope: "none" }) +union Pet extends PetBase { + cat: Cat, + dog: Dog, +} + +op get(): Pet; +``` + +Emitters that intentionally use native discriminated unions can set `convertDiscriminatedUnionsToModels: false` in `createSdkContext` instead of requesting polymorphic conversion. + +## Suppression + +This diagnostic should not be suppressed. Align the hierarchy's discriminator metadata, or disable conversion when the emitter intentionally retains discriminated unions. diff --git a/packages/typespec-client-generator-core/src/interfaces.ts b/packages/typespec-client-generator-core/src/interfaces.ts index 06caa743b3..930c244bbb 100644 --- a/packages/typespec-client-generator-core/src/interfaces.ts +++ b/packages/typespec-client-generator-core/src/interfaces.ts @@ -66,7 +66,9 @@ export interface TCGCContext { disableUsageAccessPropagationToBase: boolean; flattenUnionAsEnum?: boolean; enableLegacyHierarchyBuilding?: boolean; + convertDiscriminatedUnionsToModels?: boolean; + __discriminatedUnionModels?: Map; __referencedTypeCache: Map; __arrayDictionaryCache: Map; __methodParameterCache: Map; diff --git a/packages/typespec-client-generator-core/src/lib.ts b/packages/typespec-client-generator-core/src/lib.ts index 1f3a5afabc..c34be610ed 100644 --- a/packages/typespec-client-generator-core/src/lib.ts +++ b/packages/typespec-client-generator-core/src/lib.ts @@ -171,6 +171,13 @@ function doc(code: string) { export const $lib = createTypeSpecLibrary({ name: "@azure-tools/typespec-client-generator-core", diagnostics: { + "union-model-conversion-conflict": { + ...doc("union-model-conversion-conflict"), + severity: "error", + messages: { + default: paramMessage`Discriminated union '${"unionName"}' has conflicting discriminator metadata for its base model hierarchy. Use consistent discriminator properties and tag mappings, or set \`convertDiscriminatedUnionsToModels\` to \`false\` to retain union representations.`, + }, + }, "union-null": { ...doc("union-null"), severity: "warning", diff --git a/packages/typespec-client-generator-core/src/types.ts b/packages/typespec-client-generator-core/src/types.ts index 5983978175..5d9a551bb2 100644 --- a/packages/typespec-client-generator-core/src/types.ts +++ b/packages/typespec-client-generator-core/src/types.ts @@ -2,6 +2,7 @@ import { type UnionEnum, getUnionAsEnum } from "@azure-tools/typespec-azure-core import { type BooleanLiteral, type Diagnostic, + type DiscriminatedUnion, type EncodeData, type Enum, type EnumMember, @@ -561,6 +562,18 @@ export function getSdkUnionWithDiagnostics( const diagnostics = createDiagnosticCollector(); if (!retval) { + const discriminated = $(context.program).union.getDiscriminatedUnion(type); + if ( + context.convertDiscriminatedUnionsToModels !== false && + discriminated?.options.envelope === "none" && + !discriminated.defaultVariant && + !isEvents(context.program, type) + ) { + const model = diagnostics.pipe( + getSdkDiscriminatedUnionModel(context, discriminated, operation), + ); + if (model) return diagnostics.wrap(model); + } const nonNullOptions = getNonNullOptions(type); const nullOption = getNullOption(type); @@ -638,7 +651,7 @@ export function getSdkUnionWithDiagnostics( access: "public", usage: UsageFlags.None, }; - const discriminatedOptions = $(context.program).union.getDiscriminatedUnion(type)?.options; + const discriminatedOptions = discriminated?.options; if (discriminatedOptions) { const envelope = discriminatedOptions.envelope ?? "object"; retval.discriminatedOptions = { @@ -692,6 +705,189 @@ export function getSdkUnionWithDiagnostics( return diagnostics.wrap(retval); } +function getSdkDiscriminatedUnionModel( + context: TCGCContext, + discriminated: DiscriminatedUnion, + operation?: Operation, +): [SdkModelType | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const union = discriminated.type; + const base = union.baseType; + if ( + base?.kind !== "Model" || + base.indexer || + discriminated.variants.size === 0 || + [...discriminated.variants.values()].some( + (variant) => + variant.kind !== "Model" || + variant === base || + !walkBaseChain(variant).includes(base) || + walkBaseChain(variant).some( + (model) => getAlternateType(context, model) || getLegacyHierarchyBuilding(context, model), + ), + ) + ) { + return diagnostics.wrap(undefined); + } + + (context.__discriminatedUnionModels ??= new Map()).set(union, base); + const model = diagnostics.pipe(getSdkModelWithDiagnostics(context, base, operation)); + updateReferencedTypeMap(context, union, model); + const variants = [...discriminated.variants].map(([tag, variant]) => { + compilerAssert(variant.kind === "Model", "Inline discriminated union variants must be models."); + return { + tag, + raw: variant, + model: diagnostics.pipe(getSdkModelWithDiagnostics(context, variant, operation)), + }; + }); + const propertyName = discriminated.options.discriminatorPropertyName; + // A payload model can have only one tag. Existing hierarchies must also agree on + // the discriminator's wire name and on tags introduced by other unions. + if ( + new Set(variants.map((variant) => variant.raw)).size !== variants.length || + (model.discriminatorProperty && + (model.discriminatorProperty.serializationOptions.json?.name ?? + model.discriminatorProperty.__raw?.name ?? + model.discriminatorProperty.name) !== propertyName) || + [base, ...variants.map((variant) => variant.raw)].some((raw) => { + const property = walkBaseChain(raw) + .map((ancestor) => ancestor.properties.get(propertyName)) + .find((property) => property !== undefined); + return ( + property && + resolveEncodedName(context.program, property, "application/json") !== propertyName + ); + }) || + variants.some( + (variant) => + (variant.model.discriminatorValue !== undefined && + variant.model.discriminatorValue !== variant.tag) || + (model.discriminatedSubtypes && + Object.hasOwn(model.discriminatedSubtypes, variant.tag) && + model.discriminatedSubtypes[variant.tag] !== variant.model), + ) + ) { + diagnostics.add( + createDiagnostic({ + code: "union-model-conversion-conflict", + target: union, + format: { unionName: union.name ?? "(anonymous)" }, + }), + ); + return diagnostics.wrap(model); + } + model.discriminatorProperty = diagnostics.pipe( + getUnionDiscriminatorProperty(context, base, model, propertyName), + ); + model.discriminatedSubtypes = Object.assign( + Object.create(null) as Record, + model.discriminatedSubtypes, + ); + for (const variant of variants) { + diagnostics.pipe( + getUnionDiscriminatorProperty( + context, + variant.raw, + variant.model, + propertyName, + variant.tag, + model.discriminatorProperty.type, + ), + ); + variant.model.discriminatorValue = variant.tag; + model.discriminatedSubtypes[variant.tag] = variant.model; + } + diagnostics.pipe(updateUsageOrAccess(context, model.usage, model)); + if (model.__accessSet) diagnostics.pipe(updateUsageOrAccess(context, model.access, model)); + const contentTypes: string[] = []; + if (model.serializationOptions.json) contentTypes.push("application/json"); + if (model.serializationOptions.xml) contentTypes.push("application/xml"); + diagnostics.pipe(updateSerializationOptions(context, model, contentTypes)); + return diagnostics.wrap(model); +} + +function getUnionDiscriminatorProperty( + context: TCGCContext, + rawModel: Model, + model: SdkModelType, + name: string, + tag?: string, + discriminatorType?: SdkType, +): [SdkModelPropertyType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + let property = model.properties.find( + (property) => + (property.__raw?.name ?? property.serializationOptions.json?.name ?? property.name) === name, + ); + const rawProperty = walkBaseChain(rawModel) + .map((ancestor) => ancestor.properties.get(name)) + .find((property) => property !== undefined); + const source = rawProperty + ? diagnostics.pipe(getSdkModelPropertyType(context, rawProperty)) + : undefined; + if (!property && rawModel.properties.has(name)) property = source; + if (!property) { + const type: SdkType = + tag === undefined + ? (source?.type ?? getTypeSpecBuiltInType(context, "string")) + : { + kind: "constant", + value: tag, + valueType: getTypeSpecBuiltInType(context, "string"), + name: `${model.name}${pascalCase(name)}`, + isGeneratedName: true, + isExactName: false, + decorators: [], + }; + property = createDiscriminatorProperty(model, source?.name ?? name, type, name); + property.__raw = rawProperty; + model.properties.unshift(property); + } + if ( + tag !== undefined && + property.type.kind === "constant" && + discriminatorType?.kind === "enum" + ) { + const value = discriminatorType.values.find((value) => value.value === tag); + if (value) property.type = value; + } + property.optional = false; + property.discriminator = true; + property.serializationOptions.json ??= { name }; + return diagnostics.wrap(property); +} + +function createDiscriminatorProperty( + model: SdkModelType, + name: string, + type: SdkType, + serializedName: string, +): SdkModelPropertyType { + return { + kind: "property", + doc: `Discriminator property for ${model.name}.`, + optional: false, + discriminator: true, + // eslint-disable-next-line @typescript-eslint/no-deprecated + serializedName, + serializationOptions: {}, + type, + name, + isGeneratedName: false, + isExactName: false, + onClient: false, + apiVersions: model.apiVersions, + isApiVersionParam: false, + // eslint-disable-next-line @typescript-eslint/no-deprecated + isMultipartFileInput: false, + flatten: false, + crossLanguageDefinitionId: `${model.crossLanguageDefinitionId}.${name}`, + decorators: [], + access: "public", + }; +} + function getEmptyUnionType( context: TCGCContext, type: Union, @@ -835,32 +1031,17 @@ function addDiscriminatorToModelType( discriminatorType = getTypeSpecBuiltInType(context, "string"); } const name = discriminatorProperty ? discriminatorProperty.name : discriminator.propertyName; - const discriminatorPropertyType: SdkModelPropertyType = { - kind: "property", - doc: `Discriminator property for ${model.name}.`, - optional: false, - discriminator: true, - // eslint-disable-next-line @typescript-eslint/no-deprecated - serializedName: discriminatorProperty + const discriminatorPropertyType = createDiscriminatorProperty( + model, + name, + discriminatorType!, + discriminatorProperty ? discriminatorProperty.serializedName // eslint-disable-line @typescript-eslint/no-deprecated : discriminator.propertyName, - serializationOptions: {}, - type: discriminatorType!, - name, - isGeneratedName: false, - isExactName: false, - onClient: false, - apiVersions: discriminatorProperty - ? getAvailableApiVersions(context, discriminatorProperty.__raw!, type) - : model.apiVersions, - isApiVersionParam: false, - // eslint-disable-next-line @typescript-eslint/no-deprecated - isMultipartFileInput: false, // discriminator property cannot be a file - flatten: false, // discriminator properties can not be flattened - crossLanguageDefinitionId: `${model.crossLanguageDefinitionId}.${name}`, - decorators: [], - access: "public", - }; + ); + discriminatorPropertyType.apiVersions = discriminatorProperty + ? getAvailableApiVersions(context, discriminatorProperty.__raw!, type) + : model.apiVersions; model.properties.splice(0, 0, discriminatorPropertyType); model.discriminatorProperty = discriminatorPropertyType; } @@ -900,6 +1081,10 @@ export function getSdkModelWithDiagnostics( serializationOptions: {}, }; updateReferencedTypeMap(context, type, sdkType); + // Register aliases before traversing properties that can refer back to the union. + for (const [union, base] of context.__discriminatedUnionModels ?? []) { + if (base === type) updateReferencedTypeMap(context, union, sdkType); + } // model MyModel is Record<> {} should be model with additional properties if (type.sourceModel?.kind === "Model" && type.sourceModel?.name === "Record") { @@ -2094,15 +2279,28 @@ function updateTypesFromOperation( function updateAccessOverride(context: TCGCContext): [void, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); + const overrides = new Map(); + for (const [rawType, sdkType] of context.__referencedTypeCache) { + if (rawType.kind !== "Model" && rawType.kind !== "Enum" && rawType.kind !== "Union") continue; + const access = getAccessOverride(context, rawType); + if (access && (access === "public" || !overrides.has(sdkType))) overrides.set(sdkType, access); + } + // Seed orphan hierarchies from all aliases before a raw base or subtype gets a + // public default. Already-inferred access still participates in conflict checks below. + for (const [sdkType, access] of overrides) { + if (!sdkType.__accessSet) diagnostics.pipe(updateUsageOrAccess(context, access, sdkType)); + } // set access for all orphan model without override - for (const sdkType of context.__referencedTypeCache.values()) { - const accessOverride = getAccessOverride(context, sdkType.__raw as any); + for (const [rawType, sdkType] of context.__referencedTypeCache) { + if (rawType.kind !== "Model" && rawType.kind !== "Enum" && rawType.kind !== "Union") continue; + const accessOverride = getAccessOverride(context, rawType); if (!sdkType.__accessSet && accessOverride === undefined) { diagnostics.pipe(updateUsageOrAccess(context, "public", sdkType)); } } - for (const sdkType of context.__referencedTypeCache.values()) { - const accessOverride = getAccessOverride(context, sdkType.__raw as any); + for (const [rawType, sdkType] of context.__referencedTypeCache) { + if (rawType.kind !== "Model" && rawType.kind !== "Enum" && rawType.kind !== "Union") continue; + const accessOverride = getAccessOverride(context, rawType); if (accessOverride) { diagnostics.pipe(updateUsageOrAccess(context, accessOverride, sdkType, { isOverride: true })); } @@ -2112,8 +2310,9 @@ function updateAccessOverride(context: TCGCContext): [void, readonly Diagnostic[ function updateUsageOverride(context: TCGCContext): [void, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); - for (const sdkType of context.__referencedTypeCache.values()) { - const usageOverride = getUsageOverride(context, sdkType.__raw as any); + for (const [rawType, sdkType] of context.__referencedTypeCache) { + if (rawType.kind !== "Model" && rawType.kind !== "Enum" && rawType.kind !== "Union") continue; + const usageOverride = getUsageOverride(context, rawType); if (usageOverride) { diagnostics.pipe(updateUsageOrAccess(context, usageOverride, sdkType, { isOverride: true })); if (usageOverride & UsageFlags.Json) { diff --git a/packages/typespec-client-generator-core/test/tester.ts b/packages/typespec-client-generator-core/test/tester.ts index 9bf184a683..4080a9b478 100644 --- a/packages/typespec-client-generator-core/test/tester.ts +++ b/packages/typespec-client-generator-core/test/tester.ts @@ -88,6 +88,7 @@ ${x} * XML tester with XML library support */ export const XmlTester = createTester(resolvePath(import.meta.dirname, ".."), { + features: ["union-extends"], libraries: [ "@typespec/http", "@typespec/rest", diff --git a/packages/typespec-client-generator-core/test/types/union.test.ts b/packages/typespec-client-generator-core/test/types/union.test.ts index 9e01da08d6..154b41d50e 100644 --- a/packages/typespec-client-generator-core/test/types/union.test.ts +++ b/packages/typespec-client-generator-core/test/types/union.test.ts @@ -1,10 +1,699 @@ -import { expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; -import { it } from "vitest"; +import { describe, it } from "vitest"; +import { createTCGCContext } from "../../src/context.js"; +import { getAccess, getUsage } from "../../src/decorators.js"; import { type SdkArrayType, type SdkMethodResponse, UsageFlags } from "../../src/interfaces.js"; -import { createSdkContextForTester, SimpleTester, SimpleTesterWithService } from "../tester.js"; +import { getSdkModel, getSdkUnion } from "../../src/types.js"; +import { + createSdkContextForTester, + SimpleTester, + SimpleTesterWithService, + XmlTesterWithBuiltInService, +} from "../tester.js"; import { getSdkTypeHelper } from "./utils.js"; +describe("inline discriminated union conversion", () => { + it.each([false, true])( + "converts the hierarchy with injected discriminator=%s", + async (injected) => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { + ${injected ? "" : "kind: string;"} + name: string; + } + model Mammal extends PetBase { age?: int32; } + model Cat extends Mammal { ${injected ? "" : 'kind: "cat";'} meows: boolean; } + model Dog extends PetBase { ${injected ? "" : 'kind: "dog";'} barks: boolean; } + model Unrelated extends PetBase { flies: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat, dog: Dog } + @post op echo(@body pet: Pet): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((x) => x.name === "PetBase"); + ok(base); + strictEqual(context.sdkPackage.unions.length, 0); + const method = context.sdkPackage.clients[0].methods[0]; + strictEqual(method.parameters[0].type, base); + strictEqual(method.response.type, base); + strictEqual(method.operation.bodyParam?.type, base); + strictEqual(method.operation.responses[0].type, base); + strictEqual(base.discriminatorProperty?.name, "kind"); + strictEqual(base.discriminatorProperty?.serializationOptions.json?.name, "kind"); + deepStrictEqual(Object.keys(base.discriminatedSubtypes ?? {}), ["cat", "dog"]); + const mammal = context.sdkPackage.models.find((model) => model.name === "Mammal"); + ok(mammal); + strictEqual(mammal.baseModel, base); + strictEqual(base.discriminatedSubtypes?.cat.baseModel, mammal); + strictEqual(mammal.discriminatorProperty, undefined); + strictEqual(base.properties.filter((property) => property.discriminator).length, 1); + const union = context + .getMutatedGlobalNamespace() + .namespaces.get("TestService")! + .unions.get("Pet")!; + strictEqual(getUsage(context, union), base.usage); + for (const [tag, name] of [ + ["cat", "Cat"], + ["dog", "Dog"], + ]) { + const variant = context.sdkPackage.models.find((x) => x.name === name); + ok(variant); + strictEqual(base.discriminatedSubtypes?.[tag], variant); + strictEqual(variant.discriminatorValue, tag); + const property = variant.properties.find((x) => x.discriminator); + ok(property); + strictEqual(property.type.kind, "constant"); + strictEqual(property.type.value, tag); + strictEqual(property.serializationOptions.json?.name, "kind"); + strictEqual(variant.usage, UsageFlags.Input | UsageFlags.Output | UsageFlags.Json); + } + strictEqual( + context.sdkPackage.models.some((x) => x.name === "Unrelated"), + false, + ); + }, + ); + + it.each([false, true])( + "does not convert a union containing its base with other subtypes=%s", + async (withSubtype) => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { base: PetBase, ${withSubtype ? "cat: Cat" : ""} } + @post op echo(@body body: Pet): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics( + context.diagnostics, + Array.from({ length: withSubtype ? 2 : 1 }, () => ({ + code: "@azure-tools/typespec-azure-core/union-enums-invalid-kind", + severity: "warning" as const, + })), + ); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + const union = context.sdkPackage.unions[0]; + ok(union); + strictEqual(union.kind, "union"); + strictEqual(union.baseType, base); + strictEqual(union.discriminatedOptions?.envelope, "none"); + strictEqual(context.sdkPackage.clients[0].methods[0].parameters[0].type, union); + strictEqual(context.sdkPackage.clients[0].methods[0].response.type, union); + deepStrictEqual(union.variantTypes, [ + base, + ...(withSubtype ? [context.sdkPackage.models.find((model) => model.name === "Cat")] : []), + ]); + strictEqual(base.discriminatorProperty, undefined); + strictEqual(base.discriminatorValue, undefined); + strictEqual(base.discriminatedSubtypes, undefined); + strictEqual( + context.sdkPackage.models.find((model) => model.name === "Cat")?.discriminatorValue, + undefined, + ); + }, + ); + + it("preserves unions and variant identity when conversion is disabled", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + op get(): Pet; + `); + const context = await createSdkContextForTester( + program, + {}, + { convertDiscriminatedUnionsToModels: false }, + ); + expectDiagnostics(context.diagnostics, { + code: "@azure-tools/typespec-azure-core/union-enums-invalid-kind", + severity: "warning", + }); + const union = context.sdkPackage.unions[0]; + strictEqual(union.kind, "union"); + strictEqual(union.discriminatedOptions?.envelope, "none"); + strictEqual( + union.variantTypes[0], + context.sdkPackage.models.find((model) => model.name === "Cat"), + ); + strictEqual(context.sdkPackage.clients[0].methods[0].response.type, union); + const base = context.sdkPackage.models.find((x) => x.name === "PetBase"); + ok(base); + strictEqual(base.discriminatorProperty, undefined); + strictEqual(base.discriminatedSubtypes, undefined); + strictEqual( + context.sdkPackage.models.find((x) => x.name === "Cat")?.discriminatorValue, + undefined, + ); + const converted = await createSdkContextForTester(program); + strictEqual(converted.sdkPackage.clients[0].methods[0].response.type?.kind, "model"); + strictEqual(base.discriminatorProperty, undefined); + }); + + it("converts recursive properties, containers, and nullable uses regardless of traversal order", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; next?: Pet; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + model Holder { pet: Pet; pets: Pet[]; lookup: Record; maybe: Pet | null; } + op getCat(): Cat; + @post op echo(@body body: Holder): Holder; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((x) => x.name === "PetBase"); + const holder = context.sdkPackage.models.find((x) => x.name === "Holder"); + ok(base); + ok(holder); + strictEqual(base.properties.find((x) => x.name === "next")?.type, base); + strictEqual(holder.properties[0].type, base); + const array = holder.properties[1].type; + strictEqual(array.kind, "array"); + strictEqual(array.valueType, base); + const dict = holder.properties[2].type; + strictEqual(dict.kind, "dict"); + strictEqual(dict.valueType, base); + const nullable = holder.properties[3].type; + strictEqual(nullable.kind, "nullable"); + strictEqual(nullable.type, base); + strictEqual(context.sdkPackage.models.find((x) => x.name === "Cat")?.discriminatorValue, "cat"); + }); + + it("reuses discriminator properties declared after recursive union properties", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { + next?: Pet; + @clientName("clientKind") kind: string; + } + model Cat extends PetBase { kind: "cat"; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + @route("/base") op getBase(): PetBase; + @route("/union") op getUnion(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + strictEqual(base.properties.filter((property) => property.discriminator).length, 1); + strictEqual( + base.discriminatorProperty, + base.properties.find((property) => property.name === "clientKind"), + ); + strictEqual(base.properties.find((property) => property.name === "next")?.type, base); + context.sdkPackage.clients[0].methods.forEach((method) => + strictEqual(method.response.type, base), + ); + }); + + it("updates previously handled model properties and HTTP types to the projected base", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + model Holder { pet: Pet; } + @route("/base") op getBase(): PetBase; + @route("/holder") @post op echo(@body body: Holder): Holder; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const [getBase, echo] = context.sdkPackage.clients[0].methods; + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + const holder = context.sdkPackage.models.find((model) => model.name === "Holder"); + ok(base); + ok(holder); + strictEqual(getBase.response.type, base); + strictEqual(getBase.operation.responses[0].type, base); + strictEqual(echo.parameters[0].type, holder); + strictEqual(echo.operation.bodyParam?.type, holder); + strictEqual(holder.properties[0].type, base); + strictEqual(base.discriminatedSubtypes?.cat.discriminatorValue, "cat"); + }); + + it("preserves standalone SDK union conversion after the base is already cached", async () => { + const { program, PetBase, Pet } = await SimpleTesterWithService.compile(t.code` + model ${t.model("PetBase")} { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union ${t.union("Pet")} extends PetBase { cat: Cat } + `); + const context = createTCGCContext(program, "@azure-tools/typespec-test", { + mutateNamespace: false, + }); + const base = getSdkModel(context, PetBase); + const projected = getSdkUnion(context, Pet); + strictEqual(projected, base); + strictEqual(base.discriminatedSubtypes?.cat.discriminatorValue, "cat"); + strictEqual(base.properties.filter((property) => property.discriminator).length, 1); + }); + + it("discovers instantiated union templates during normal type handling", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: T } + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + strictEqual(context.sdkPackage.clients[0].methods[0].response.type, base); + strictEqual(base.discriminatedSubtypes?.cat.discriminatorValue, "cat"); + }); + + it("uses wire tags independently of client names", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { @clientName("clientKind") type: string; } + @clientName("Kitten") + model Cat extends PetBase { @clientName("clientKind") type: "feline"; } + @discriminated(#{ envelope: "none", discriminatorPropertyName: "type" }) + union Pet extends PetBase { feline: Cat } + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((x) => x.name === "PetBase"); + ok(base); + strictEqual(base.discriminatorProperty?.name, "clientKind"); + strictEqual(base.discriminatorProperty?.serializationOptions.json?.name, "type"); + strictEqual(base.discriminatedSubtypes?.feline.name, "Kitten"); + strictEqual(base.discriminatedSubtypes?.feline.discriminatorValue, "feline"); + }); + + it("preserves client names when injecting a subtype discriminator", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { @clientName("clientKind") kind: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const cat = context.sdkPackage.models.find((x) => x.name === "Cat"); + ok(cat); + const property = cat.properties.find((x) => x.discriminator); + ok(property); + strictEqual(property.name, "clientKind"); + strictEqual(property.serializationOptions.json?.name, "kind"); + }); + + it.each(["public", "internal"] as const)( + "preserves union usage and %s access overrides on the projected base", + async (access) => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @usage(Usage.input | Usage.output | Usage.json) + @access(Access.${access}) + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((x) => x.name === "PetBase"); + ok(base); + strictEqual(base.usage, UsageFlags.Input | UsageFlags.Output | UsageFlags.Json); + strictEqual(base.access, access); + strictEqual(base.discriminatedSubtypes?.cat.access, access); + strictEqual(base.discriminatorProperty?.serializationOptions.json?.name, "kind"); + }, + ); + + it("still reports internal union overrides conflicting with public operation use", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @access(Access.internal) + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics( + context.diagnostics, + Array.from({ length: 2 }, () => ({ + code: "@azure-tools/typespec-client-generator-core/conflict-access-override", + })), + ); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + strictEqual(base.access, "public"); + strictEqual(base.discriminatedSubtypes?.cat.access, "public"); + }); + + it("preserves inherited discriminator XML metadata without changing the inherited SDK property", async () => { + const { program } = await XmlTesterWithBuiltInService.compile(` + model Entity { + @clientName("clientKind") + @encodedName("application/xml", "XmlKind") + @Xml.attribute + @Xml.ns("https://example.com/pets", "p") + kind: string; + } + model PetBase extends Entity { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + @post op echo(@header contentType: "application/xml", @body body: Pet): + { @header contentType: "application/xml"; @body body: Pet; }; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + const inherited = base.baseModel?.properties.find((property) => property.name === "clientKind"); + const subtype = base.discriminatedSubtypes?.cat.properties.find( + (property) => property.discriminator, + ); + ok(inherited); + ok(subtype); + for (const property of [base.discriminatorProperty, subtype]) { + ok(property); + strictEqual(property.__raw, inherited.__raw); + strictEqual(property.name, "clientKind"); + strictEqual(property.serializationOptions.xml?.name, "XmlKind"); + strictEqual(property.serializationOptions.xml?.attribute, true); + deepStrictEqual(property.serializationOptions.xml?.ns, { + namespace: "https://example.com/pets", + prefix: "p", + }); + } + strictEqual(inherited.discriminator, false); + strictEqual(inherited.type.kind, "string"); + strictEqual(subtype.type.kind, "constant"); + }); + + it("preserves an inherited discriminator's client name and enum type", async () => { + const { program } = await SimpleTesterWithService.compile(` + union PetKind { cat: "cat", dog: "dog" } + model NamedEntity { @clientName("clientKind") kind: PetKind; } + model Entity extends NamedEntity { id: string; } + model PetBase extends Entity { name: string; } + model Cat extends PetBase { meows: boolean; } + model Dog extends PetBase { barks: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + @discriminated(#{ envelope: "none" }) + union OtherPet extends PetBase { dog: Dog } + @route("/cat") op get(): Pet; + @route("/dog") op getOther(): OtherPet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + strictEqual(base.discriminatorProperty?.name, "clientKind"); + strictEqual(base.discriminatorProperty?.serializationOptions.json?.name, "kind"); + strictEqual(base.discriminatorProperty?.type.kind, "enum"); + strictEqual(base.baseModel?.discriminatorProperty, undefined); + const entity = base.baseModel; + const root = entity?.baseModel; + ok(entity); + ok(root); + strictEqual(entity.name, "Entity"); + strictEqual(root.name, "NamedEntity"); + strictEqual(root.discriminatorProperty, undefined); + const inherited = root.properties.find((property) => property.name === "clientKind"); + ok(inherited); + strictEqual(base.discriminatorProperty?.__raw, inherited.__raw); + strictEqual(base.discriminatorProperty?.type, inherited.type); + const property = base.discriminatedSubtypes?.cat.properties.find( + (property) => property.discriminator, + ); + ok(property); + strictEqual(property.name, "clientKind"); + strictEqual(property.type.kind, "enumvalue"); + strictEqual(base.discriminatedSubtypes?.cat.baseModel, base); + strictEqual(base.discriminatedSubtypes?.dog.discriminatorValue, "dog"); + context.sdkPackage.clients[0].methods.forEach((method) => + strictEqual(method.response.type, base), + ); + }); + + it.each([false, true])( + "preserves enum discriminator types with injected discriminator=%s", + async (injected) => { + const { program } = await SimpleTesterWithService.compile(` + union PetKind { cat: "cat", dog: "dog" } + model PetBase { kind: PetKind; } + model Cat extends PetBase { ${injected ? "" : 'kind: "cat";'} meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + const enumType = base.discriminatorProperty?.type; + ok(enumType); + strictEqual(enumType.kind, "enum"); + strictEqual( + base.discriminatedSubtypes?.cat.properties.find((property) => property.discriminator)?.type, + enumType.values.find((value) => value.value === "cat"), + ); + }, + ); + + it("combines compatible disjoint unions using the same base", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + model Dog extends PetBase { barks: boolean; } + @discriminated(#{ envelope: "none" }) + union IndoorPet extends PetBase { cat: Cat } + @discriminated(#{ envelope: "none" }) + union OutdoorPet extends PetBase { dog: Dog } + @route("/indoor") op indoor(): IndoorPet; + @route("/outdoor") op outdoor(): OutdoorPet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((x) => x.name === "PetBase"); + ok(base); + deepStrictEqual(Object.keys(base.discriminatedSubtypes ?? {}), ["cat", "dog"]); + context.sdkPackage.clients[0].methods.forEach((method) => + strictEqual(method.response.type, base), + ); + strictEqual(base.properties.filter((property) => property.discriminator).length, 1); + strictEqual(context.sdkPackage.models.filter((x) => x.name === "PetBase").length, 1); + }); + + it("propagates combined usage, access, and serialization after shared-base conversion", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + model Dog extends PetBase { barks: boolean; } + @discriminated(#{ envelope: "none" }) + union IndoorPet extends PetBase { cat: Cat } + @discriminated(#{ envelope: "none" }) + union OutdoorPet extends PetBase { dog: Dog } + @access(Access.internal) + @route("/indoor") @post + op indoor(@header contentType: "application/xml", @body body: IndoorPet): void; + @route("/outdoor") op outdoor(): OutdoorPet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + deepStrictEqual(Object.keys(base.discriminatedSubtypes ?? {}), ["cat", "dog"]); + for (const variant of Object.values(base.discriminatedSubtypes ?? {})) { + strictEqual( + variant.usage, + UsageFlags.Input | UsageFlags.Output | UsageFlags.Json | UsageFlags.Xml, + ); + strictEqual(variant.access, "public"); + ok(variant.serializationOptions.json); + ok(variant.serializationOptions.xml); + } + strictEqual(context.sdkPackage.clients[0].methods[0].operation.bodyParam?.type, base); + strictEqual(context.sdkPackage.clients[0].methods[1].response.type, base); + }); + + it("aliases connected unions to their respective bases without rolling back valid hierarchies", async () => { + const { program } = await SimpleTesterWithService.compile(` + model AnimalBase { name: string; } + model PetBase extends AnimalBase { } + model Cat extends PetBase { meows: boolean; } + model Dog extends PetBase { barks: boolean; } + @discriminated(#{ envelope: "none" }) + union Pets extends PetBase { cat: Cat } + @discriminated(#{ envelope: "none" }) + union OtherPets extends PetBase { feline: Cat } + @discriminated(#{ envelope: "none" }) + union Animals extends AnimalBase { dog: Dog } + @route("/pets") op pets(): Pets; + @route("/others") op others(): OtherPets; + @route("/animals") op animals(): Animals; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, { + code: "@azure-tools/typespec-client-generator-core/union-model-conversion-conflict", + severity: "error", + }); + const petBase = context.sdkPackage.models.find((model) => model.name === "PetBase"); + const animalBase = context.sdkPackage.models.find((model) => model.name === "AnimalBase"); + ok(petBase); + ok(animalBase); + const methods = context.sdkPackage.clients[0].methods; + strictEqual(methods[0].response.type, petBase); + strictEqual(methods[1].response.type, petBase); + strictEqual(methods[2].response.type, animalBase); + strictEqual(petBase.discriminatedSubtypes?.cat.discriminatorValue, "cat"); + strictEqual(animalBase.discriminatedSubtypes?.dog.discriminatorValue, "dog"); + }); + + it("propagates internal operation access to the converted hierarchy", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + @access(Access.internal) + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + strictEqual(base.access, "internal"); + strictEqual(base.discriminatedSubtypes?.cat.access, "internal"); + const union = context + .getMutatedGlobalNamespace() + .namespaces.get("TestService")! + .unions.get("Pet")!; + strictEqual(getAccess(context, union), "internal"); + }); + + it("keeps conflicting unions aliased to the base without overwriting subtype tags", async () => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { cat: Cat } + @discriminated(#{ envelope: "none" }) + union Animal extends PetBase { feline: Cat } + @route("/base") op base(): PetBase; + @route("/pet") op pet(): Pet; + @route("/animal") op animal(): Animal; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, { + code: "@azure-tools/typespec-client-generator-core/union-model-conversion-conflict", + severity: "error", + }); + const base = context.sdkPackage.models.find((model) => model.name === "PetBase"); + ok(base); + context.sdkPackage.clients[0].methods.forEach((method) => + strictEqual(method.response.type, base), + ); + deepStrictEqual(Object.keys(base.discriminatedSubtypes ?? {}), ["cat"]); + strictEqual(context.sdkPackage.models.find((x) => x.name === "Cat")?.discriminatorValue, "cat"); + }); + + it.each([ + { name: "repeated payload", properties: "", variants: "cat: Cat, feline: Cat" }, + { + name: "encoded discriminator", + properties: '@encodedName("application/json", "type") kind: string;', + variants: "cat: Cat", + }, + ])("reports incompatible $name metadata", async ({ properties, variants }) => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { ${properties} name: string; } + model Cat extends PetBase { meows: boolean; } + @discriminated(#{ envelope: "none" }) + union Pet extends PetBase { ${variants} } + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, { + code: "@azure-tools/typespec-client-generator-core/union-model-conversion-conflict", + severity: "error", + }); + strictEqual( + context.sdkPackage.clients[0].methods[0].response.type, + context.sdkPackage.models.find((model) => model.name === "PetBase"), + ); + strictEqual( + context.sdkPackage.models.find((x) => x.name === "Cat")?.discriminatorValue, + undefined, + ); + }); + + it.each([ + { + name: "ordinary", + decorator: "", + cat: "extends PetBase", + constraint: "extends PetBase", + variants: "cat: Cat, dog: Dog", + }, + { + name: "envelope", + decorator: "@discriminated", + cat: "extends PetBase", + constraint: "extends PetBase", + variants: "cat: Cat", + }, + { + name: "structural", + decorator: '@discriminated(#{ envelope: "none" })', + cat: "", + constraint: "extends PetBase", + variants: "cat: Cat", + }, + { + name: "no base", + decorator: '@discriminated(#{ envelope: "none" })', + cat: "extends PetBase", + constraint: "", + variants: "cat: Cat", + }, + { + name: "default", + decorator: '@discriminated(#{ envelope: "none" })', + cat: "extends PetBase", + constraint: "extends PetBase", + variants: "cat: Cat, UnknownPet", + }, + ])("leaves $name unions unchanged", async ({ decorator, cat, constraint, variants }) => { + const { program } = await SimpleTesterWithService.compile(` + model PetBase { name: string; } + model Cat ${cat} { name: string; meows: boolean; } + model Dog extends PetBase { barks: boolean; } + model UnknownPet extends PetBase { kind: string; } + ${decorator} + union Pet ${constraint} { ${variants} } + op get(): Pet; + `); + const context = await createSdkContextForTester(program); + expectDiagnostics( + context.diagnostics, + Array.from({ length: variants.includes(",") ? 2 : 1 }, () => ({ + code: "@azure-tools/typespec-azure-core/union-enums-invalid-kind", + severity: "warning" as const, + })), + ); + strictEqual(context.sdkPackage.clients[0].methods[0].response.type?.kind, "union"); + strictEqual( + context.sdkPackage.models.find((x) => x.name === "PetBase")?.discriminatorProperty, + undefined, + ); + }); +}); + it("primitive union", async function () { const { program } = await SimpleTesterWithService.compile( ` diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md index 3a0194e1a1..1786993479 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md @@ -62,6 +62,8 @@ Use the [TCGC Playground](https://azure.github.io/typespec-azure/playground/?e=% TCGC provides flags to control the client type graph style, such as enabling or disabling convenience and protocol APIs for Java and C# emitters. Other emitters should not set `generate-convenience-methods` or `generate-protocol-methods`; TCGC reports an `unnecessary-emitter-option` warning when either option is explicitly set for another language. See the [documentation](../reference/emitter/#emitter-options) for details. +By default, `createSdkContext` converts eligible inline-discriminated unions to the existing polymorphic model representation. Emitters that use native discriminated unions, such as Rust, can opt out with `{ convertDiscriminatedUnionsToModels: false }` in the third argument to `createSdkContext`. + ## TCGC Raw Types and Helpers In order to introduce the client concept, TCGC introduces some new raw types and helper functions. @@ -241,6 +243,36 @@ For types in TypeSpec, TCGC provides several client types to represent them in a - [`SdkUnionType`](../reference/js-api/interfaces/sdkuniontype/) represents a TCGC union type. It is typically converted from a TypeSpec [`Union`](https://typespec.io/docs/language-basics/unions/) type. - When a TypeSpec union declares an `extends` constraint, `SdkUnionType.baseType` preserves that explicit constraint. TCGC does not infer `baseType` merely because all variants share a common ancestor. Usage, access, and serialization information propagate from the union to its explicit base type, including through nullable wrappers and other union constraints. This propagation does not include unrelated discriminated subtypes of a base model. +#### Inline-discriminated union conversion + +An inline-discriminated union handled by the SDK context is converted when it uses `@discriminated(#{ envelope: "none" })`, explicitly extends a named model, and every alternative is a named variant whose model nominally inherits from that base, directly or transitively. Unhandled unions do not reshape otherwise-used models. + +```tsp +model PetBase { + name: string; +} +model Cat extends PetBase { + meows: boolean; +} +model Dog extends PetBase { + barks: boolean; +} + +@discriminated(#{ envelope: "none" }) +union Pet extends PetBase { + cat: Cat, + dog: Dog, +} + +@post op echo(@body pet: Pet): Pet; +``` + +References to `Pet` resolve to the existing `PetBase` SDK model, including operation parameters/responses, model properties, containers, and nullable uses. The base's `discriminatorProperty` and `discriminatedSubtypes`, plus each variant's `discriminatorValue` and discriminator property, describe the inline tags. Missing discriminator properties are synthesized; the compiler type graph is not changed. The projected model retains the base model's name and identity rather than introducing a model named after the union. + +Ordinary unions, object-envelope unions, structural-only variants, unions without a model constraint, and unions containing defaults or the base model itself as an alternative are not converted. Alternate types and legacy hierarchy rebasing retain their existing handling. Eligible unions always resolve to their base SDK model. Conflicting discriminator metadata produces a `union-model-conversion-conflict` error rather than overwriting an existing hierarchy or falling back to a union representation. + +Compatible unions sharing the same base share its SDK model and combined subtype map; the polymorphic representation does not enforce each union's subset separately. Synthesized inline discriminator properties also appear on directly used SDK variants. Disable conversion when an emitter needs union-specific serialization or exact alternative-set information. + **Model Types:** - [`SdkModelType`](../reference/js-api/interfaces/sdkmodeltype/) represents a TCGC model type. It is typically converted from a TypeSpec [`Model`](https://typespec.io/docs/language-basics/models/) type.