feat(compiler): allow declarations to be used as expressions - #11019
feat(compiler): allow declarations to be used as expressions#11019Timothee Guerin (timotheeguerin) wants to merge 41 commits into
Conversation
Allow model, enum, union, and scalar declarations to be used in expression position (e.g. alias RHS, property types). In expression position they are anonymous (name is "") and the resulting type has expression: true; they are not registered in the enclosing namespace. A diagnostic is reported when template parameters are used on a declaration in expression position.
@typespec/compiler
@typespec/graphql
@typespec/html-program-viewer
@typespec/json-schema
@typespec/openapi
@typespec/openapi3
@typespec/versioning
commit: |
|
All changed packages have been documented.
Show changes
|
|
You can try these changes here
|
A keyword-form union (`union { a, b }`) used in expression position is marked
`expression: true`, which caused checkUnionExpression to flatten its (possibly
named) variants into the parent union, silently dropping colliding members.
Flatten only unions originating from the `|` operator (UnionExpression node).
Add tests for: - expression: false on statement declarations - name retention on named declaration expressions - named expressions not being referenceable - union namespace non-registration - alias-resolved types, op return/param, union variant usage - member access via alias, decorator rejection - enum values, union named variants, scalar constructors, model spread - parser negatives for interface/op in expression position - formatter named & nested declaration expressions
Anonymous declarations used in expression position rendered with a stray
namespace prefix (e.g. `Ns.` for enum/scalar, `Ns.{ x: string }` for
keyword-form model). Render them inline and un-prefixed, mirroring union
expression naming.
Also extract a single shared `isDeclarationInExpressionPosition` helper used
by both the binder and checker so the two position predicates cannot drift,
and add regression tests (type names, keyword-form union as `|` operand,
template parameter referenced inside an expression declaration).
Inline anonymous declaration expressions and hoist named ones across the OpenAPI and JSON Schema emitters, validate keyword-form union expression variants in versioning, and derive the enum typekit `expression` flag from an empty name.
Follow-up: doc comments (and directives) are dropped on declaration expressionsProblemLeading doc comments are not attached to declarations used in expression position. They are silently discarded: model Foo {
/** the status */
status: enum { active, inactive }; // ✅ doc attaches to ModelProperty `status`
other: /** the other */ enum { x, y }; // ❌ doc dropped entirely
}
alias E = /** my enum */ enum { a, b }; // ❌ doc dropped entirelyVerified against the AST: in the first case the doc node attaches to the This is not specific to the inline-decorators work — it affects every declaration expression regardless of decorators — so it belongs to this base feature rather than the decorators PR. Root causeDoc comments take a separate parse path from decorators. They are scanned into Declaration expressions are parsed in For reference, at statement position docs/directives are attached after parsing via the mutate pattern, not passed into // parseTypeSpecScriptItemList / parseStatementList
item = parseDeclaration(pos, decorators, docs, directives);
...
mutate(item).directives = directives;
mutate(item).docs = docs;
Suggested implementationIn
A cleaner alternative is to factor the doc/directive/decorator collection so the expression path reuses the same ordering logic as CheckerNo checker change expected: Tests to add
Repro snippet for quick verification@test model Foo {
inner: /** the inner */ model Inner { x: string };
}Expected after fix: |
# Conflicts: # packages/compiler/src/formatter/print/printer.ts
Replace the flag-based design (statement nodes with optional id + position inference) with dedicated SyntaxKind nodes per declaration type: Model/Scalar/Union/EnumDeclarationExpression. The Type-level `expression` field is kept so emitters/versioning are unchanged.
|
Design meeting discussion
b:
@doc("Model b doc")
@doc("Model b doc")
@doc("Model b doc")
model NamedB {
prop: string;
};
|
…expressions Allow decorators to be applied inline to model/enum/union/scalar declarations used in expression position, and allow augment decorators (@@) to target them via navigation references (e.g. `Foo.x::type`).
…omments, and formatting Support using model/enum/union/scalar declaration expressions in expression-list positions (decorator arguments, template arguments, function/call arguments, tuples), apply doc comments inline like decorators, and keep multiple inline decorators inline when formatting. Also consolidate the per-commit decl-expr changesets into PR-level entries: one @typespec/compiler feature entry each for declaration expressions, decorators on declaration expressions, doc comments, and the $.enum.create typekit change.
- Parser: scope declaration-keyword list termination to element start so an unclosed expression list (e.g. @foo(model X {}) no longer swallows the following statement - Typekit: add optional expression flag to model/union/enum descriptors and key isExpresion/isExpression off type.expression - Printer: use isDeclarationExpressionNode instead of parent-position inference - Docs: add 'In expression position' sections to models, enums, unions, scalars
Replace the experimental-feature warning with a dedicated declaration-expression-disabled error and restore ScalarStatement in the node inspector switch.
# Conflicts: # packages/compiler/src/core/helpers/type-name-utils.ts # packages/compiler/test/core/cli/actions/info.test.ts # packages/compiler/test/server/completion.tspconfig.test.ts # packages/versioning/test/incompatible-versioning.test.ts
There was a problem hiding this comment.
🟡 Changes recommended
The spec grammar update for ModelDeclarationExpression doesn’t reflect the implemented model is Base (no-body) expression form and needs to be corrected to match compiler behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 60/60 changed files
- Comments generated: 1
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces new language syntax/AST/type-graph semantics and touches multiple compiler subsystems and emitters, warranting final human review despite strong test coverage.
Review details
- Files reviewed: 60/60 changed files
- Comments generated: 0 new
- Review effort level: Lite
# Conflicts: # grammars/typespec.json # packages/compiler/src/core/messages.ts # packages/compiler/src/core/parser.ts # packages/compiler/src/formatter/print/comment-handler.ts # packages/compiler/test/core/cli/actions/info.test.ts # packages/compiler/test/server/completion.tspconfig.test.ts # website/src/content/docs/docs/language-basics/unions.md
There was a problem hiding this comment.
🟡 Changes recommended
There are a couple of concrete correctness/doc issues in the updated TypeKit defaulting and tester compiler-options merging semantics that should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 60/60 changed files
- Comments generated: 3
- Review effort level: Lite
- Preserve tester-level configFile (and features) when per-call compiler options provide their own configFile. - Treat an empty name as anonymous when defaulting `expression` in `$.model.create`, `$.union.create` and `$.enum.create`. - Clarify wording around anonymous declaration expressions having no name.
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces broad Tier 0 language/type-graph changes across parser/binder/checker/formatter/emitters, requiring careful human verification of compatibility and downstream impact.
Review details
- Files reviewed: 61/61 changed files
- Comments generated: 3
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
The new mergeCompilerOptions behavior can still drop tester-enabled features when a per-call configFile.features is present due to shallow merging.
Review details
Suppressed comments (1)
packages/compiler/src/testing/tester.ts:99
mergeCompilerOptionsshallow-mergesconfigFile, so if a per-callcompilerOptions.configFileincludes its ownfeaturesarray it will replace (and effectively drop) the tester instance’s default features, contradicting the function comment that per-call options shouldn’t drop enabled features.
const configFile =
base?.configFile && override?.configFile
? ({ ...base.configFile, ...override.configFile } as TypeSpecConfig)
: (override?.configFile ?? base?.configFile);
return { ...base, ...override, ...(configFile && { configFile }) };
- Files reviewed: 62/62 changed files
- Comments generated: 0 new
- Review effort level: Lite
# Conflicts: # packages/compiler/src/formatter/print/printer.ts
| /** | ||
| * Declaration expressions are an experimental feature gated behind the | ||
| * `declaration-expressions` compiler feature. Enable it for these tests so they | ||
| * exercise the feature without the experimental warning. | ||
| */ |
Summary
Allow
model,enum,union, andscalardeclarations to be used as expressions (e.g. the right-hand side of analias, property types, return types, union variants).In expression position these declarations:
nameis""expression: true(so the type graph explicitly records that it came from an expression)Named forms in expression position are also allowed (e.g.
nested: model Inner { x: string }), and declarations can be nested.Motivation
A model/enum/scalar is often only meaningful in the context of its parent type. Allowing them inline lets a spec be expressed more clearly without polluting the namespace with single-use declarations. Specifically this enables:
model Inner { ... }documents intent and gives emitters a usable name, even though the declaration is not registered or referenceable.decoratorsfield; a later PR can allow@doc("...") model Inner { ... }in expression position.All four data-shape declarations are supported for consistency.
interface,op, andaliasare intentionally excluded — an anonymous interface/op has no meaningful use as a type expression.Approach
Rather than introducing new
*ExpressionAST node kinds, the existing statement node kinds are reused with an optionalid(OptionallyNamedDeclarationNode), and these kinds are added to theExpressionunion. Whether a declaration is in statement vs expression position is determined by its parent node kind.OptionallyNamedDeclarationNode(optionalid);expression: booleanonModel/Enum/Scalar(Unionalready had it); the 4 statement kinds added to theExpressionunion.parsePrimaryExpressiondispatch for themodel/enum/union/scalarkeywords; ascalarin expression position no longer consumes the alias trailing;."-"symbol) vs named binding depending on position.name/expression; skip namespace registration for expression-form types; route expression decls through the namespace walk-up ingetParentNamespaceType; newtemplated-declaration-in-expressiondiagnostic.;for anonymous scalars.id.Semantics of the
expressionflagexpressionmeans "produced in expression position / not registered as a named statement" — it is position-based, not name-based. A named declaration expression (model Inner { ... }used as a property type) is thereforeexpression: trueeven though it has a name. This is the single definition all consumers should rely on.Diagnostics
Template parameters on a declaration in expression position are rejected (
templated-declaration-in-expression) since an anonymous declaration cannot be referenced or instantiated.Tests
test/checker/declaration-expressions.test.ts(14 tests)test/parser.test.ts— new "declaration expressions" block (11 cases)test/formatter/formatter.test.ts— new "declaration expressions" block (5 cases)Full compiler suite: 3982 passed / 6 skipped.
tsc --noEmit,pnpm format, andpnpm lintare all clean.Follow-ups (not in this PR)
@handling inparsePrimaryExpressionis narrowly relaxed to "decorators immediately followed by a declaration keyword" (keyword-only — do not broaden to arbitrary expressions). Decorators attach to the keyword-form node.Type.namebut is not referenceable. Whether it should ever be bindable is still undecided.expressionflag (expression= "produced in expression position / not registered as a named statement"):packages/openapi/src/helpers.tsshouldInline→ inline whentype.expression(else anonymous scalars emit empty-named declarations and named expressions are hoisted as collision-prone components).packages/versioning/src/validate.tsunion-variant branch → gate onSyntaxKind.UnionExpression, notexpression(keyword-form union variants can have decorators).kits/enum.ts(expression: false) withkits/model.ts(name === undefined).Declarationnow admits an optionalid(public type) — minor downstream typing impact.