microsoft / microsoft/typespec
Scopping/conditional system
- Dominant language
- Java
- Stars
- 5.9k
- Forks
- 394
- Avg merge
- 1d 23h
- Merged PRs (30d)
- 104
Description
# 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
```typespec
@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
```typespec
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
```typespec
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](https://github.com/Microsoft/typespec/pull/10197)) — 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:
```typescript
// 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:
```typespec
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.
```typescript
// 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
```
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
```typescript
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)](https://github.com/Microsoft/typespec/pull/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 { 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.
Contributor guide
Assessment
This issue has not been assessed yet.