Skip to content

feat(compiler): allow declarations to be used as expressions#11019

Draft
timotheeguerin wants to merge 11 commits into
microsoft:mainfrom
timotheeguerin:decl-expr
Draft

feat(compiler): allow declarations to be used as expressions#11019
timotheeguerin wants to merge 11 commits into
microsoft:mainfrom
timotheeguerin:decl-expr

Conversation

@timotheeguerin

@timotheeguerin timotheeguerin commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Allow model, enum, union, and scalar declarations to be used as expressions (e.g. the right-hand side of an alias, property types, return types, union variants).

In expression position these declarations:

  • are anonymous — their name is ""
  • produce a type with expression: true (so the type graph explicitly records that it came from an expression)
  • are not registered in the enclosing namespace
alias Foo = enum {
  a,
  b,
};

model Bar {
  status: enum { active, inactive };
  unit: scalar extends string;
  inner: model { x: string };
}

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:

  1. Naming sub-types for clarity & emitters — a name like model Inner { ... } documents intent and gives emitters a usable name, even though the declaration is not registered or referenceable.
  2. A path to decorating expressions (follow-up) — the keyword forms carry a decorators field; a later PR can allow @doc("...") model Inner { ... } in expression position.
  3. Passing inline values to decorators when the type doesn't need to exist outside that use.

All four data-shape declarations are supported for consistency. interface, op, and alias are intentionally excluded — an anonymous interface/op has no meaningful use as a type expression.

Approach

Rather than introducing new *Expression AST node kinds, the existing statement node kinds are reused with an optional id (OptionallyNamedDeclarationNode), and these kinds are added to the Expression union. Whether a declaration is in statement vs expression position is determined by its parent node kind.

  • types.tsOptionallyNamedDeclarationNode (optional id); expression: boolean on Model/Enum/Scalar (Union already had it); the 4 statement kinds added to the Expression union.
  • parser.ts — optional-id parsing on the 4 declaration parsers; parsePrimaryExpression dispatch for the model/enum/union/scalar keywords; a scalar in expression position no longer consumes the alias trailing ;.
  • binder.ts — anonymous ("-" symbol) vs named binding depending on position.
  • checker.ts — derive name/expression; skip namespace registration for expression-form types; route expression decls through the namespace walk-up in getParentNamespaceType; new templated-declaration-in-expression diagnostic.
  • formatter — optional-id printing; avoids a double ; for anonymous scalars.
  • server/inspector — null-safe handling of the now-optional id.

Semantics of the expression flag

expression means "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 therefore expression: true even 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, and pnpm lint are all clean.

Follow-ups (not in this PR)

  • Decorating declaration expressions. Feasible and grammar-unambiguous only if the @ handling in parsePrimaryExpression is 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.
  • Naming policy (open question). A name on a pure-expression declaration is kept on Type.name but is not referenceable. Whether it should ever be bindable is still undecided.
  • Audit consumers of the expression flag (expression = "produced in expression position / not registered as a named statement"):
    • packages/openapi/src/helpers.ts shouldInline → inline when type.expression (else anonymous scalars emit empty-named declarations and named expressions are hoisted as collision-prone components).
    • packages/versioning/src/validate.ts union-variant branch → gate on SyntaxKind.UnionExpression, not expression (keyword-form union variants can have decorators).
    • Typekit: align kits/enum.ts (expression: false) with kits/model.ts (name === undefined).
    • Verify json-schema emitter inline path for anonymous enum/scalar.
  • Deeper TextMate grammar rules specific to keyword-declarations in expression position.
  • Compat note: Declaration now admits an optional id (public type) — minor downstream typing impact.

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.
@microsoft-github-policy-service microsoft-github-policy-service Bot added the compiler:core Issues for @typespec/compiler label Jun 18, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@typespec/compiler

npm i https://pkg.pr.new/@typespec/compiler@11019

@typespec/html-program-viewer

npm i https://pkg.pr.new/@typespec/html-program-viewer@11019

@typespec/json-schema

npm i https://pkg.pr.new/@typespec/json-schema@11019

@typespec/openapi

npm i https://pkg.pr.new/@typespec/openapi@11019

@typespec/openapi3

npm i https://pkg.pr.new/@typespec/openapi3@11019

@typespec/versioning

npm i https://pkg.pr.new/@typespec/versioning@11019

commit: e3ecdd7

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • @typespec/compiler
  • @typespec/html-program-viewer
  • @typespec/json-schema
  • @typespec/openapi
  • @typespec/openapi3
  • @typespec/versioning
Show changes

@typespec/json-schema - feature ✏️

Support model, enum, union, and scalar declarations used in expression position. Anonymous declaration expressions are inlined, while named ones are hoisted into their own schema.,> ,> tsp,> model Foo {,> status: enum { active, inactive }; // inlined,> unit: scalar extends string; // inlined,> inner: model Inner { x: string }; // hoisted as `Inner.json`,> },>

@typespec/openapi - feature ✏️

Support model, enum, union, and scalar declarations used in expression position. Anonymous declaration expressions are inlined, while named ones are hoisted into a referenced component.,> ,> tsp,> model Foo {,> status: enum { active, inactive }; // inlined,> unit: scalar extends string; // inlined,> inner: model Inner { x: string }; // hoisted as component `Inner`,> },>

@typespec/openapi3 - feature ✏️

Support model, enum, union, and scalar declarations used in expression position. Anonymous declaration expressions are inlined, while named ones are hoisted into a referenced component.,> ,> tsp,> model Foo {,> status: enum { active, inactive }; // inlined,> unit: scalar extends string; // inlined,> inner: model Inner { x: string }; // hoisted as component `Inner`,> },>

@typespec/compiler - feature ✏️

$.enum.create now produces an enum expression (expression: true) when given an empty name, mirroring $.model.create.

@typespec/versioning - feature ✏️

Validate the variants of a keyword-form union expression (union { ... }) used in expression position like the variants of a named union, so versioning incompatibilities on decorated variants are reported.

@typespec/compiler - feature ✏️

Allow model, enum, union, and scalar declarations to be used as expressions. A declaration used in expression position has its corresponding type marked with expression: true and is not registered in the enclosing namespace. It may be named or anonymous (in which case its name is "").,> ,> tsp,> alias Foo = enum {,> a,,> b,,> };,> ,> model Bar {,> status: enum { active, inactive };,> unit: scalar extends string;,> inner: model Inner { x: string };,> },>

@typespec/html-program-viewer - feature ✏️

Display the new expression property on Model, Enum, and Scalar types in the program viewer.

@azure-sdk-automation

azure-sdk-automation Bot commented Jun 18, 2026

Copy link
Copy Markdown

You can try these changes here

🛝 Playground 🌐 Website 🛝 VSCode Extension

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.
@timotheeguerin

Copy link
Copy Markdown
Member Author

Follow-up: doc comments (and directives) are dropped on declaration expressions

Problem

Leading 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 entirely

Verified against the AST: in the first case the doc node attaches to the ModelProperty; in the inline/alias cases no node receives the doc, so getDoc(program, type) returns undefined. The same applies to model, union, and scalar declaration expressions.

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 cause

Doc comments take a separate parse path from decorators. They are scanned into docRanges as trivia and only materialized when something calls parseDocList(). That only happens inside parseAnnotations() and the statement-list loops.

Declaration expressions are parsed in parsePrimaryExpression (packages/compiler/src/core/parser.ts), whose model/enum/union/scalar keyword cases (and the @ decorator case) call parse*Statement(...) directly and never call parseDocList(). Any pending doc node sitting in docRanges is therefore thrown away.

For reference, at statement position docs/directives are attached after parsing via the mutate pattern, not passed into parse*Statement:

// parseTypeSpecScriptItemList / parseStatementList
item = parseDeclaration(pos, decorators, docs, directives);
...
mutate(item).directives = directives;
mutate(item).docs = docs;

parse*Statement(pos, decorators, modifiers, allowAnonymous) does not accept docs/directives.

Suggested implementation

In parsePrimaryExpression, for the four declaration-keyword cases and the @-decorator case that dispatches to them:

  1. Call parseDocList() to consume the pending doc ranges (the leading doc comment before the keyword is already in docRanges by the time we reach these cases).
  2. After building the declaration node, attach docs with mutate(node).docs = docs (mirror the statement-list pattern).
  3. Decide on directive support too. Directives (#suppress, etc.) hit the existing case Token.Hash in parsePrimaryExpression which currently calls reportInvalidDirective(directives, "expression"). If we want directives on declaration expressions, this needs the same dispatch treatment as the @ case; otherwise leave directives rejected and scope this to doc comments only (document the choice).

A cleaner alternative is to factor the doc/directive/decorator collection so the expression path reuses the same ordering logic as parseAnnotations() (which accepts docs, directives, and decorators in any order), then dispatch to the declaration parse and attach all three. Watch ordering cases like /** doc */ @dec enum {} and @dec /** doc */ enum {}parseDecoratorList() scanning past the decorator will repopulate docRanges, so a naive single parseDocList() call may miss docs that appear after the decorators.

Checker

No checker change expected: node.docs is already consumed for model/enum/union/scalar nodes (doc comments work for these at statement position), so attaching the docs in the parser is sufficient for getDoc to pick them up.

Tests to add

  • packages/compiler/test/checker/declaration-expressions.test.ts: mirror the existing decorator tests — assert getDoc(program, type) returns the doc text for an anonymous enum expression, a named model expression, a union expression, and a scalar expression, in both the property-inline form (prop: /** d */ enum {...}) and the alias form. Add a combined case (/** d */ @doc("x") enum {...}) to lock in ordering.
  • packages/compiler/test/formatter/formatter.test.ts: add formatter cases ensuring a leading doc comment is preserved/printed correctly on each declaration-expression kind.

Repro snippet for quick verification

@test model Foo {
  inner: /** the inner */ model Inner { x: string };
}

Expected after fix: getDoc(program, Foo.properties.get("inner").type) === "the inner".

# Conflicts:
#	packages/compiler/src/formatter/print/printer.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant