Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/compiler"
---

Run the `onGraphFinish` validator of a decorator applied to a type created after the type graph was checked, a clone a mutator produced during `$onValidate` for example. Those validators used to be registered but never run.
14 changes: 14 additions & 0 deletions .chronus/changes/fix-relation-checker-recursion-2026-8-26.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
changeKind: fix
packages:
- "@typespec/compiler"
---

Fix a stack overflow when checking assignability of mutually recursive types

Checking whether a type was assignable to another one could recurse forever and crash the compiler with `RangeError: Maximum call stack size exceeded`. Two cases were affected:

- mutually recursive models, such as `model A { b: B }` / `model B { a: A }`
- any union reaching itself, such as `union Foo { self: Foo }`

The relation cache is now shared for the whole check instead of being recreated at every level, and unions seed it before walking their variants, so a cycle coming back to the same pair of types resolves instead of recursing.
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`.
29 changes: 29 additions & 0 deletions .chronus/changes/union-strict-extends-2026-8-26.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add `@strictExtends` to require every variant of a union to explicitly extend the base type declared by the union `extends` clause.

By default the `extends` clause of a union is a structural constraint: any variant with a compatible shape satisfies it. Emitters targeting languages without native unions represent such a union with a polymorphic base type, which requires each variant to actually derive from the base type.

```tsp
model Pet {
name: string;
}
model Cat extends Pet {
meow: boolean;
}
model Rock {
name: string;
}

@strictExtends
union Pets extends Pet {
cat: Cat, // ok: `Cat` extends `Pet`
rock: Rock, // error: `Rock` has the same shape as `Pet` but doesn't extend it
}
```

`@strictExtends` can only be used when the base type is a model, a scalar or an enum, since those are the only types that can be explicitly extended. A variant that is itself a union satisfies the constraint when all of its own variants do, so unions can still be composed.
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
35 changes: 35 additions & 0 deletions packages/compiler/generated-defs/TypeSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,40 @@ export type DiscriminatedDecorator = (
options?: DiscriminatedOptions,
) => DecoratorValidatorCallbacks | void;

/**
* Require every variant of a union to explicitly extend the base type declared by the union
* `extends` clause.
*
* By default a union `extends` clause is a structural constraint: any variant with a compatible
* shape satisfies it. Emitters targeting languages without native unions represent such a union
* with a polymorphic base type, which requires each variant to actually derive from that base
* type. `@strictExtends` turns that requirement into a compile time error.
*
* This only adds a constraint when the base type is a model, scalar or enum. Any other base
* type (a union, tuple, `unknown`, ...) cannot be explicitly extended and is an error.
*
* A variant that is itself a union satisfies the constraint when all of its own variants do,
* which allows composing unions. `never`, an empty union and a self referencing union describe
* no value at all so they satisfy the constraint vacuously.
*
* @example
* ```typespec
* model Pet {}
* model Cat extends Pet {}
* model Rock {}
*
* @strictExtends
* union Pets extends Pet {
* cat: Cat, // ok: `Cat` extends `Pet`
* rock: Rock, // error: `Rock` has the same shape as `Pet` but doesn't extend it
* }
* ```
*/
export type StrictExtendsDecorator = (
context: DecoratorContext,
target: Union,
) => DecoratorValidatorCallbacks | void;

/**
* Specify the property to be used to discriminate this type.
*
Expand Down Expand Up @@ -1191,6 +1225,7 @@ export type TypeSpecDecorators = {
overload: OverloadDecorator;
encodedName: EncodedNameDecorator;
discriminated: DiscriminatedDecorator;
strictExtends: StrictExtendsDecorator;
discriminator: DiscriminatorDecorator;
example: ExampleDecorator;
opExample: OpExampleDecorator;
Expand Down
32 changes: 32 additions & 0 deletions packages/compiler/lib/std/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,38 @@ model DiscriminatedOptions {
*/
extern dec discriminated(target: Union, options?: valueof DiscriminatedOptions);

/**
* Require every variant of a union to explicitly extend the base type declared by the union
* `extends` clause.
*
* By default a union `extends` clause is a structural constraint: any variant with a compatible
* shape satisfies it. Emitters targeting languages without native unions represent such a union
* with a polymorphic base type, which requires each variant to actually derive from that base
* type. `@strictExtends` turns that requirement into a compile time error.
*
* This only adds a constraint when the base type is a model, scalar or enum. Any other base
* type (a union, tuple, `unknown`, ...) cannot be explicitly extended and is an error.
*
* A variant that is itself a union satisfies the constraint when all of its own variants do,
* which allows composing unions. `never`, an empty union and a self referencing union describe
* no value at all so they satisfy the constraint vacuously.
*
* @example
*
* ```typespec
* model Pet {}
* model Cat extends Pet {}
* model Rock {}
*
* @strictExtends
* union Pets extends Pet {
* cat: Cat, // ok: `Cat` extends `Pet`
* rock: Rock, // error: `Rock` has the same shape as `Pet` but doesn't extend it
* }
* ```
*/
extern dec strictExtends(target: Union);

/**
* Specify the property to be used to discriminate this type.
* @param propertyName The property name to use for discrimination
Expand Down
123 changes: 122 additions & 1 deletion packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
const pendingResolutions = new PendingResolutions();
const spreadResolutionAncestors = new Map<Sym, Set<Sym>>();
const postCheckValidators: ValidatorFn[] = [];
/**
* Whether {@link postCheckValidators} was already run. Types created after that, with a mutator
* for example, have no graph finish left to wait for.
*/
let postCheckValidatorsRan = false;

const typespecNamespaceBinding = resolver.symbols.global.exports!.get("TypeSpec");
if (typespecNamespaceBinding) {
Expand Down Expand Up @@ -1399,6 +1404,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 @@ -4898,6 +4905,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
internalDecoratorValidation();
assertNoPendingResolutions();
runPostValidators(postCheckValidators);
postCheckValidatorsRan = true;
}

function assertNoPendingResolutions() {
Expand Down Expand Up @@ -7786,6 +7794,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 +7835,110 @@ 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 {
Expand Down Expand Up @@ -8061,7 +8176,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
postSelfValidators.push(validators.onTargetFinish);
}
if (validators?.onGraphFinish) {
postCheckValidators.push(validators.onGraphFinish);
if (postCheckValidatorsRan) {
// The type graph was already checked so the validator would never run. Run it as soon as
// the type is finished instead.
postSelfValidators.push(validators.onGraphFinish);
} else {
postCheckValidators.push(validators.onGraphFinish);
}
}
}
return postSelfValidators;
Expand Down
19 changes: 19 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,25 @@ const diagnostics = {
default: paramMessage`Union already has a variant named ${"name"}`,
},
},
"strict-extends-no-base-type": {
severity: "error",
messages: {
default: "@strictExtends can only be used on a union declaring a base type with `extends`.",
},
},
"strict-extends-invalid-base-type": {
severity: "error",
messages: {
default: paramMessage`@strictExtends cannot be used with the base type '${"baseType"}': only a model, scalar or enum base type can be explicitly extended.`,
},
},
"strict-extends-variant": {
severity: "error",
messages: {
default: paramMessage`Variant of type '${"variantType"}' must be or extend '${"baseType"}' as required by @strictExtends.`,
nested: paramMessage`Variant of type '${"variantType"}' includes '${"offendingType"}' which must be or extend '${"baseType"}' as required by @strictExtends.`,
},
},
"enum-member-duplicate": {
severity: "error",
messages: {
Expand Down
Loading
Loading