Skip to content

Scopping/conditional system #10551

@timotheeguerin

Description

@timotheeguerin

Design: when Keyword for Conditional Scoping

Summary

This document proposes a when keyword for TypeSpec that enables conditional scoping of decorators, type declarations, and model properties based on dimensions like emitter identity, API version, and visibility lifecycle.

Motivation

Today, different scoping concerns are handled by separate, incompatible mechanisms:

  • Emitter-specific metadata: No built-in mechanism. Emitters rely on conventions or custom decorators.
  • API versioning: The @added/@removed decorator family stores version info in state maps; the versioning mutator projects the type graph per version.
  • Visibility/lifecycle: The @visibility decorator stores modifiers; consumers filter at query time.

These share a common pattern — the same type has different facets depending on who's looking — but each requires its own API, storage, and projection mechanism. The when keyword unifies them under a single language construct.

Proposed Syntax

Emitter-scoped decorators

@name("CsBar") when emitter("@typespec/http-client-csharp")
@name("PyBar") when emitter("@typespec/http-client-python")
@name("JavaBar") when language("java")
@name("ClientBar") when target("client")
model Bar {}

Scoped decorators are not applied by default. It is up to the emitter or library to query the type graph with the right scope/filter. An emitter declares a program view with its emitter name, language, and emitter kind (client, server, etc.), and the matching decorators are applied accordingly.

Visibility-scoped properties

model Foo {
  name: string when Lifecycle.read;
  name: string | number when Lifecycle.create, Lifecycle.update;
}

In the unconditioned type graph the checker represents name as string | string | number (the union of all possibilities). Emitters and validators query the type with a given filter (e.g., Lifecycle.read) to see the resolved type.

Version-scoped declarations

when since(Version.v2) {
  model Foo {}
  model Bar {}
}

model Foo {} when between(Versions.v2, Versions.v3)

model Bar {
  prop: Foo when between(Versions.v2, Versions.v3);
}

Same as above — by default the type graph sees everything merged (similar to how versioning works today). The versioning mutator projects to a specific version.

Key Design Constraint: when on Decorators Limited to Metadata Decorators

The when clause on decorators is restricted to decorators that are guaranteed to be side-effect-free metadata annotations. This includes:

  1. data decorators (from the Data Decorators proposal) — no JS implementation, compiler auto-stores arguments.
  2. pure extern decorators — have a JS implementation but are marked as side-effect-free by the author.

Plain extern dec decorators (which may have arbitrary side effects) cannot use when clauses.

Why this constraint matters

Without it, scoped decorators would require deferred execution of arbitrary JS code — the decorator implementation would need to be stored and replayed when a consumer queries with a matching scope. This creates severe problems:

  • Side-effect ordering: Some decorators mutate state that other decorators depend on. Deferring them breaks check-time invariants.
  • Checker flow disruption: The checker assumes all decorators have run by the time finishType() completes. Deferred decorators would violate this.
  • Complexity: The compiler would need to track decorator dependency graphs to determine safe deferral order.

By restricting to metadata decorators, scoped storage becomes a simple query-time filter on the existing state map infrastructure. No deferred execution, no side-effect concerns, no checker changes.

The three-tier decorator model

Kind Syntax JS Implementation Side Effects when eligible
Data data dec foo(...) None (auto-stored) None ✅ Yes
Pure extern pure extern dec foo(...) Yes None (author contract) ✅ Yes
Extern extern dec foo(...) Yes Possible ❌ No

Modifier Naming: pure for Side-Effect-Free extern dec

The key semantic contract for pure is: "this decorator's JS implementation only stores metadata (calls context.program.stateMap().set() or equivalent). It does not mutate type structure, affect checking, or have ordering-dependent side effects."

Options considered

Option Syntax Pros Cons
pure pure extern dec foo(...) Well-known CS concept (Haskell, D, GCC, Solidity). Short. Intuitive. Strict FP "pure" means deterministic + no I/O; this is slightly looser
metadata metadata extern dec foo(...) Most descriptive of actual behavior Verbose (8 chars). Unusual as a language keyword
readonly readonly extern dec foo(...) Existing TypeSpec concept Misleading — the decorator does write state
safe safe extern dec foo(...) Short, intuitive Vague — safe in what sense?
const const extern dec foo(...) Familiar keyword Already used in TypeSpec for const declarations
stable stable extern dec foo(...) Implies idempotent Doesn't convey "no side effects"
passive passive extern dec foo(...) Implies annotation-only Unusual, no precedent
declarative declarative extern dec foo(...) Describes nature well Very verbose (11 chars)

Recommendation: pure

  • Precedent: Used in D, Haskell, GCC (__attribute__((pure))), and Solidity for "no side effects."
  • Brevity: 4 characters, scans well alongside extern.
  • Accurate enough: The TypeSpec usage — metadata storage is effectively idempotent and order-independent — is close to the CS meaning.
  • Future-proof: Can be refined if we later distinguish levels of purity.

Enforcement

pure is an author-declared contract (similar to how readonly is trusted). Optional static analysis of the JS implementation to verify it only calls state-map APIs is a future enhancement.

Detailed Design

1. Parser / Syntax Changes

New keyword

when is added to the scanner as a contextual keyword (usable as an identifier in non-ambiguous positions).

New AST nodes

A WhenClause node containing a condition expression, attachable to:

  • Decorator expressions (trailing): @decorator(args) when condition
  • Statement declarations (trailing): model Foo {} when condition
  • Model property types (trailing): prop: Type when condition
  • Block form (leading): when condition { ...statements }

Condition expression grammar

Initially restricted to a known set of filter predicates:

WhenClause     ::= 'when' ConditionList
ConditionList  ::= Condition (',' Condition)*
Condition      ::= FilterCall | EnumMemberRef
FilterCall     ::= Identifier '(' ArgumentList ')'
EnumMemberRef  ::= MemberExpression

Built-in filters: emitter(name), language(name), target(kind), since(version), between(v1, v2).

Block form

when condition { ...statements } is syntactic sugar — it desugars to applying the when clause to each statement inside the block.

Design issues

  • Ambiguity: The trailing form model Foo {} when cond could be confused with a new statement starting with when. The parser resolves this by treating when as trailing when it follows a closing } or declaration.
  • Nesting: when blocks can appear inside namespaces. Nested when blocks compose (AND semantics).

2. Checker / Type System

2a. Scoped metadata decorators

Storage model: When a metadata decorator (data or pure extern) has a when clause, the compiler extends the auto-storage to include condition metadata:

// Unscoped data decorator
stateMap.set(type, value)

// Scoped data decorator
scopedStateMap.set(type, [
  { value: "CsBar", condition: { kind: "emitter", value: "@typespec/http-client-csharp" } },
  { value: "PyBar", condition: { kind: "emitter", value: "@typespec/http-client-python" } },
])

Validation: The checker still validates all decorator arguments at check time, regardless of the when clause. Only the storage is conditioned, not the validation.

Query API: The generic data decorator accessors gain a scope-aware overload:

// Existing (returns unscoped value)
getDataDecoratorValue(program, "MyLib.name", type);

// New (filters by scope)
getDataDecoratorValue(program, "MyLib.name", type, {
  emitter: "@typespec/http-client-csharp",
});

Conflict resolution: If two scoped entries match the same query scope, the compiler reports an error at check time. Unscoped values act as defaults — a scoped value overrides the default when the scope matches.

Error on extern dec: If a non-pure, non-data decorator has a when clause, the checker emits:

error: 'when' clause is only allowed on 'data' or 'pure extern' decorators.

2b. Visibility-scoped properties

A property can have multiple type variants conditioned on visibility:

model Foo {
  name: string when Lifecycle.read;
  name: string | number when Lifecycle.create, Lifecycle.update;
}

Checker behavior:

  • Multiple declarations of the same property name are allowed if each has a non-overlapping when condition.
  • In the unconditioned type graph, the property's type is the union of all variant types.
  • When queried with a visibility filter, only the matching variant's type is returned.

Issues:

  • Exhaustiveness: Should the compiler verify that all variants of the condition enum are covered? This is desirable but complex. Recommendation: warn, don't error (initially).
  • Default/fallback: If no condition matches, the property is absent from the filtered view.
  • Inheritance: is/extends propagate conditional properties to the derived model.

2c. Version-scoped declarations

when since(v2) maps to the same state that @added(Versions, Versions.v2) produces today. The versioning mutator already handles projection.

Block scoping: when since(v2) { model Foo {} } propagates the version condition through the binder to all declarations inside the block.

Migration: Both @added/@removed and when since(...)/when between(...) are supported during transition. Eventual deprecation of the decorator forms.

3. Projection / Query System

For scoped metadata decorators: query-time filtering (no cloning)

Since metadata decorators don't change the type graph structure, scoped queries are a simple filter on the state map. No type cloning or mutator infrastructure is needed.

// Emitter creates a scope
const scope = context.createScope({
  emitter: "@typespec/http-client-csharp",
  language: "csharp",
  target: "client",
});

// Query resolves scoped values
const name = getDataDecoratorValue(scope, "MyLib.name", someType);
// → "CsBar" (from the @name("CsBar") when emitter("...csharp") entry)

For visibility/versioning: mutator-based projection

When the type graph itself changes shape (properties added/removed, types swapped), the existing mutator engine (packages/compiler/src/experimental/mutators.ts) creates projected views:

ProgramView = Program + Set<Filter>

The mutator clones the relevant subgraph and applies transformations (remove absent members, swap types, rename). This is already how versioning works today via getVersioningMutators().

Composability

Filters compose:

  • Metadata filters (emitter/language/target): compose at query time (AND semantics)
  • Structural filters (version/visibility): compose via chained mutators

An emitter that needs both version projection and emitter-scoped metadata creates a version-projected view, then queries scoped metadata against it.

4. Emitter API Changes

Scope declaration

export async function $onEmit(context: EmitContext) {
  // Option A: Scope from emitter definition (auto-populated from package.json metadata)
  // context.scope is pre-configured

  // Option B: Explicit scope creation
  const scope = context.createScope({
    emitter: "@typespec/http-client-csharp",
    language: "csharp",
    target: "client",
  });

  // Use scope for metadata queries
  const name = getDataDecoratorValue(scope, "MyLib.name", someType);

  // Versioning projection remains unchanged
  const versionMutators = getVersioningMutators(context.program, service.type);
  // ...
}

Backward compatibility

  • getDataDecoratorValue(program, fqn, type) without a scope returns the unscoped default — identical to today's behavior.
  • Existing emitters that don't use when features work unchanged.
  • The scope parameter is additive — no breaking changes.

5. IDE / Tooling Support

  • Syntax highlighting: when keyword colored as a keyword.
  • Completions: After when, suggest filter predicates (emitter, language, target, since, between). Inside filter args, suggest known emitter names / versions.
  • Hover info: On a scoped decorator, show which scopes it applies to.
  • Diagnostics: Error on when with non-metadata decorators. Warning on overlapping conditions.
  • Formatter: Formatting rules for when clauses (trailing, block form).
  • Playground: Scope selector to switch between emitter/version views.

Implementation Phases

Phase 1: Scoped Metadata Decorators

Depends on: Data Decorators PR (#10197)

  1. Add when keyword to scanner
  2. Add WhenClause AST node
  3. Parser: trailing when on decorator expressions
  4. Checker: validate when only on data/pure extern decorators
  5. Extend data decorator auto-storage with condition metadata
  6. Add scope-aware getDataDecoratorValue overload
  7. Add EmitContext.createScope() API
  8. Formatter + syntax highlighting

Risk: Low. No type graph changes. Pure metadata filtering. Well-contained in the storage layer.

Phase 2: Version-Scoped when

  1. Parser: trailing when since(...)/when between(...) on statements
  2. Parser: block form when since(...) { ... }
  3. Binder: propagate version conditions from blocks to contained declarations
  4. Connect to existing versioning state (same state as @added/@removed)
  5. Versioning mutator: recognize when conditions alongside decorator-based versions

Risk: Low. Existing versioning mutator infrastructure handles projection. This is primarily a syntactic and binder change.

Phase 3: Visibility-Scoped Properties

  1. Parser: trailing when VisibilityEnum.member on property type
  2. Checker: allow same-name properties with non-overlapping when conditions
  3. Checker: compute union type for unconditioned view
  4. Mutator: visibility-based property filtering
  5. Optional: exhaustiveness checking for condition coverage

Risk: High. Changes fundamental model property semantics. Requires careful design of inheritance, spread, and template interactions.

Open Questions

  1. Restricted predicates vs. general expressions?
    Recommendation: Restricted initially (emitter, language, target, since, between, enum member refs). General expressions add complexity without clear use cases today.

  2. User-extensible filter dimensions?
    Can libraries define new when predicates beyond the built-in ones? Recommendation: Defer. Start with built-in dimensions; evaluate extensibility based on demand.

  3. Conflict resolution for overlapping scoped decorators?
    Two @name decorators with overlapping conditions on the same type. Recommendation: Compiler error on overlap.

  4. Unscoped decorator as default?
    @name("Bar") + @name("CsBar") when emitter("csharp") — unscoped is default, scoped overrides. Recommendation: Yes.

  5. Unconditioned type graph accessible?
    For validation tools that need to see everything. Recommendation: Yes — querying without a scope returns the merged/unconditioned view.

  6. Template interaction?
    model Foo<T> { prop: T when Lifecycle.read; } — condition is part of the template body, evaluated after instantiation.

Appendix: Feasibility Summary

Area Feasibility Complexity Risk
Parser / syntax High Medium Low
Scoped metadata decorators High Low–Medium Low
Visibility-scoped properties Medium Very High High
Version when High Medium Low
Projection system High Medium Low
Emitter API High Low Low
IDE / tooling High Medium Low

The feature is feasible. The data/pure decorator constraint transforms what would be a high-risk compiler overhaul into an incremental, well-contained enhancement. Phase 1 (scoped metadata decorators) is self-contained and low risk. The hardest part — visibility-scoped properties (Phase 3) — can be tackled independently and later.

Metadata

Metadata

Assignees

No one assigned

    Labels

    compiler:coreIssues for @typespec/compilerdesign:proposedProposal has been added and ready for discussionfeatureNew feature or requesttriaged:core

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions