Skip to content

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

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

feat(compiler): allow declarations to be used as expressions#11019
timotheeguerin wants to merge 10 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: 75ef483

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