Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
30 changes: 30 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,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`.
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))",
"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
151 changes: 151 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 @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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<Union["baseType"]> | 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<Union["baseType"]> {
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 {
Expand Down
4 changes: 4 additions & 0 deletions packages/compiler/src/core/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CompilerFeatureDefinition>;

export type CompilerFeatureName = keyof typeof compilerFeatures;
Expand Down
9 changes: 9 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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: {
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
24 changes: 24 additions & 0 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
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
Loading
Loading