Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .chronus/changes/union-extends-base-type-2026-8-26.md
Original file line number Diff line number Diff line change
@@ -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`.
24 changes: 24 additions & 0 deletions grammars/typespec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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))",
Comment thread
JoshLove-msft marked this conversation as resolved.
"patterns": [
{
"include": "#expression"
},
{
"include": "#punctuation-comma"
}
]
},
"union-statement": {
"name": "meta.union-statement.typespec",
"begin": "(?:(internal)\\s+)?\\b(union)\\b\\s+(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)",
Expand All @@ -1397,6 +1415,12 @@
{
"include": "#token"
},
{
"include": "#type-parameters"
},
{
"include": "#union-extends"
},
{
"include": "#union-body"
}
Expand Down
109 changes: 109 additions & 0 deletions packages/compiler/src/core/checker.ts
Comment thread
JoshLove-msft marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions packages/compiler/src/core/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,13 +704,17 @@ 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 {
kind: SyntaxKind.UnionStatement,
id,
templateParameters,
templateParametersRange,
extends: optionalExtends,
decorators,
modifiers,
modifierFlags: modifiersToFlags(modifiers),
Expand All @@ -719,6 +723,13 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
};
}

function parseOptionalUnionExtends() {
if (parseOptional(Token.ExtendsKeyword)) {
Comment thread
JoshLove-msft marked this conversation as resolved.
return parseExpression();
}
return undefined;
}

function parseIdOrValueForVariant(): Expression {
const nextToken = token();

Expand Down Expand Up @@ -3072,6 +3083,7 @@ export function visitChildren<T>(node: Node, cb: NodeCallback<T>): 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:
Expand Down
3 changes: 3 additions & 0 deletions packages/compiler/src/core/semantic-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
20 changes: 20 additions & 0 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
JoshLove-msft marked this conversation as resolved.
Outdated

/**
* Late-bound symbol of this interface type.
* @internal
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/experimental/mutators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions packages/compiler/src/formatter/print/comment-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const commentHandler: Printer<Node>["handleComments"] = {
addEmptyInterfaceComment,
addEmptyModelComment,
addEmptyScalarComment,
addEmptyUnionComment,
addCommentBetweenAnnotationsAndNode,
handleOnlyComments,
].some((x) => x({ comment, text, options, ast: ast as TypeSpecScriptNode, isLastComment })),
Expand Down Expand Up @@ -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) {
Comment thread
JoshLove-msft marked this conversation as resolved.
Outdated
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) {
Expand Down
10 changes: 8 additions & 2 deletions packages/compiler/src/formatter/print/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];
Expand All @@ -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, "}"]);
}

Expand Down
Loading
Loading