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:
data decorators (from the Data Decorators proposal) — no JS implementation, compiler auto-stores arguments.
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)
- Add
when keyword to scanner
- Add
WhenClause AST node
- Parser: trailing
when on decorator expressions
- Checker: validate
when only on data/pure extern decorators
- Extend data decorator auto-storage with condition metadata
- Add scope-aware
getDataDecoratorValue overload
- Add
EmitContext.createScope() API
- Formatter + syntax highlighting
Risk: Low. No type graph changes. Pure metadata filtering. Well-contained in the storage layer.
Phase 2: Version-Scoped when
- Parser: trailing
when since(...)/when between(...) on statements
- Parser: block form
when since(...) { ... }
- Binder: propagate version conditions from blocks to contained declarations
- Connect to existing versioning state (same state as
@added/@removed)
- 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
- Parser: trailing
when VisibilityEnum.member on property type
- Checker: allow same-name properties with non-overlapping
when conditions
- Checker: compute union type for unconditioned view
- Mutator: visibility-based property filtering
- Optional: exhaustiveness checking for condition coverage
Risk: High. Changes fundamental model property semantics. Requires careful design of inheritance, spread, and template interactions.
Open Questions
-
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.
-
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.
-
Conflict resolution for overlapping scoped decorators?
Two @name decorators with overlapping conditions on the same type. Recommendation: Compiler error on overlap.
-
Unscoped decorator as default?
@name("Bar") + @name("CsBar") when emitter("csharp") — unscoped is default, scoped overrides. Recommendation: Yes.
-
Unconditioned type graph accessible?
For validation tools that need to see everything. Recommendation: Yes — querying without a scope returns the merged/unconditioned view.
-
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.
Design:
whenKeyword for Conditional ScopingSummary
This document proposes a
whenkeyword 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:
@added/@removeddecorator family stores version info in state maps; the versioning mutator projects the type graph per version.@visibilitydecorator 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
whenkeyword unifies them under a single language construct.Proposed Syntax
Emitter-scoped decorators
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
In the unconditioned type graph the checker represents
nameasstring | 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:
whenon Decorators Limited to Metadata DecoratorsThe
whenclause on decorators is restricted to decorators that are guaranteed to be side-effect-free metadata annotations. This includes:datadecorators (from the Data Decorators proposal) — no JS implementation, compiler auto-stores arguments.pure externdecorators — have a JS implementation but are marked as side-effect-free by the author.Plain
extern decdecorators (which may have arbitrary side effects) cannot usewhenclauses.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:
finishType()completes. Deferred decorators would violate this.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
wheneligibledata dec foo(...)pure extern dec foo(...)extern dec foo(...)Modifier Naming:
purefor Side-Effect-Freeextern decThe key semantic contract for
pureis: "this decorator's JS implementation only stores metadata (callscontext.program.stateMap().set()or equivalent). It does not mutate type structure, affect checking, or have ordering-dependent side effects."Options considered
pure✅pure extern dec foo(...)metadatametadata extern dec foo(...)readonlyreadonly extern dec foo(...)safesafe extern dec foo(...)constconst extern dec foo(...)constdeclarationsstablestable extern dec foo(...)passivepassive extern dec foo(...)declarativedeclarative extern dec foo(...)Recommendation:
pure__attribute__((pure))), and Solidity for "no side effects."extern.Enforcement
pureis an author-declared contract (similar to howreadonlyis 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
whenis added to the scanner as a contextual keyword (usable as an identifier in non-ambiguous positions).New AST nodes
A
WhenClausenode containing a condition expression, attachable to:@decorator(args) when conditionmodel Foo {} when conditionprop: Type when conditionwhen condition { ...statements }Condition expression grammar
Initially restricted to a known set of filter predicates:
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 thewhenclause to each statement inside the block.Design issues
model Foo {} when condcould be confused with a new statement starting withwhen. The parser resolves this by treatingwhenas trailing when it follows a closing}or declaration.whenblocks can appear inside namespaces. Nestedwhenblocks compose (AND semantics).2. Checker / Type System
2a. Scoped metadata decorators
Storage model: When a metadata decorator (
dataorpure extern) has awhenclause, the compiler extends the auto-storage to include condition metadata:Validation: The checker still validates all decorator arguments at check time, regardless of the
whenclause. Only the storage is conditioned, not the validation.Query API: The generic data decorator accessors gain a scope-aware overload:
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-datadecorator has awhenclause, the checker emits:2b. Visibility-scoped properties
A property can have multiple type variants conditioned on visibility:
Checker behavior:
whencondition.Issues:
is/extendspropagate 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/@removedandwhen 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.
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: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:
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
Backward compatibility
getDataDecoratorValue(program, fqn, type)without a scope returns the unscoped default — identical to today's behavior.whenfeatures work unchanged.5. IDE / Tooling Support
whenkeyword colored as a keyword.when, suggest filter predicates (emitter,language,target,since,between). Inside filter args, suggest known emitter names / versions.whenwith non-metadata decorators. Warning on overlapping conditions.whenclauses (trailing, block form).Implementation Phases
Phase 1: Scoped Metadata Decorators
Depends on: Data Decorators PR (#10197)
whenkeyword to scannerWhenClauseAST nodewhenon decorator expressionswhenonly ondata/pure externdecoratorsgetDataDecoratorValueoverloadEmitContext.createScope()APIRisk: Low. No type graph changes. Pure metadata filtering. Well-contained in the storage layer.
Phase 2: Version-Scoped
whenwhen since(...)/when between(...)on statementswhen since(...) { ... }@added/@removed)whenconditions alongside decorator-based versionsRisk: Low. Existing versioning mutator infrastructure handles projection. This is primarily a syntactic and binder change.
Phase 3: Visibility-Scoped Properties
when VisibilityEnum.memberon property typewhenconditionsRisk: High. Changes fundamental model property semantics. Requires careful design of inheritance, spread, and template interactions.
Open Questions
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.User-extensible filter dimensions?
Can libraries define new
whenpredicates beyond the built-in ones? Recommendation: Defer. Start with built-in dimensions; evaluate extensibility based on demand.Conflict resolution for overlapping scoped decorators?
Two
@namedecorators with overlapping conditions on the same type. Recommendation: Compiler error on overlap.Unscoped decorator as default?
@name("Bar")+@name("CsBar") when emitter("csharp")— unscoped is default, scoped overrides. Recommendation: Yes.Unconditioned type graph accessible?
For validation tools that need to see everything. Recommendation: Yes — querying without a scope returns the merged/unconditioned view.
Template interaction?
model Foo<T> { prop: T when Lifecycle.read; }— condition is part of the template body, evaluated after instantiation.Appendix: Feasibility Summary
whenThe feature is feasible. The
data/puredecorator 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.