From ff0bed2835b25e1a364d490146da45ca3eb497ce Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 26 Aug 2026 21:45:02 -0700 Subject: [PATCH 1/6] feat(compiler): add `extends` base type clause for unions A named union can now declare a base type with `extends`. Every variant must be assignable to that base type, and the resolved type is exposed on the type graph as `Union.baseType` so emitters can represent the union with a polymorphic base type in languages without native unions. `extends` on a union is purely a constraint: it doesn't create any inheritance relationship, the base type doesn't become a variant, it doesn't make the union extensible and it has no interaction with `@discriminator`. Fixes #2737 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: be5c2e95-cfc7-417c-bc70-b34cf66bbea4 --- .../union-extends-base-type-2026-8-26.md | 28 ++ grammars/typespec.json | 24 + packages/compiler/src/core/checker.ts | 109 +++++ packages/compiler/src/core/parser.ts | 12 + packages/compiler/src/core/semantic-walker.ts | 3 + packages/compiler/src/core/types.ts | 20 + .../compiler/src/experimental/mutators.ts | 1 + .../src/formatter/print/comment-handler.ts | 26 ++ .../compiler/src/formatter/print/printer.ts | 10 +- packages/compiler/src/server/completion.ts | 12 +- packages/compiler/src/server/tmlanguage.ts | 18 +- packages/compiler/test/checker/union.test.ts | 433 +++++++++++++++++- .../test/experimental/mutator.test.ts | 12 + .../compiler/test/formatter/formatter.test.ts | 103 +++++ .../test/formatter/scenarios/inputs/union.tsp | 7 + .../formatter/scenarios/outputs/union.tsp | 9 + packages/compiler/test/parser.test.ts | 17 +- .../compiler/test/semantic-walker.test.ts | 18 + .../compiler/test/server/colorization.test.ts | 35 ++ .../compiler/test/server/completion.test.ts | 36 ++ packages/spec/src/spec.emu.html | 5 +- .../docs/docs/language-basics/unions.md | 71 +++ 22 files changed, 1001 insertions(+), 8 deletions(-) create mode 100644 .chronus/changes/union-extends-base-type-2026-8-26.md diff --git a/.chronus/changes/union-extends-base-type-2026-8-26.md b/.chronus/changes/union-extends-base-type-2026-8-26.md new file mode 100644 index 00000000000..07441da5037 --- /dev/null +++ b/.chronus/changes/union-extends-base-type-2026-8-26.md @@ -0,0 +1,28 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add support for an `extends` clause on union statements to constrain every variant to a common base type. + +```tsp +model PetBase { + name: string; +} +model Cat extends PetBase { + toy: string; +} +model Dog extends PetBase { + food: string; +} + +union Pet extends PetBase { + cat: Cat, + dog: Dog, +} +``` + +The base type is exposed on the type graph as `Union.baseType`, giving emitters an easy way to know that all the variants of a union share a common base type. A diagnostic is reported on any variant that isn't assignable to the base type. + +`extends` on a union is purely a constraint: it doesn't imply any subtyping relationship, it doesn't make the union extensible and it has no interaction with `@discriminator`. diff --git a/grammars/typespec.json b/grammars/typespec.json index c29ce411b12..1c9c41f6bdf 100644 --- a/grammars/typespec.json +++ b/grammars/typespec.json @@ -1378,6 +1378,24 @@ } ] }, + "union-extends": { + "name": "meta.union-extends.typespec", + "begin": "\\b(extends)\\b", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + } + }, + "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "patterns": [ + { + "include": "#expression" + }, + { + "include": "#punctuation-comma" + } + ] + }, "union-statement": { "name": "meta.union-statement.typespec", "begin": "(?:(internal)\\s+)?\\b(union)\\b\\s+(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)", @@ -1397,6 +1415,12 @@ { "include": "#token" }, + { + "include": "#type-parameters" + }, + { + "include": "#union-extends" + }, { "include": "#union-body" } diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 9d989e33d61..2372ad81099 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -1399,6 +1399,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); case SyntaxKind.InterfaceStatement: return checkDeprecatedNode(node); + case SyntaxKind.UnionStatement: + return checkDeprecatedNode(node); case SyntaxKind.IntersectionExpression: case SyntaxKind.UnionExpression: case SyntaxKind.ModelProperty: @@ -7786,6 +7788,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker }); linkType(ctx, links, unionType); + if (node.extends) { + unionType.baseType = checkUnionBaseType(ctx, node, unionType, node.extends); + } + unionType.decorators = checkDecorators(ctx, unionType, node); checkUnionVariants(ctx, unionType, node, variants); @@ -7823,9 +7829,112 @@ export function createChecker(program: Program, resolver: NameResolver): Checker continue; } variants.set(variantType.name as string, variantType); + checkUnionVariantAgainstBaseType(ctx, parentUnion, variantNode, variantType); + } + } + + /** + * Validate that a union variant satisfies the constraint declared by the union `extends` clause. + * Skipped inside of an uninstantiated template declaration where variant types are still + * unresolved template parameters. Each instantiation is checked instead. + */ + function checkUnionVariantAgainstBaseType( + ctx: CheckContext, + parentUnion: Union, + variantNode: UnionVariantNode, + variantType: UnionVariant, + ) { + const baseType = parentUnion.baseType; + if (baseType === undefined || ctx.hasFlags(CheckFlags.InTemplateDeclaration)) { + return; + } + if (isErrorType(variantType.type)) { + return; + } + checkTypeAssignable(variantType.type, baseType, variantNode.value); + } + + /** + * Resolve the type referenced by a union `extends` clause. + * + * The resulting type is only a constraint on the union variants: it doesn't create any + * inheritance relationship, it doesn't add anything to the union and it doesn't make the + * union extensible. + */ + function checkUnionBaseType( + ctx: CheckContext, + union: UnionStatementNode, + unionType: Union, + extendsRef: Expression, + ): Type | undefined { + const unionSymId = getNodeSym(union); + pendingResolutions.start(unionSymId, ResolutionKind.BaseType); + + try { + const target = resolver.getNodeLinks(extendsRef).resolvedSymbol; + if (target && pendingResolutions.has(target, ResolutionKind.BaseType)) { + if (ctx.mapper === undefined) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "circular-base-type", + format: { typeName: target.name }, + target: target, + }), + ); + } + return undefined; + } + + const baseType = getTypeForNode(extendsRef, ctx); + if (isErrorType(baseType)) { + // Should already have reported an error when resolving the expression. + return undefined; + } + + // `extends` accepts an arbitrary expression so, unlike `model`/`scalar`, the union can + // also reference itself through a union expression (e.g. `union a extends a | string` or + // `union a extends b` with `alias b = a | string`). Those don't go through a symbol that + // `pendingResolutions` can observe so they are detected on the resolved type instead. + if (unionExpressionReferences(baseType, unionType)) { + if (ctx.mapper === undefined) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "circular-base-type", + format: { typeName: union.id.sv }, + target: extendsRef, + }), + ); + } + return undefined; + } + return baseType; + } finally { + pendingResolutions.finish(unionSymId, ResolutionKind.BaseType); } } + /** + * Check whether `target` is reachable from `type` through union expressions only. + * + * Traversal deliberately stops at anything else (named unions, models, arrays, ...): a union + * referencing itself from those positions builds a perfectly valid cyclic type graph, exactly + * like `model Foo { foo: Foo }` does, and must not be reported. Only union expressions are + * followed, which is a finite syntactic structure, so this always terminates. + */ + function unionExpressionReferences(type: Type, target: Union): boolean { + if (type === target) { + return true; + } + if (type.kind === "Union" && type.expression) { + for (const variant of type.variants.values()) { + if (unionExpressionReferences(variant.type, target)) { + return true; + } + } + } + return false; + } + function checkUnionVariant(ctx: CheckContext, variantNode: UnionVariantNode): UnionVariant { const links = getSymbolLinksForMember(variantNode); if (links && links.declaredType && ctx.mapper === undefined) { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 749ec169d17..e75940ecba9 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -704,6 +704,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); + expectTokenIsOneOf(Token.OpenBrace, Token.ExtendsKeyword); + + const optionalExtends = parseOptionalUnionExtends(); const { items: options } = parseList(ListKind.UnionVariants, parseUnionVariant); return { @@ -711,6 +714,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa id, templateParameters, templateParametersRange, + extends: optionalExtends, decorators, modifiers, modifierFlags: modifiersToFlags(modifiers), @@ -719,6 +723,13 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parseOptionalUnionExtends() { + if (parseOptional(Token.ExtendsKeyword)) { + return parseExpression(); + } + return undefined; + } + function parseIdOrValueForVariant(): Expression { const nextToken = token(); @@ -3072,6 +3083,7 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitEach(cb, node.templateParameters) || + visitNode(cb, node.extends) || visitEach(cb, node.options) ); case SyntaxKind.UnionVariant: diff --git a/packages/compiler/src/core/semantic-walker.ts b/packages/compiler/src/core/semantic-walker.ts index d4e127a032f..e13bf3bd602 100644 --- a/packages/compiler/src/core/semantic-walker.ts +++ b/packages/compiler/src/core/semantic-walker.ts @@ -335,6 +335,9 @@ function navigateUnionType(type: Union, context: NavigationContext) { return; } if (context.emit("union", type) === ListenerFlow.NoRecursion) return; + if (type.baseType) { + navigateTypeInternal(type.baseType, context); + } for (const variant of type.variants.values()) { navigateUnionTypeVariant(variant, context); } diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 92a0d21f665..4a1d779dc89 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -675,6 +675,19 @@ export interface Union extends BaseType, DecoratedType, TemplatedTypeBase { expression: boolean; + /** + * Type declared with the `extends` clause of a union statement. Every variant of the + * union is guaranteed to be assignable to this type. + * + * This is only set for named unions declared with an `extends` clause. It documents a + * constraint: it does **not** imply a subclassing relationship, it does **not** mean the + * union is extensible, and it has no interaction with `@discriminator`. + * + * Emitters should not require this to be present: a union with the same variants and no + * `extends` clause should ideally be handled the same way. + */ + baseType?: Type; + /** * Late-bound symbol of this interface type. * @internal @@ -1600,6 +1613,13 @@ export interface InterfaceStatementNode extends BaseNode, DeclarationNode, Templ export interface UnionStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.UnionStatement; readonly options: readonly UnionVariantNode[]; + /** + * Type that every variant of this union must be assignable to. + * + * This is a constraint only, it does not imply any subtyping relationship between + * the union and the base type beyond the one that already exists structurally. + */ + readonly extends?: Expression; readonly decorators: readonly DecoratorExpressionNode[]; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } diff --git a/packages/compiler/src/experimental/mutators.ts b/packages/compiler/src/experimental/mutators.ts index 150621d5dbb..783381b6645 100644 --- a/packages/compiler/src/experimental/mutators.ts +++ b/packages/compiler/src/experimental/mutators.ts @@ -701,6 +701,7 @@ function createMutatorEngine( break; case "Union": mutateSubMap(root, "variants", mutating, newMutators); + mutateProperty(root, "baseType", mutating, newMutators); break; case "UnionVariant": mutateProperty(root, "type", mutating, newMutators); diff --git a/packages/compiler/src/formatter/print/comment-handler.ts b/packages/compiler/src/formatter/print/comment-handler.ts index b1138fdaa91..fa4678aa080 100644 --- a/packages/compiler/src/formatter/print/comment-handler.ts +++ b/packages/compiler/src/formatter/print/comment-handler.ts @@ -19,6 +19,7 @@ export const commentHandler: Printer["handleComments"] = { addEmptyInterfaceComment, addEmptyModelComment, addEmptyScalarComment, + addEmptyUnionComment, addCommentBetweenAnnotationsAndNode, handleOnlyComments, ].some((x) => x({ comment, text, options, ast: ast as TypeSpecScriptNode, isLastComment })), @@ -153,6 +154,31 @@ function addEmptyScalarComment({ comment }: CommentContext) { return false; } +/** + * When a comment is on an empty union make sure it gets added as a dangling comment on it and not on the identifier. + * + * @example + * + * union Foo extends Bar { + * // My comment + * } + */ +function addEmptyUnionComment({ comment }: CommentContext) { + const { precedingNode, enclosingNode } = comment; + + if ( + enclosingNode && + enclosingNode.kind === SyntaxKind.UnionStatement && + enclosingNode.options.length === 0 && + precedingNode && + (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends) + ) { + util.addDanglingComment(enclosingNode, comment, undefined); + return true; + } + return false; +} + function handleOnlyComments({ comment, ast, isLastComment }: CommentContext) { const { enclosingNode } = comment; if (ast?.statements?.length === 0) { diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 51491a656ab..da3e9580d1b 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -742,12 +742,14 @@ export function printUnionStatement( const id = path.call(print, "id"); const { decorators } = printDecorators(path, options, print, { tryInline: false }); const generic = printTemplateParameters(path, options, print, "templateParameters"); + const heritage = printHeritageClause(path, print, "extends", "extends"); return [ decorators, printModifiers(path, options, print), "union ", id, generic, + heritage, " ", printUnionVariantsBlock(path, options, print), ]; @@ -759,11 +761,15 @@ export function printUnionVariantsBlock( print: PrettierChildPrint, ) { const node = path.node; - if (node.options.length === 0) { + const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); + if (node.options.length === 0 && !nodeHasComments) { return "{}"; } - const body = joinMembersInBlock(path, "options", options, print, ",", hardline); + const body = [joinMembersInBlock(path, "options", options, print, ",", hardline)]; + if (nodeHasComments) { + body.push(printDanglingComments(path, options, { sameIndent: true })); + } return group(["{", indent(body), hardline, "}"]); } diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index 0c0294f3b57..2d596830069 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -91,6 +91,7 @@ function addCompletionByLookingBackward( n.kind === SyntaxKind.ScalarStatement || n.kind === SyntaxKind.OperationStatement || n.kind === SyntaxKind.InterfaceStatement || + n.kind === SyntaxKind.UnionStatement || n.kind === SyntaxKind.TemplateParameterDeclaration, true /*includeSelf*/, ); @@ -112,12 +113,14 @@ function addCompletionByLookingBackwardNode( [SyntaxKind.ScalarStatement]: "scalarHeader", [SyntaxKind.OperationStatement]: "operationHeader", [SyntaxKind.InterfaceStatement]: "interfaceHeader", + [SyntaxKind.UnionStatement]: "unionHeader", }; switch (preNode?.kind) { case SyntaxKind.ModelStatement: case SyntaxKind.ScalarStatement: case SyntaxKind.OperationStatement: case SyntaxKind.InterfaceStatement: + case SyntaxKind.UnionStatement: const idEndPos = preNode.templateParametersRange.end >= 0 ? preNode.templateParametersRange.end @@ -195,6 +198,7 @@ interface KeywordArea { templateParameter?: boolean; operationHeader?: boolean; interfaceHeader?: boolean; + unionHeader?: boolean; } const keywords = [ @@ -218,7 +222,13 @@ const keywords = [ // On model `model Foo ...` [ "extends", - { modelHeader: true, scalarHeader: true, templateParameter: true, interfaceHeader: true }, + { + modelHeader: true, + scalarHeader: true, + templateParameter: true, + interfaceHeader: true, + unionHeader: true, + }, ], ["is", { modelHeader: true, operationHeader: true }], diff --git a/packages/compiler/src/server/tmlanguage.ts b/packages/compiler/src/server/tmlanguage.ts index 92b268fef58..f766da9433b 100644 --- a/packages/compiler/src/server/tmlanguage.ts +++ b/packages/compiler/src/server/tmlanguage.ts @@ -695,6 +695,17 @@ const unionBody: BeginEndRule = { patterns: [namedUnionVariant, token, directive, decorator, expression, punctuationComma], }; +const unionExtends: BeginEndRule = { + key: "union-extends", + scope: meta, + begin: "\\b(extends)\\b", + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + }, + end: `((?=\\{)|${universalEndExceptComma})`, + patterns: [expression, punctuationComma], +}; + const unionStatement: BeginEndRule = { key: "union-statement", scope: meta, @@ -705,7 +716,12 @@ const unionStatement: BeginEndRule = { "3": { scope: "entity.name.type.tsp" }, }, end: `(?<=\\})|${universalEnd}`, - patterns: [token, unionBody], + patterns: [ + token, + typeParameters, + unionExtends, // before unionBody or `extends` will look like a type name + unionBody, + ], }; const aliasAssignment: BeginEndRule = { diff --git a/packages/compiler/test/checker/union.test.ts b/packages/compiler/test/checker/union.test.ts index db34d307aaf..d756682db4d 100644 --- a/packages/compiler/test/checker/union.test.ts +++ b/packages/compiler/test/checker/union.test.ts @@ -1,7 +1,13 @@ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; -import type { Model, Union, UnionVariant } from "../../src/core/types.js"; -import { expectTypeEquals, mockFile, t } from "../../src/testing/index.js"; +import type { Model, Scalar, Union, UnionVariant } from "../../src/core/types.js"; +import { + expectDiagnosticEmpty, + expectDiagnostics, + expectTypeEquals, + mockFile, + t, +} from "../../src/testing/index.js"; import { Tester } from "../tester.js"; describe("declarations", () => { @@ -72,6 +78,429 @@ describe("declarations", () => { }); }); +describe("extends", () => { + it("sets baseType on the union type", async () => { + const { Pet, PetBase } = await Tester.compile(t.code` + model ${t.model("PetBase")} { name: string } + model Cat extends PetBase { toy: string } + model Dog extends PetBase { food: string } + + union ${t.union("Pet")} extends PetBase { + cat: Cat, + dog: Dog, + } + `); + + expectTypeEquals(Pet.baseType, PetBase); + }); + + it("baseType is undefined when there is no extends clause", async () => { + const { Pet } = await Tester.compile(t.code` + model Cat { name: string } + union ${t.union("Pet")} { cat: Cat } + `); + + strictEqual(Pet.baseType, undefined); + }); + + it("does not add the base type as a variant", async () => { + const { Pet } = await Tester.compile(t.code` + model PetBase { name: string } + model Cat extends PetBase { toy: string } + + union ${t.union("Pet")} extends PetBase { cat: Cat } + `); + + strictEqual(Pet.variants.size, 1); + ok(Pet.variants.has("cat")); + }); + + it("accepts variants that structurally satisfy the base type without extending it", async () => { + // Per the design, `extends` is an assignability constraint, not a nominal one. + const diagnostics = await Tester.diagnose(` + model PetBase { name: string } + model Cat { name: string, toy: string } + model Dog extends PetBase { food: string } + + union Pet extends PetBase { + cat: Cat, + dog: Dog, + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("accepts the base type itself as a variant", async () => { + const diagnostics = await Tester.diagnose(` + model PetBase { name: string } + model Cat extends PetBase { toy: string } + + union Pet extends PetBase { + cat: Cat, + base: PetBase, + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("emits a diagnostic on the variant that doesn't satisfy the constraint", async () => { + const diagnostics = await Tester.diagnose(` + model PetBase { name: string } + model Cat extends PetBase { toy: string } + model Rock { hardness: int32 } + + union Pet extends PetBase { + cat: Cat, + rock: Rock, + } + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + message: /Type 'Rock' is not assignable to type 'PetBase'/, + }); + }); + + it("emits one diagnostic per offending variant", async () => { + const diagnostics = await Tester.diagnose(` + model PetBase { name: string } + model Rock { hardness: int32 } + model Tree { height: int32 } + + union Pet extends PetBase { + rock: Rock, + tree: Tree, + } + `); + expectDiagnostics(diagnostics, [ + { code: "unassignable", message: /Type 'Rock' is not assignable to type 'PetBase'/ }, + { code: "unassignable", message: /Type 'Tree' is not assignable to type 'PetBase'/ }, + ]); + }); + + it("works with unnamed variants", async () => { + const diagnostics = await Tester.diagnose(` + union Status extends string { + "start", + "stop", + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("emits a diagnostic for an unnamed variant that doesn't satisfy the constraint", async () => { + const diagnostics = await Tester.diagnose(` + union Status extends string { + "start", + 123, + } + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + message: "Type '123' is not assignable to type 'string'", + }); + }); + + it("supports composing unions declared with extends", async () => { + const diagnostics = await Tester.diagnose(` + union OperationStatus extends string { + "Running", + "Succeeded", + "Failure", + } + + union ServiceOperationStatus extends string { + OperationStatus, + "NotStarted", + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("supports a scalar base type", async () => { + const { Status, string: stringType } = await Tester.compile(t.code` + union ${t.union("Status")} extends ${t.scalar("string")} { + "start", + "stop", + } + `); + expectTypeEquals(Status.baseType, stringType); + }); + + it("supports a union expression as the base type", async () => { + const { Foo } = await Tester.compile(t.code` + union ${t.union("Foo")} extends string | int32 { + a: string, + b: int32, + } + `); + strictEqual(Foo.baseType?.kind, "Union"); + }); + + it("emits a diagnostic when a variant doesn't satisfy a union expression base type", async () => { + const diagnostics = await Tester.diagnose(` + union Foo extends string | int32 { + a: string, + b: boolean, + } + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + message: "Type 'boolean' is not assignable to type 'string | int32'", + }); + }); + + it("supports an intersection as the base type", async () => { + const diagnostics = await Tester.diagnose(` + model A { a: string } + model B { b: string } + model AB { a: string, b: string, c: string } + + union Foo extends A & B { + ab: AB, + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("supports a templated base type reference", async () => { + const diagnostics = await Tester.diagnose(` + model Wrapper { value: T } + + union Foo extends Wrapper { + a: Wrapper, + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + describe("templates", () => { + it("checks the constraint on instantiation", async () => { + const diagnostics = await Tester.diagnose(` + union Foo extends string { + value: T, + } + + alias Bad = Foo; + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + message: "Type 'int32' is not assignable to type 'string'", + }); + }); + + it("does not report on a valid instantiation", async () => { + const diagnostics = await Tester.diagnose(` + union Foo extends string { + value: T, + } + + alias Good = Foo<"abc">; + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("does not report on the uninstantiated template declaration", async () => { + const diagnostics = await Tester.diagnose(` + union Foo extends string { + value: T, + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("supports a template parameter as the base type", async () => { + const { Foo, string: stringType } = await Tester.compile(t.code` + union Template extends T { + value: string, + } + + alias ${t.union("Foo")} = Template<${t.scalar("string")}>; + `); + expectTypeEquals(Foo.baseType, stringType); + }); + + it("emits a diagnostic when a variant doesn't satisfy a template parameter base type", async () => { + const diagnostics = await Tester.diagnose(` + union Template extends T { + value: string, + } + + alias Bad = Template; + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + message: "Type 'string' is not assignable to type 'int32'", + }); + }); + }); + + describe("circular references", () => { + it("reports a diagnostic when a union extends itself", async () => { + const diagnostics = await Tester.diagnose(`union a extends a { x: string }`); + expectDiagnostics(diagnostics, { + code: "circular-base-type", + message: "Type 'a' recursively references itself as a base type.", + }); + }); + + it("reports a diagnostic when a union extends itself via another union", async () => { + const diagnostics = await Tester.diagnose(` + union a extends b { x: string } + union b extends a { x: string } + `); + expectDiagnostics(diagnostics, { + code: "circular-base-type", + message: "Type 'a' recursively references itself as a base type.", + }); + }); + + it("reports a diagnostic when a union extends itself via an alias", async () => { + const diagnostics = await Tester.diagnose(` + union a extends b { x: string } + alias b = a; + `); + expectDiagnostics(diagnostics, { + code: "circular-base-type", + message: "Type 'a' recursively references itself as a base type.", + }); + }); + + it("reports a diagnostic when a union references itself in a union expression", async () => { + const diagnostics = await Tester.diagnose(`union a extends a | string { x: string }`); + expectDiagnostics(diagnostics, { + code: "circular-base-type", + message: "Type 'a' recursively references itself as a base type.", + }); + }); + + it("reports a diagnostic when a union references itself in a nested union expression", async () => { + const diagnostics = await Tester.diagnose( + `union a extends string | (int32 | a) { x: string }`, + ); + expectDiagnostics(diagnostics, { + code: "circular-base-type", + message: "Type 'a' recursively references itself as a base type.", + }); + }); + + it("reports a diagnostic when a union references itself in a union expression via an alias", async () => { + const diagnostics = await Tester.diagnose(` + union a extends b { x: string } + alias b = a | string; + `); + expectDiagnostics(diagnostics, { + code: "circular-base-type", + message: "Type 'a' recursively references itself as a base type.", + }); + }); + + it("doesn't set a base type when a circular reference is reported", async () => { + const [{ a }, diagnostics] = await Tester.compileAndDiagnose( + t.code`union ${t.union("a")} extends a | string { x: string }`, + ); + expectDiagnostics(diagnostics, { code: "circular-base-type" }); + strictEqual(a.baseType, undefined); + }); + + it("allows a union to reference itself from a model reachable from the base type", async () => { + // Cyclic type graphs are legal in TypeSpec (e.g. `model Foo { foo: Foo }`) so this must + // not be reported as a circular base type. + const [{ a }, diagnostics] = await Tester.compileAndDiagnose(t.code` + model Box { inner: a } + union ${t.union("a")} extends Box { x: Box } + `); + expectDiagnosticEmpty(diagnostics); + strictEqual(a.baseType?.kind, "Model"); + }); + }); + + it("doesn't cascade errors when the base type cannot be resolved", async () => { + const diagnostics = await Tester.diagnose(` + union Foo extends NotDefined { + a: string, + } + `); + expectDiagnostics(diagnostics, { + code: "invalid-ref", + message: "Unknown identifier NotDefined", + }); + }); + + it("reports a diagnostic when the base type is a value", async () => { + const diagnostics = await Tester.diagnose(` + union Foo extends #{ a: 1 } { + a: string, + } + `); + expectDiagnostics(diagnostics, [{ code: "value-in-type" }]); + }); + + describe("deprecation", () => { + it("reports the deprecation of the base type", async () => { + const diagnostics = await Tester.diagnose(` + #deprecated "Use NewBase instead" + model Base {} + + union Foo extends Base { + a: {}, + } + `); + expectDiagnostics(diagnostics, [ + { code: "deprecated", message: "Deprecated: Use NewBase instead" }, + ]); + }); + + it("doesn't report the deprecation of the base type when the union is deprecated", async () => { + // Same mitigation as `model Foo extends Base`: a deprecated declaration is allowed to + // reference deprecated types without adding noise. + const diagnostics = await Tester.diagnose(` + #deprecated "Use NewBase instead" + model Base {} + + #deprecated "Use NewFoo instead" + union Foo extends Base { + a: {}, + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("doesn't copy the deprecation of the base type onto the union", async () => { + // `extends` on a union is a constraint, not inheritance, so the deprecation must not + // propagate to the union the way it does for `scalar`. + const diagnostics = await Tester.diagnose(` + #deprecated "Use NewBase instead" + model Base {} + + union Foo extends Base { + a: {}, + } + + model Usage { + foo: Foo, + } + `); + expectDiagnostics(diagnostics, [ + { code: "deprecated", message: "Deprecated: Use NewBase instead" }, + ]); + }); + }); + + it("keeps a per-instantiation base type", async () => { + const { Foo, Bar } = await Tester.compile(t.code` + union Template extends T { + value: T, + } + + alias ${t.union("Foo")} = Template; + alias ${t.union("Bar")} = Template; + `); + + strictEqual((Foo.baseType as Scalar).name, "string"); + strictEqual((Bar.baseType as Scalar).name, "int32"); + }); +}); + describe("expressions", () => { it("reduces union expressions and gives them symbol keys", async () => { const { Foo } = await Tester.compile(t.code` diff --git a/packages/compiler/test/experimental/mutator.test.ts b/packages/compiler/test/experimental/mutator.test.ts index 7302674365a..30849334f96 100644 --- a/packages/compiler/test/experimental/mutator.test.ts +++ b/packages/compiler/test/experimental/mutator.test.ts @@ -490,6 +490,18 @@ describe("global graph mutation", () => { const barProp: any = MutatedB.properties.get("bar"); expectTypeEquals(MutatedA, barProp.type.values[0]); }); + + it("mutate union base type", async () => { + const type = await globalMutate(` + model PetBase { name: string } + model Cat extends PetBase { toy: string } + union Pet extends PetBase { cat: Cat }; + `); + + const MutatedPetBase = type.models.get("PetBase")!; + const MutatedPet = type.unions.get("Pet")!; + expectTypeEquals(MutatedPet.baseType!, MutatedPetBase); + }); }); describe("decorators", () => { diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index dce7cb8fe2c..7d18a5f3eec 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1975,6 +1975,109 @@ union Foo { }); }); + describe("extends", () => { + it("formats a union extends clause", async () => { + await assertFormat({ + code: ` +union Foo extends Bar { a: A, b: B} +`, + expected: ` +union Foo extends Bar { + a: A, + b: B, +} +`, + }); + }); + + it("formats a templated union with an extends clause", async () => { + await assertFormat({ + code: ` +union Foo extends Bar { a: T} +`, + expected: ` +union Foo extends Bar { + a: T, +} +`, + }); + }); + + it("formats decorators and modifiers with an extends clause", async () => { + await assertFormat({ + code: ` +@doc("foo") internal union Foo extends string { "a", "b"} +`, + expected: ` +@doc("foo") +internal union Foo extends string { + "a", + "b", +} +`, + }); + }); + + it("breaks a long extends clause onto a new line", async () => { + await assertFormat({ + code: ` +union ThisIsAVeryLongUnionName extends ThisIsAVeryLongBaseTypeName { a: A } +`, + expected: ` +union ThisIsAVeryLongUnionName + extends ThisIsAVeryLongBaseTypeName { + a: A, +} +`, + }); + }); + + it("keeps a dangling comment inside an empty union body with an extends clause", async () => { + await assertFormat({ + code: ` +union Foo extends Bar { + // dangling +} +`, + expected: ` +union Foo extends Bar { + // dangling +} +`, + }); + }); + + it("keeps a comment between the base type and the body", async () => { + await assertFormat({ + code: ` +union Foo +// before extends +extends Bar {} +`, + expected: ` +union Foo extends Bar { + // before extends +} +`, + }); + }); + }); + + it("keeps a dangling comment inside an empty union body", async () => { + await assertFormat({ + code: ` +union Foo { + // dangling +} +`, + expected: ` +union Foo { + // dangling +} +`, + }); + }); + // Regression test for https://github.com/microsoft/typespec/issues/11009 it("does not add a blank line or extra indent for a union used as a template argument", async () => { await assertFormat({ diff --git a/packages/compiler/test/formatter/scenarios/inputs/union.tsp b/packages/compiler/test/formatter/scenarios/inputs/union.tsp index ca0d82c8a8c..59c1227a6d4 100644 --- a/packages/compiler/test/formatter/scenarios/inputs/union.tsp +++ b/packages/compiler/test/formatter/scenarios/inputs/union.tsp @@ -1,2 +1,9 @@ union Foo { x: int32 } + +union Constrained extends + Base { a: A, b: B } + +union Empty extends Base { + // dangling +} \ No newline at end of file diff --git a/packages/compiler/test/formatter/scenarios/outputs/union.tsp b/packages/compiler/test/formatter/scenarios/outputs/union.tsp index 9b5593c8231..487b9d5dfab 100644 --- a/packages/compiler/test/formatter/scenarios/outputs/union.tsp +++ b/packages/compiler/test/formatter/scenarios/outputs/union.tsp @@ -1,3 +1,12 @@ union Foo { x: int32, } + +union Constrained extends Base { + a: A, + b: B, +} + +union Empty extends Base { + // dangling +} diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 2d3157811a4..70762b3aba2 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -270,8 +270,23 @@ describe("union declarations", () => { `union A { string, int32 }`, `union A { B, C }`, `union A { "hi", \`bye\` }`, + "union A extends B { x: C }", + "union A extends B { x: C }", + "union A extends B | C { x: B }", + "union A extends B & C { x: B }", + "union A extends string { `hi` }", + "union A extends B[] { x: B[] }", + "union A extends { name: string } { x: B }", + "union A extends T { x: T }", + "union A extends string { x: T }", + "@myDec union A extends B { @myDec a: B }", + ]); + parseErrorEach([ + ['union A { @myDec "x" x: number, y: string }', [/';' expected/]], + ["union A extends { x: B }", [/'{' expected/]], + ["union A extends;", [/Expression expected/]], + ["union A is B { x: B }", [/'{', or 'extends' expected/]], ]); - parseErrorEach([['union A { @myDec "x" x: number, y: string }', [/';' expected/]]]); }); describe("const statements", () => { diff --git a/packages/compiler/test/semantic-walker.test.ts b/packages/compiler/test/semantic-walker.test.ts index 2e2e3cadd6d..7a78c438d4a 100644 --- a/packages/compiler/test/semantic-walker.test.ts +++ b/packages/compiler/test/semantic-walker.test.ts @@ -504,6 +504,24 @@ it("finds unions", async () => { strictEqual(result.unionVariants[0].name!, "x"); }); +it("navigates the base type of a union declared with extends", async () => { + const result = await runNavigator(` + model PetBase { name: string } + model Cat extends PetBase { toy: string } + + union Pet extends PetBase { + cat: Cat; + } + `); + + strictEqual(result.unions.length, 1); + strictEqual(result.unions[0].name!, "Pet"); + ok( + result.models.some((x) => x.name === "PetBase"), + "PetBase should be navigated as the base type of the union", + ); +}); + it("finds tuples", async () => { const result = await runNavigator(` model ContainsTuple { diff --git a/packages/compiler/test/server/colorization.test.ts b/packages/compiler/test/server/colorization.test.ts index 374d563fd7e..5a56a804a67 100644 --- a/packages/compiler/test/server/colorization.test.ts +++ b/packages/compiler/test/server/colorization.test.ts @@ -1091,6 +1091,41 @@ function testColorization(description: string, tokenize: Tokenize) { Token.punctuation.closeBrace, ]); }); + + it("union with extends", async () => { + const tokens = await tokenize("union Foo extends Bar { a: A }"); + deepStrictEqual(tokens, [ + Token.keywords.union, + Token.identifiers.type("Foo"), + Token.keywords.extends, + Token.identifiers.type("Bar"), + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.operators.typeAnnotation, + Token.identifiers.type("A"), + Token.punctuation.closeBrace, + ]); + }); + + it("templated union with extends", async () => { + const tokens = await tokenize("union Foo extends Bar { a: T }"); + deepStrictEqual(tokens, [ + Token.keywords.union, + Token.identifiers.type("Foo"), + Token.punctuation.typeParameters.begin, + Token.identifiers.type("T"), + Token.keywords.extends, + Token.identifiers.type("string"), + Token.punctuation.typeParameters.end, + Token.keywords.extends, + Token.identifiers.type("Bar"), + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.operators.typeAnnotation, + Token.identifiers.type("T"), + Token.punctuation.closeBrace, + ]); + }); }); describe("namespaces", () => { diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index 7ecfe5b1a18..99968bc08fb 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -140,6 +140,22 @@ describe("completes for keywords", () => { [`interface I {┆}`, []], [`interface I`, []], + [`union U ┆`, ["extends"]], + [`union U ┆ `, ["extends"]], + [`union U \n┆\n`, ["extends"]], + [`union U ┆{}`, ["extends"]], + [`union U ┆ {}`, ["extends"]], + [`union U ┆ \nscalar S2`, ["extends"]], + [`model M1{}; union U ┆ M1`, ["extends"]], + [`model M1{}; union U e┆x M1`, ["extends"]], + [`union U ┆\n`, ["extends"]], + [`union U┆ \n`, ["extends"]], + [`union U ┆ {}`, ["extends"]], + [`union U ex┆`, ["extends"]], + [`union U ex┆tends`, ["extends"]], + [`union U {┆}`, []], + [`union U {}`, []], + [`scalar S`, ["extends"]], [`scalar S`, ["extends"]], [`model M`, ["extends"]], @@ -1321,6 +1337,26 @@ describe("identifiers", () => { ]); }); + it("completes types in a union extends clause", async () => { + const completions = await complete( + ` + namespace N { + model A {} + union B extends ┆ + } + `, + ); + + check(completions, [ + { + label: "A", + insertText: "A", + kind: CompletionItemKind.Class, + documentation: { kind: MarkupKind.Markdown, value: "```typespec\nmodel N.A\n```" }, + }, + ]); + }); + it("completes using statements", async () => { const completions = await complete( ` diff --git a/packages/spec/src/spec.emu.html b/packages/spec/src/spec.emu.html index 5230dacc946..bc3a8e27a18 100644 --- a/packages/spec/src/spec.emu.html +++ b/packages/spec/src/spec.emu.html @@ -387,7 +387,10 @@

Syntactic Grammar

UnionStatement : - DirectiveList? DecoratorList? `union` Identifier TemplateParameters? `{` UnionBody? `}` + DirectiveList? DecoratorList? `union` Identifier TemplateParameters? UnionExtends? `{` UnionBody? `}` + +UnionExtends : + `extends` Expression UnionBody : UnionVariantList `;`? diff --git a/website/src/content/docs/docs/language-basics/unions.md b/website/src/content/docs/docs/language-basics/unions.md index 8a0bbfbbc84..13250a58a98 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -35,3 +35,74 @@ union Breed { ``` The above example is equivalent to the `Breed` alias mentioned earlier, with the difference that emitters can recognize `Breed` as a named entity and also identify the `beagle`, `shepherd`, and `retriever` names for the options. This format also allows the application of [decorators](./decorators.md) to each of the options. + +## Constraining a union with `extends` + +A named union can declare a base type with the `extends` keyword. Every variant of the union must be [assignable](./type-relations.md) to that base type, otherwise a diagnostic is reported on the offending variant. + +```typespec +model Dog { + name: string; +} +model Beagle extends Dog { + huntingSkill: string; +} +model GermanShepherd extends Dog { + guardingSkill: string; +} + +union Breed extends Dog { + beagle: Beagle, + shepherd: GermanShepherd, +} +``` + +This serves two purposes: + +- It prevents a common class of mistake where an unrelated type is accidentally added to a union. +- It records the common base type in the type graph, which makes it easy for emitters to represent the union with a polymorphic base type in languages that don't support unions natively. + +The base type does **not** become a variant of the union. `Breed` above still has exactly two variants. + +`extends` is a constraint, not a declaration of inheritance. A variant only needs to be assignable to the base type, it doesn't have to explicitly `extends` it: + +```typespec +model Dog { + name: string; +} +model Beagle { + name: string; + huntingSkill: string; +} + +// Ok: `Beagle` is assignable to `Dog` even though it doesn't explicitly extend it. +union Breed extends Dog { + beagle: Beagle, +} +``` + +Any type expression can be used as the base type, including scalars, unions and templates. + +```typespec +union OperationStatus extends string { + "Running", + "Succeeded", + "Failed", +} +``` + +:::caution +`extends` on a union does not mean the union is extensible. `union Foo extends string { "a", "b" }` and `union Foo { "a", "b" }` describe exactly the same set of values, and emitters should treat them the same way. To allow additional values, add a variant for them explicitly: + +```typespec +union OperationStatus extends string { + "Running", + "Succeeded", + "Failed", + string, +} +``` + +::: + +`extends` also has no interaction with the [`@discriminator`](../standard-library/built-in-decorators.md#@discriminator) decorator. From bc9e185f8e5072262cbefec6665acd03af501235 Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 28 Aug 2026 14:45:24 -0700 Subject: [PATCH 2/6] fix(compiler): address union extends feedback Gate union extends behind an experimental compiler feature, restrict base types to data declarations, reject model expressions, and consolidate empty-declaration comment handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../union-extends-base-type-2026-8-26.md | 4 +- packages/compiler/src/core/checker.ts | 44 ++- packages/compiler/src/core/features.ts | 4 + packages/compiler/src/core/messages.ts | 9 + packages/compiler/src/core/types.ts | 8 +- .../src/formatter/print/comment-handler.ts | 134 +++------ packages/compiler/test/checker/union.test.ts | 283 +++++++++++++----- .../test/core/cli/actions/info.test.ts | 1 + .../test/experimental/mutator.test.ts | 12 +- .../compiler/test/semantic-walker.test.ts | 11 +- .../test/server/completion.tspconfig.test.ts | 9 +- .../docs/docs/language-basics/unions.md | 12 +- 12 files changed, 356 insertions(+), 175 deletions(-) diff --git a/.chronus/changes/union-extends-base-type-2026-8-26.md b/.chronus/changes/union-extends-base-type-2026-8-26.md index 07441da5037..9da98a5d3b8 100644 --- a/.chronus/changes/union-extends-base-type-2026-8-26.md +++ b/.chronus/changes/union-extends-base-type-2026-8-26.md @@ -4,7 +4,9 @@ packages: - "@typespec/compiler" --- -Add support for an `extends` clause on union statements to constrain every variant to a common base type. +Add experimental support for an `extends` clause on union statements to constrain every variant to a common data type. + +Enable the `union-extends` compiler feature in `tspconfig.yaml` to use the clause without an experimental feature warning. ```tsp model PetBase { diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 2372ad81099..2f314c51491 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -7770,6 +7770,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (ctx.mapper === undefined) { checkModifiers(program, node); + if (node.extends && !isCompilerFeatureEnabled(program, "union-extends", node)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "experimental-feature", + messageId: "unionExtends", + target: node.extends, + }), + ); + } } checkTemplateDeclaration(ctx, node); @@ -7866,7 +7875,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker union: UnionStatementNode, unionType: Union, extendsRef: Expression, - ): Type | undefined { + ): NonNullable | undefined { const unionSymId = getNodeSym(union); pendingResolutions.start(unionSymId, ResolutionKind.BaseType); @@ -7907,12 +7916,45 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } + + if ( + ctx.hasFlags(CheckFlags.InTemplateDeclaration) && + (baseType.kind === "TemplateParameter" || baseType.kind === "TemplateParameterAccess") + ) { + return undefined; + } + + if (baseType.kind === "Model" && baseType.node?.kind === SyntaxKind.ModelExpression) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "extend-union", + messageId: "modelExpression", + target: extendsRef, + }), + ); + return undefined; + } + + if (!isUnionBaseType(baseType)) { + reportCheckerDiagnostic(createDiagnostic({ code: "extend-union", target: extendsRef })); + return undefined; + } + return baseType; } finally { pendingResolutions.finish(unionSymId, ResolutionKind.BaseType); } } + function isUnionBaseType(type: Type): type is NonNullable { + return ( + type.kind === "Model" || + type.kind === "Scalar" || + type.kind === "Enum" || + type.kind === "Union" + ); + } + /** * Check whether `target` is reachable from `type` through union expressions only. * diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index eaeeda6cb9b..666d0e2bc22 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -15,6 +15,10 @@ export const compilerFeatures = { description: "Allows use of auto decorator declarations without experimental warnings in project code.", }, + "union-extends": { + description: + "Allows use of union `extends` clauses without experimental warnings in project code.", + }, } as const satisfies Record; export type CompilerFeatureName = keyof typeof compilerFeatures; diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index c91a2528634..5eec4c33726 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -267,6 +267,8 @@ const diagnostics = { default: paramMessage`${"feature"} is an experimental feature. It may change in the future or be removed. Use with caution and consider providing feedback on this feature.`, functionDeclarations: "Function declarations are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", + unionExtends: + "Union `extends` clauses are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", }, }, "auto-decorator-disabled": { @@ -382,6 +384,13 @@ const diagnostics = { modelExpression: "Models cannot extend model expressions.", }, }, + "extend-union": { + severity: "error", + messages: { + default: "Union `extends` must specify a model, scalar, enum, or union.", + modelExpression: "Unions cannot extend model expressions.", + }, + }, "is-model": { severity: "error", messages: { diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 4a1d779dc89..a6a882acf51 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -677,7 +677,7 @@ export interface Union extends BaseType, DecoratedType, TemplatedTypeBase { /** * Type declared with the `extends` clause of a union statement. Every variant of the - * union is guaranteed to be assignable to this type. + * union is guaranteed to be assignable to this model, scalar, enum, or union. * * This is only set for named unions declared with an `extends` clause. It documents a * constraint: it does **not** imply a subclassing relationship, it does **not** mean the @@ -685,8 +685,10 @@ export interface Union extends BaseType, DecoratedType, TemplatedTypeBase { * * Emitters should not require this to be present: a union with the same variants and no * `extends` clause should ideally be handled the same way. + * + * @experimental */ - baseType?: Type; + baseType?: Model | Scalar | Enum | Union; /** * Late-bound symbol of this interface type. @@ -1618,6 +1620,8 @@ export interface UnionStatementNode extends BaseNode, DeclarationNode, TemplateD * * This is a constraint only, it does not imply any subtyping relationship between * the union and the base type beyond the one that already exists structurally. + * + * @experimental */ readonly extends?: Expression; readonly decorators: readonly DecoratorExpressionNode[]; diff --git a/packages/compiler/src/formatter/print/comment-handler.ts b/packages/compiler/src/formatter/print/comment-handler.ts index fa4678aa080..355e2a26b7a 100644 --- a/packages/compiler/src/formatter/print/comment-handler.ts +++ b/packages/compiler/src/formatter/print/comment-handler.ts @@ -15,14 +15,9 @@ interface CommentNode extends TextRange { */ export const commentHandler: Printer["handleComments"] = { ownLine: (comment, text, options, ast, isLastComment) => - [ - addEmptyInterfaceComment, - addEmptyModelComment, - addEmptyScalarComment, - addEmptyUnionComment, - addCommentBetweenAnnotationsAndNode, - handleOnlyComments, - ].some((x) => x({ comment, text, options, ast: ast as TypeSpecScriptNode, isLastComment })), + [addEmptyDeclarationComment, addCommentBetweenAnnotationsAndNode, handleOnlyComments].some( + (x) => x({ comment, text, options, ast: ast as TypeSpecScriptNode, isLastComment }), + ), remaining: (comment, text, options, ast, isLastComment) => [handleOnlyComments].some((x) => x({ comment, text, options, ast: ast as TypeSpecScriptNode, isLastComment }), @@ -41,24 +36,48 @@ interface CommentContext { isLastComment: boolean; } /** - * When a comment is on an empty interface make sure it gets added as a dangling comment on it and not on the identifier. + * When a comment is inside an empty declaration body, attach it to the declaration instead of + * the last node in the declaration header. * * @example * - * interface Foo { + * union Foo extends Bar { * // My comment * } */ -function addEmptyInterfaceComment({ comment, ast }: CommentContext) { +function addEmptyDeclarationComment({ comment }: CommentContext) { const { precedingNode, enclosingNode } = comment; - if ( - enclosingNode && - enclosingNode.kind === SyntaxKind.InterfaceStatement && - enclosingNode.operations.length === 0 && - precedingNode && - precedingNode.kind === SyntaxKind.Identifier - ) { + if (!enclosingNode || !precedingNode) { + return false; + } + + let isEmptyDeclarationBody = false; + switch (enclosingNode.kind) { + case SyntaxKind.InterfaceStatement: + isEmptyDeclarationBody = + enclosingNode.operations.length === 0 && precedingNode.kind === SyntaxKind.Identifier; + break; + case SyntaxKind.ModelStatement: + isEmptyDeclarationBody = + enclosingNode.properties.length === 0 && + (precedingNode === enclosingNode.is || + precedingNode === enclosingNode.id || + precedingNode === enclosingNode.extends); + break; + case SyntaxKind.ScalarStatement: + isEmptyDeclarationBody = + enclosingNode.members.length === 0 && + (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends); + break; + case SyntaxKind.UnionStatement: + isEmptyDeclarationBody = + enclosingNode.options.length === 0 && + (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends); + break; + } + + if (isEmptyDeclarationBody) { util.addDanglingComment(enclosingNode, comment, undefined); return true; } @@ -77,7 +96,7 @@ function addEmptyInterfaceComment({ comment, ast }: CommentContext) { * } */ function addCommentBetweenAnnotationsAndNode({ comment }: CommentContext) { - const { enclosingNode, precedingNode } = comment; + const { precedingNode, enclosingNode } = comment; if ( precedingNode && @@ -102,83 +121,6 @@ function addCommentBetweenAnnotationsAndNode({ comment }: CommentContext) { return false; } -/** - * When a comment is on an empty model make sure it gets added as a dangling comment on it and not on the identifier. - * - * @example - * - * model Foo { - * // My comment - * } - */ -function addEmptyModelComment({ comment }: CommentContext) { - const { precedingNode, enclosingNode } = comment; - - if ( - enclosingNode && - enclosingNode.kind === SyntaxKind.ModelStatement && - enclosingNode.properties.length === 0 && - precedingNode && - (precedingNode === enclosingNode.is || - precedingNode === enclosingNode.id || - precedingNode === enclosingNode.extends) - ) { - util.addDanglingComment(enclosingNode, comment, undefined); - return true; - } - return false; -} - -/** - * When a comment is on an empty scalar make sure it gets added as a dangling comment on it and not on the identifier. - * - * @example - * - * scalar foo { - * // My comment - * } - */ -function addEmptyScalarComment({ comment }: CommentContext) { - const { precedingNode, enclosingNode } = comment; - - if ( - enclosingNode && - enclosingNode.kind === SyntaxKind.ScalarStatement && - enclosingNode.members.length === 0 && - precedingNode && - (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends) - ) { - util.addDanglingComment(enclosingNode, comment, undefined); - return true; - } - return false; -} - -/** - * When a comment is on an empty union make sure it gets added as a dangling comment on it and not on the identifier. - * - * @example - * - * union Foo extends Bar { - * // My comment - * } - */ -function addEmptyUnionComment({ comment }: CommentContext) { - const { precedingNode, enclosingNode } = comment; - - if ( - enclosingNode && - enclosingNode.kind === SyntaxKind.UnionStatement && - enclosingNode.options.length === 0 && - precedingNode && - (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends) - ) { - util.addDanglingComment(enclosingNode, comment, undefined); - return true; - } - return false; -} - function handleOnlyComments({ comment, ast, isLastComment }: CommentContext) { const { enclosingNode } = comment; if (ast?.statements?.length === 0) { diff --git a/packages/compiler/test/checker/union.test.ts b/packages/compiler/test/checker/union.test.ts index d756682db4d..c466e542908 100644 --- a/packages/compiler/test/checker/union.test.ts +++ b/packages/compiler/test/checker/union.test.ts @@ -2,6 +2,7 @@ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import type { Model, Scalar, Union, UnionVariant } from "../../src/core/types.js"; import { + type TestCompileOptions, expectDiagnosticEmpty, expectDiagnostics, expectTypeEquals, @@ -10,6 +11,22 @@ import { } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; +const unionExtendsOptions: TestCompileOptions = { + compilerOptions: { + configFile: { + projectRoot: ".", + kind: "project", + features: ["union-extends"], + diagnostics: [], + outputDir: "tsp-output", + }, + }, +}; + +function diagnoseUnionExtends(code: string) { + return Tester.diagnose(code, unionExtendsOptions); +} + describe("declarations", () => { it("can be declared and decorated", async () => { const blues = new WeakSet(); @@ -79,18 +96,129 @@ describe("declarations", () => { }); describe("extends", () => { - it("sets baseType on the union type", async () => { - const { Pet, PetBase } = await Tester.compile(t.code` - model ${t.model("PetBase")} { name: string } - model Cat extends PetBase { toy: string } - model Dog extends PetBase { food: string } + it("reports an experimental feature warning when the feature is not enabled", async () => { + const diagnostics = await Tester.diagnose(` + model PetBase { name: string } + union Pet extends PetBase { base: PetBase } + `); - union ${t.union("Pet")} extends PetBase { - cat: Cat, - dog: Dog, + expectDiagnostics(diagnostics, { + code: "experimental-feature", + message: + "Union `extends` clauses are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", + }); + }); + + it("does not report an experimental feature warning when the feature is enabled", async () => { + const diagnostics = await diagnoseUnionExtends(` + model PetBase { name: string } + union Pet extends PetBase { base: PetBase } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("supports an enum base type", async () => { + const diagnostics = await diagnoseUnionExtends(` + enum PetKind { + cat, + dog, + } + + union Pet extends PetKind { + cat: PetKind.cat, + dog: PetKind.dog, } `); + expectDiagnosticEmpty(diagnostics); + }); + + it.each([ + { + name: "interface", + declaration: "interface Base {}", + baseType: "Base", + }, + { + name: "operation", + declaration: "op base(): void;", + baseType: "base", + }, + { + name: "function type", + declaration: "", + baseType: "fn () => string", + }, + { + name: "tuple", + declaration: "", + baseType: "[string]", + }, + { + name: "literal", + declaration: "", + baseType: '"base"', + }, + { + name: "intrinsic type", + declaration: "", + baseType: "unknown", + }, + ])("rejects a $name as the base type", async ({ declaration, baseType }) => { + const diagnostics = await diagnoseUnionExtends(` + ${declaration} + union Pet extends ${baseType} { value: string } + `); + + expectDiagnostics(diagnostics, { + code: "extend-union", + message: "Union `extends` must specify a model, scalar, enum, or union.", + }); + }); + + it("rejects a model expression as the base type", async () => { + const diagnostics = await diagnoseUnionExtends(` + union Pet extends { name: string } { + cat: { name: "cat" }, + } + `); + + expectDiagnostics(diagnostics, { + code: "extend-union", + message: "Unions cannot extend model expressions.", + }); + }); + + it("rejects an aliased model expression as the base type", async () => { + const diagnostics = await diagnoseUnionExtends(` + alias PetBase = { name: string }; + union Pet extends PetBase { + cat: { name: "cat" }, + } + `); + + expectDiagnostics(diagnostics, { + code: "extend-union", + message: "Unions cannot extend model expressions.", + }); + }); + + it("sets baseType on the union type", async () => { + const { Pet, PetBase } = await Tester.compile( + t.code` + model ${t.model("PetBase")} { name: string } + model Cat extends PetBase { toy: string } + model Dog extends PetBase { food: string } + + union ${t.union("Pet")} extends PetBase { + cat: Cat, + dog: Dog, + } + `, + unionExtendsOptions, + ); + expectTypeEquals(Pet.baseType, PetBase); }); @@ -104,12 +232,15 @@ describe("extends", () => { }); it("does not add the base type as a variant", async () => { - const { Pet } = await Tester.compile(t.code` - model PetBase { name: string } - model Cat extends PetBase { toy: string } + const { Pet } = await Tester.compile( + t.code` + model PetBase { name: string } + model Cat extends PetBase { toy: string } - union ${t.union("Pet")} extends PetBase { cat: Cat } - `); + union ${t.union("Pet")} extends PetBase { cat: Cat } + `, + unionExtendsOptions, + ); strictEqual(Pet.variants.size, 1); ok(Pet.variants.has("cat")); @@ -117,7 +248,7 @@ describe("extends", () => { it("accepts variants that structurally satisfy the base type without extending it", async () => { // Per the design, `extends` is an assignability constraint, not a nominal one. - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` model PetBase { name: string } model Cat { name: string, toy: string } model Dog extends PetBase { food: string } @@ -131,7 +262,7 @@ describe("extends", () => { }); it("accepts the base type itself as a variant", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` model PetBase { name: string } model Cat extends PetBase { toy: string } @@ -144,7 +275,7 @@ describe("extends", () => { }); it("emits a diagnostic on the variant that doesn't satisfy the constraint", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` model PetBase { name: string } model Cat extends PetBase { toy: string } model Rock { hardness: int32 } @@ -161,7 +292,7 @@ describe("extends", () => { }); it("emits one diagnostic per offending variant", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` model PetBase { name: string } model Rock { hardness: int32 } model Tree { height: int32 } @@ -178,7 +309,7 @@ describe("extends", () => { }); it("works with unnamed variants", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Status extends string { "start", "stop", @@ -188,7 +319,7 @@ describe("extends", () => { }); it("emits a diagnostic for an unnamed variant that doesn't satisfy the constraint", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Status extends string { "start", 123, @@ -201,7 +332,7 @@ describe("extends", () => { }); it("supports composing unions declared with extends", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union OperationStatus extends string { "Running", "Succeeded", @@ -217,27 +348,33 @@ describe("extends", () => { }); it("supports a scalar base type", async () => { - const { Status, string: stringType } = await Tester.compile(t.code` - union ${t.union("Status")} extends ${t.scalar("string")} { - "start", - "stop", - } - `); + const { Status, string: stringType } = await Tester.compile( + t.code` + union ${t.union("Status")} extends ${t.scalar("string")} { + "start", + "stop", + } + `, + unionExtendsOptions, + ); expectTypeEquals(Status.baseType, stringType); }); it("supports a union expression as the base type", async () => { - const { Foo } = await Tester.compile(t.code` - union ${t.union("Foo")} extends string | int32 { - a: string, - b: int32, - } - `); + const { Foo } = await Tester.compile( + t.code` + union ${t.union("Foo")} extends string | int32 { + a: string, + b: int32, + } + `, + unionExtendsOptions, + ); strictEqual(Foo.baseType?.kind, "Union"); }); it("emits a diagnostic when a variant doesn't satisfy a union expression base type", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Foo extends string | int32 { a: string, b: boolean, @@ -250,7 +387,7 @@ describe("extends", () => { }); it("supports an intersection as the base type", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` model A { a: string } model B { b: string } model AB { a: string, b: string, c: string } @@ -263,7 +400,7 @@ describe("extends", () => { }); it("supports a templated base type reference", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` model Wrapper { value: T } union Foo extends Wrapper { @@ -275,7 +412,7 @@ describe("extends", () => { describe("templates", () => { it("checks the constraint on instantiation", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Foo extends string { value: T, } @@ -289,7 +426,7 @@ describe("extends", () => { }); it("does not report on a valid instantiation", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Foo extends string { value: T, } @@ -300,7 +437,7 @@ describe("extends", () => { }); it("does not report on the uninstantiated template declaration", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Foo extends string { value: T, } @@ -309,18 +446,21 @@ describe("extends", () => { }); it("supports a template parameter as the base type", async () => { - const { Foo, string: stringType } = await Tester.compile(t.code` - union Template extends T { - value: string, - } - - alias ${t.union("Foo")} = Template<${t.scalar("string")}>; - `); + const { Foo, string: stringType } = await Tester.compile( + t.code` + union Template extends T { + value: string, + } + + alias ${t.union("Foo")} = Template<${t.scalar("string")}>; + `, + unionExtendsOptions, + ); expectTypeEquals(Foo.baseType, stringType); }); it("emits a diagnostic when a variant doesn't satisfy a template parameter base type", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Template extends T { value: string, } @@ -336,7 +476,7 @@ describe("extends", () => { describe("circular references", () => { it("reports a diagnostic when a union extends itself", async () => { - const diagnostics = await Tester.diagnose(`union a extends a { x: string }`); + const diagnostics = await diagnoseUnionExtends(`union a extends a { x: string }`); expectDiagnostics(diagnostics, { code: "circular-base-type", message: "Type 'a' recursively references itself as a base type.", @@ -344,7 +484,7 @@ describe("extends", () => { }); it("reports a diagnostic when a union extends itself via another union", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union a extends b { x: string } union b extends a { x: string } `); @@ -355,7 +495,7 @@ describe("extends", () => { }); it("reports a diagnostic when a union extends itself via an alias", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union a extends b { x: string } alias b = a; `); @@ -366,7 +506,7 @@ describe("extends", () => { }); it("reports a diagnostic when a union references itself in a union expression", async () => { - const diagnostics = await Tester.diagnose(`union a extends a | string { x: string }`); + const diagnostics = await diagnoseUnionExtends(`union a extends a | string { x: string }`); expectDiagnostics(diagnostics, { code: "circular-base-type", message: "Type 'a' recursively references itself as a base type.", @@ -374,7 +514,7 @@ describe("extends", () => { }); it("reports a diagnostic when a union references itself in a nested union expression", async () => { - const diagnostics = await Tester.diagnose( + const diagnostics = await diagnoseUnionExtends( `union a extends string | (int32 | a) { x: string }`, ); expectDiagnostics(diagnostics, { @@ -384,7 +524,7 @@ describe("extends", () => { }); it("reports a diagnostic when a union references itself in a union expression via an alias", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union a extends b { x: string } alias b = a | string; `); @@ -397,6 +537,7 @@ describe("extends", () => { it("doesn't set a base type when a circular reference is reported", async () => { const [{ a }, diagnostics] = await Tester.compileAndDiagnose( t.code`union ${t.union("a")} extends a | string { x: string }`, + unionExtendsOptions, ); expectDiagnostics(diagnostics, { code: "circular-base-type" }); strictEqual(a.baseType, undefined); @@ -405,17 +546,20 @@ describe("extends", () => { it("allows a union to reference itself from a model reachable from the base type", async () => { // Cyclic type graphs are legal in TypeSpec (e.g. `model Foo { foo: Foo }`) so this must // not be reported as a circular base type. - const [{ a }, diagnostics] = await Tester.compileAndDiagnose(t.code` - model Box { inner: a } - union ${t.union("a")} extends Box { x: Box } - `); + const [{ a }, diagnostics] = await Tester.compileAndDiagnose( + t.code` + model Box { inner: a } + union ${t.union("a")} extends Box { x: Box } + `, + unionExtendsOptions, + ); expectDiagnosticEmpty(diagnostics); strictEqual(a.baseType?.kind, "Model"); }); }); it("doesn't cascade errors when the base type cannot be resolved", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Foo extends NotDefined { a: string, } @@ -427,7 +571,7 @@ describe("extends", () => { }); it("reports a diagnostic when the base type is a value", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` union Foo extends #{ a: 1 } { a: string, } @@ -437,7 +581,7 @@ describe("extends", () => { describe("deprecation", () => { it("reports the deprecation of the base type", async () => { - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` #deprecated "Use NewBase instead" model Base {} @@ -453,7 +597,7 @@ describe("extends", () => { it("doesn't report the deprecation of the base type when the union is deprecated", async () => { // Same mitigation as `model Foo extends Base`: a deprecated declaration is allowed to // reference deprecated types without adding noise. - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` #deprecated "Use NewBase instead" model Base {} @@ -468,7 +612,7 @@ describe("extends", () => { it("doesn't copy the deprecation of the base type onto the union", async () => { // `extends` on a union is a constraint, not inheritance, so the deprecation must not // propagate to the union the way it does for `scalar`. - const diagnostics = await Tester.diagnose(` + const diagnostics = await diagnoseUnionExtends(` #deprecated "Use NewBase instead" model Base {} @@ -487,14 +631,17 @@ describe("extends", () => { }); it("keeps a per-instantiation base type", async () => { - const { Foo, Bar } = await Tester.compile(t.code` - union Template extends T { - value: T, - } + const { Foo, Bar } = await Tester.compile( + t.code` + union Template extends T { + value: T, + } - alias ${t.union("Foo")} = Template; - alias ${t.union("Bar")} = Template; - `); + alias ${t.union("Foo")} = Template; + alias ${t.union("Bar")} = Template; + `, + unionExtendsOptions, + ); strictEqual((Foo.baseType as Scalar).name, "string"); strictEqual((Bar.baseType as Scalar).name, "int32"); diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index 86fb69c71dd..a0d00ae2cdf 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -22,5 +22,6 @@ it("lists available compiler features and marks enabled features", () => { "", " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", + " disabled union-extends Allows use of union `extends` clauses without experimental warnings in project code.", ]); }); diff --git a/packages/compiler/test/experimental/mutator.test.ts b/packages/compiler/test/experimental/mutator.test.ts index 30849334f96..a946dd47e72 100644 --- a/packages/compiler/test/experimental/mutator.test.ts +++ b/packages/compiler/test/experimental/mutator.test.ts @@ -405,7 +405,17 @@ describe("global graph mutation", () => { }; async function globalMutate(code: string): Promise { - const { program } = await Tester.compile(code); + const { program } = await Tester.compile(code, { + compilerOptions: { + configFile: { + projectRoot: ".", + kind: "project", + features: ["union-extends"], + diagnostics: [], + outputDir: "tsp-output", + }, + }, + }); const { type } = mutateSubgraphWithNamespace( program, diff --git a/packages/compiler/test/semantic-walker.test.ts b/packages/compiler/test/semantic-walker.test.ts index 7a78c438d4a..38bfa1fd939 100644 --- a/packages/compiler/test/semantic-walker.test.ts +++ b/packages/compiler/test/semantic-walker.test.ts @@ -148,7 +148,16 @@ async function runNavigator( options?: NavigationOptions, ) { const [{ program }] = await NavigatorTester.compileAndDiagnose(typespec, { - compilerOptions: { nostdlib: true }, + compilerOptions: { + nostdlib: true, + configFile: { + projectRoot: ".", + kind: "project", + features: ["union-extends"], + diagnostics: [], + outputDir: "tsp-output", + }, + }, }); const [result, listener] = createCollector(customListener); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 0f8d70e5d34..77885951410 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -134,19 +134,19 @@ describe("Test completion items for features", () => { it.each([ { config: `features:\n - ┆`, - expected: ['"auto-decorators"', '"function-declarations"'], + expected: ['"auto-decorators"', '"function-declarations"', '"union-extends"'], }, { config: `features:\n - "┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "function-declarations", "union-extends"], }, { config: `features:\n - "function┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "function-declarations", "union-extends"], }, { config: `features:\n - function-declarations\n - ┆`, - expected: ['"auto-decorators"'], + expected: ['"auto-decorators"', '"union-extends"'], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -159,6 +159,7 @@ describe("Test completion items for features", () => { [ "Allows use of auto decorator declarations without experimental warnings in project code.", "Allows use of function declarations without experimental warnings in project code.", + "Allows use of union `extends` clauses without experimental warnings in project code.", ], true, ); diff --git a/website/src/content/docs/docs/language-basics/unions.md b/website/src/content/docs/docs/language-basics/unions.md index 13250a58a98..ab6ecccb062 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -40,6 +40,16 @@ The above example is equivalent to the `Breed` alias mentioned earlier, with the A named union can declare a base type with the `extends` keyword. Every variant of the union must be [assignable](./type-relations.md) to that base type, otherwise a diagnostic is reported on the offending variant. +:::caution +Union `extends` clauses are experimental. Enable the `union-extends` compiler feature in `tspconfig.yaml` to use them without an experimental feature warning: + +```yaml +features: + - union-extends +``` + +::: + ```typespec model Dog { name: string; @@ -81,7 +91,7 @@ union Breed extends Dog { } ``` -Any type expression can be used as the base type, including scalars, unions and templates. +The base expression must resolve to a model, scalar, enum, or union. This includes union, intersection, array, and template expressions that resolve to one of those data types. Anonymous model expressions cannot be used directly or through an alias. ```typespec union OperationStatus extends string { From 97c496054d69c2f2dcc868bab0bbea631c460a54 Mon Sep 17 00:00:00 2001 From: jolov Date: Sat, 29 Aug 2026 20:57:15 -0700 Subject: [PATCH 3/6] docs: fix union extends wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- website/src/content/docs/docs/language-basics/unions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/content/docs/docs/language-basics/unions.md b/website/src/content/docs/docs/language-basics/unions.md index ab6ecccb062..5b6ffe8a568 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -74,7 +74,7 @@ This serves two purposes: The base type does **not** become a variant of the union. `Breed` above still has exactly two variants. -`extends` is a constraint, not a declaration of inheritance. A variant only needs to be assignable to the base type, it doesn't have to explicitly `extends` it: +`extends` is a constraint, not a declaration of inheritance. A variant only needs to be assignable to the base type, it doesn't have to explicitly extend it: ```typespec model Dog { From 04fbd62d8b8a07475a0ace568dcca2d87f47984f Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 2 Sep 2026 17:45:26 -0700 Subject: [PATCH 4/6] fix(compiler): require union extends feature flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .chronus/changes/union-extends-base-type-2026-8-26.md | 2 +- packages/compiler/src/core/checker.ts | 3 +-- packages/compiler/src/core/features.ts | 3 +-- packages/compiler/src/core/messages.ts | 9 +++++++-- packages/compiler/test/checker/union.test.ts | 9 +++++---- packages/compiler/test/core/cli/actions/info.test.ts | 2 +- .../compiler/test/server/completion.tspconfig.test.ts | 2 +- website/src/content/docs/docs/language-basics/unions.md | 2 +- 8 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.chronus/changes/union-extends-base-type-2026-8-26.md b/.chronus/changes/union-extends-base-type-2026-8-26.md index 9da98a5d3b8..531c2322f10 100644 --- a/.chronus/changes/union-extends-base-type-2026-8-26.md +++ b/.chronus/changes/union-extends-base-type-2026-8-26.md @@ -6,7 +6,7 @@ packages: Add experimental support for an `extends` clause on union statements to constrain every variant to a common data type. -Enable the `union-extends` compiler feature in `tspconfig.yaml` to use the clause without an experimental feature warning. +Enable the `union-extends` compiler feature in `tspconfig.yaml` to use the clause. ```tsp model PetBase { diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 2f314c51491..11f8e422b12 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -7773,8 +7773,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (node.extends && !isCompilerFeatureEnabled(program, "union-extends", node)) { reportCheckerDiagnostic( createDiagnostic({ - code: "experimental-feature", - messageId: "unionExtends", + code: "union-extends-disabled", target: node.extends, }), ); diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index 8fc47e23b6a..30fcfce1b91 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -20,8 +20,7 @@ export const compilerFeatures = { "Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", }, "union-extends": { - description: - "Allows use of union `extends` clauses without experimental warnings in project code.", + description: "Enables experimental union `extends` clauses in project code.", }, } as const satisfies Record; diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 5eec4c33726..a60c765a8ae 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -267,8 +267,13 @@ const diagnostics = { default: paramMessage`${"feature"} is an experimental feature. It may change in the future or be removed. Use with caution and consider providing feedback on this feature.`, functionDeclarations: "Function declarations are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", - unionExtends: - "Union `extends` clauses are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", + }, + }, + "union-extends-disabled": { + severity: "error", + messages: { + default: + "Union `extends` clauses require the 'union-extends' feature to be enabled. Add 'union-extends' to the 'features' list in your tspconfig.yaml.", }, }, "auto-decorator-disabled": { diff --git a/packages/compiler/test/checker/union.test.ts b/packages/compiler/test/checker/union.test.ts index c466e542908..334bac1f3f4 100644 --- a/packages/compiler/test/checker/union.test.ts +++ b/packages/compiler/test/checker/union.test.ts @@ -96,20 +96,21 @@ describe("declarations", () => { }); describe("extends", () => { - it("reports an experimental feature warning when the feature is not enabled", async () => { + it("reports an error when the feature is not enabled", async () => { const diagnostics = await Tester.diagnose(` model PetBase { name: string } union Pet extends PetBase { base: PetBase } `); expectDiagnostics(diagnostics, { - code: "experimental-feature", + code: "union-extends-disabled", + severity: "error", message: - "Union `extends` clauses are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", + "Union `extends` clauses require the 'union-extends' feature to be enabled. Add 'union-extends' to the 'features' list in your tspconfig.yaml.", }); }); - it("does not report an experimental feature warning when the feature is enabled", async () => { + it("does not report an error when the feature is enabled", async () => { const diagnostics = await diagnoseUnionExtends(` model PetBase { name: string } union Pet extends PetBase { base: PetBase } diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index 28deee7e83d..b5ee77abf07 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -23,6 +23,6 @@ it("lists available compiler features and marks enabled features", () => { " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", " disabled type-info-provider Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", - " disabled union-extends Allows use of union `extends` clauses without experimental warnings in project code.", + " disabled union-extends Enables experimental union `extends` clauses in project code.", ]); }); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 2d817306050..7875e1ada79 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -165,7 +165,7 @@ describe("Test completion items for features", () => { "Allows use of auto decorator declarations without experimental warnings in project code.", "Allows use of function declarations without experimental warnings in project code.", "Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", - "Allows use of union `extends` clauses without experimental warnings in project code.", + "Enables experimental union `extends` clauses in project code.", ], true, ); diff --git a/website/src/content/docs/docs/language-basics/unions.md b/website/src/content/docs/docs/language-basics/unions.md index 5b6ffe8a568..5f2ae5c1880 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -41,7 +41,7 @@ The above example is equivalent to the `Breed` alias mentioned earlier, with the A named union can declare a base type with the `extends` keyword. Every variant of the union must be [assignable](./type-relations.md) to that base type, otherwise a diagnostic is reported on the offending variant. :::caution -Union `extends` clauses are experimental. Enable the `union-extends` compiler feature in `tspconfig.yaml` to use them without an experimental feature warning: +Union `extends` clauses are experimental and require the `union-extends` compiler feature. Enable it in `tspconfig.yaml`: ```yaml features: From 150406b92d82b26e259660f2ba8e4843e9a7319c Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 2 Sep 2026 19:14:01 -0700 Subject: [PATCH 5/6] docs(compiler): clarify union template base type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/src/core/types.ts | 8 ++++++-- packages/compiler/test/checker/union.test.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 388c2b678cd..cd7ad7fef85 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -676,8 +676,12 @@ export interface Union extends BaseType, DecoratedType, TemplatedTypeBase { expression: boolean; /** - * Type declared with the `extends` clause of a union statement. Every variant of the - * union is guaranteed to be assignable to this model, scalar, enum, or union. + * Type declared with the `extends` clause of a union statement. Union variants are + * constrained to be assignable to this model, scalar, enum, or union; violations are + * reported as diagnostics. + * + * Validation is deferred for uninstantiated template declarations because their variants + * can contain unresolved template parameters. Each template instance is validated instead. * * This is only set for named unions declared with an `extends` clause. It documents a * constraint: it does **not** imply a subclassing relationship, it does **not** mean the diff --git a/packages/compiler/test/checker/union.test.ts b/packages/compiler/test/checker/union.test.ts index 334bac1f3f4..5fafd07e672 100644 --- a/packages/compiler/test/checker/union.test.ts +++ b/packages/compiler/test/checker/union.test.ts @@ -446,6 +446,20 @@ describe("extends", () => { expectDiagnosticEmpty(diagnostics); }); + it("sets the base type before an uninstantiated template's variants are validated", async () => { + const { Foo, string: stringType } = await Tester.compile( + t.code` + union ${t.union("Foo")} extends ${t.scalar("string")} { + value: T, + } + `, + unionExtendsOptions, + ); + + expectTypeEquals(Foo.baseType, stringType); + strictEqual(Foo.variants.get("value")?.type.kind, "TemplateParameter"); + }); + it("supports a template parameter as the base type", async () => { const { Foo, string: stringType } = await Tester.compile( t.code` From ad3f39ced45b9ab5778fc74c3a29921ab57dc5b9 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 2 Sep 2026 21:36:40 -0700 Subject: [PATCH 6/6] test(compiler): cover union extends value literals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../compiler/test/server/colorization.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/compiler/test/server/colorization.test.ts b/packages/compiler/test/server/colorization.test.ts index 5a56a804a67..8abdaf25b21 100644 --- a/packages/compiler/test/server/colorization.test.ts +++ b/packages/compiler/test/server/colorization.test.ts @@ -1107,6 +1107,35 @@ function testColorization(description: string, tokenize: Tokenize) { ]); }); + it("keeps an object value literal inside the union extends expression", async () => { + const tokens = await tokenize(` + union Foo extends #{ base: "value" } { value: string } + model Bar { value: string } + `); + deepStrictEqual(tokens, [ + Token.keywords.union, + Token.identifiers.type("Foo"), + Token.keywords.extends, + Token.punctuation.openHashBrace, + Token.identifiers.variable("base"), + Token.operators.typeAnnotation, + Token.literals.stringQuoted("value"), + Token.punctuation.closeBrace, + Token.punctuation.openBrace, + Token.identifiers.variable("value"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + Token.keywords.model, + Token.identifiers.type("Bar"), + Token.punctuation.openBrace, + Token.identifiers.variable("value"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + ]); + }); + it("templated union with extends", async () => { const tokens = await tokenize("union Foo extends Bar { a: T }"); deepStrictEqual(tokens, [