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..9da98a5d3b8 --- /dev/null +++ b/.chronus/changes/union-extends-base-type-2026-8-26.md @@ -0,0 +1,30 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +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 { + 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..2f314c51491 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: @@ -7768,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); @@ -7786,6 +7797,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,7 +7838,143 @@ 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, + ): NonNullable | 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; + } + + 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. + * + * 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 { diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index af9a75d3453..8fc47e23b6a 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -19,6 +19,10 @@ export const compilerFeatures = { description: "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.", + }, } 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/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 518eed2a991..388c2b678cd 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -675,6 +675,21 @@ 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. + * + * 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. + * + * @experimental + */ + baseType?: Model | Scalar | Enum | Union; + /** * Late-bound symbol of this interface type. * @internal @@ -1600,6 +1615,15 @@ 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. + * + * @experimental + */ + 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..355e2a26b7a 100644 --- a/packages/compiler/src/formatter/print/comment-handler.ts +++ b/packages/compiler/src/formatter/print/comment-handler.ts @@ -15,13 +15,9 @@ interface CommentNode extends TextRange { */ export const commentHandler: Printer["handleComments"] = { ownLine: (comment, text, options, ast, isLastComment) => - [ - addEmptyInterfaceComment, - addEmptyModelComment, - addEmptyScalarComment, - 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 }), @@ -40,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; } @@ -76,7 +96,7 @@ function addEmptyInterfaceComment({ comment, ast }: CommentContext) { * } */ function addCommentBetweenAnnotationsAndNode({ comment }: CommentContext) { - const { enclosingNode, precedingNode } = comment; + const { precedingNode, enclosingNode } = comment; if ( precedingNode && @@ -101,58 +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; -} - 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..c466e542908 100644 --- a/packages/compiler/test/checker/union.test.ts +++ b/packages/compiler/test/checker/union.test.ts @@ -1,9 +1,32 @@ 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 { + type TestCompileOptions, + expectDiagnosticEmpty, + expectDiagnostics, + expectTypeEquals, + mockFile, + t, +} 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(); @@ -72,6 +95,559 @@ describe("declarations", () => { }); }); +describe("extends", () => { + 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 } + `); + + 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); + }); + + 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 } + `, + unionExtendsOptions, + ); + + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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", + } + `, + 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, + } + `, + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + model Wrapper { value: T } + + union Foo extends Wrapper { + a: Wrapper, + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + describe("templates", () => { + it("checks the constraint on instantiation", async () => { + const diagnostics = await diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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")}>; + `, + unionExtendsOptions, + ); + expectTypeEquals(Foo.baseType, stringType); + }); + + it("emits a diagnostic when a variant doesn't satisfy a template parameter base type", async () => { + const diagnostics = await diagnoseUnionExtends(` + 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 diagnoseUnionExtends(`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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(`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 diagnoseUnionExtends( + `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 diagnoseUnionExtends(` + 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 }`, + unionExtendsOptions, + ); + 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 } + `, + unionExtendsOptions, + ); + expectDiagnosticEmpty(diagnostics); + strictEqual(a.baseType?.kind, "Model"); + }); + }); + + it("doesn't cascade errors when the base type cannot be resolved", async () => { + const diagnostics = await diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + 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 diagnoseUnionExtends(` + #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 diagnoseUnionExtends(` + #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 diagnoseUnionExtends(` + #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; + `, + unionExtendsOptions, + ); + + 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/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index 5a0b9dcf695..28deee7e83d 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -23,5 +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.", ]); }); diff --git a/packages/compiler/test/experimental/mutator.test.ts b/packages/compiler/test/experimental/mutator.test.ts index 7302674365a..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, @@ -490,6 +500,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..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); @@ -504,6 +513,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/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index f7fa5d55191..2d817306050 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -134,19 +134,24 @@ describe("Test completion items for features", () => { it.each([ { config: `features:\n - ┆`, - expected: ['"auto-decorators"', '"function-declarations"', '"type-info-provider"'], + expected: [ + '"auto-decorators"', + '"function-declarations"', + '"type-info-provider"', + '"union-extends"', + ], }, { config: `features:\n - "┆"`, - expected: ["auto-decorators", "function-declarations", "type-info-provider"], + expected: ["auto-decorators", "function-declarations", "type-info-provider", "union-extends"], }, { config: `features:\n - "function┆"`, - expected: ["auto-decorators", "function-declarations", "type-info-provider"], + expected: ["auto-decorators", "function-declarations", "type-info-provider", "union-extends"], }, { config: `features:\n - function-declarations\n - ┆`, - expected: ['"auto-decorators"', '"type-info-provider"'], + expected: ['"auto-decorators"', '"type-info-provider"', '"union-extends"'], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -160,6 +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.", ], true, ); 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..ab6ecccb062 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -35,3 +35,84 @@ 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. + +:::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; +} +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, +} +``` + +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 { + "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.