Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .chronus/changes/scoped-decorators-when-clause-2026-1-15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add experimental `when` clauses on `auto` decorator applications, letting a single spec carry different metadata per emitter, language, or target. Enable with the `scoped-decorators` feature flag.

```tsp
@clientName("Widget") when language("csharp") | language("java")
@clientName("widget") when language("python")
@clientName("Thing")
model Widget {}
```

Emitters read the value for their own scope; `EmitContext.scope` is prefilled with the emitter's package name and `EmitContext.createScope()` narrows it:

```ts
const value = getAutoDecoratorValue(program, "MyLib.clientName", model, context.scope);
```

Decorator arguments are still validated in every scope — only the stored value is conditioned — and the unscoped `getAutoDecoratorValue(program, fqn, target)` overload is unchanged.
30 changes: 30 additions & 0 deletions grammars/typespec.json
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,9 @@
}
},
"patterns": [
{
"include": "#when-clause"
},
{
"include": "#model-property"
},
Expand Down Expand Up @@ -803,6 +806,9 @@
{
"include": "#token"
},
{
"include": "#when-clause"
},
{
"include": "#decorator"
},
Expand Down Expand Up @@ -1017,6 +1023,9 @@
{
"include": "#directive"
},
{
"include": "#when-clause"
},
{
"include": "#augment-decorator-statement"
},
Expand Down Expand Up @@ -1458,6 +1467,27 @@
"include": "#expression"
}
]
},
"when-clause": {
"name": "meta.when-clause.typespec",
"begin": "\\b(when)\\b",
"beginCaptures": {
"1": {
"name": "keyword.other.tsp"
}
},
"end": "(?=(?:\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)\\s*\\??\\s*:)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)",
"patterns": [
{
"include": "#token"
},
{
"include": "#parenthesized-expression"
},
{
"include": "#identifier-expression"
}
]
}
}
}
95 changes: 78 additions & 17 deletions packages/compiler/src/core/auto-decorator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT License.

import { Realm } from "../experimental/realm.js";
import { validateDecoratorUniqueOnNode } from "./decorator-utils.js";
import type { Program } from "./program.js";
import {
addScopedDecoratorEntry,
getScopedDecoratorEntries,
resolveScopedDecoratorValue,
type Scope,
type ScopeConditionSet,
} from "./scope.js";
import { getFullyQualifiedSymbolName } from "./type-utils.js";
import type { DecoratorContext, DecoratorDeclarationStatementNode, Sym, Type } from "./types.js";

Expand All @@ -16,6 +24,33 @@ export function getAutoDecoratorStateKey(decoratorFqn: string): symbol {
return Symbol.for(`dec:${decoratorFqn}`);
}

/**
* Build the `{ paramName: value }` record an auto decorator stores from its arguments.
* @internal
*/
export function buildAutoDecoratorData(
node: DecoratorDeclarationStatementNode,
args: unknown[],
): Record<string, unknown> {
const paramNames = node.parameters.map((p) => p.id.sv);
const lastParamIsRest =
node.parameters.length > 0 && node.parameters[node.parameters.length - 1].rest;

const data: Record<string, unknown> = {};
if (lastParamIsRest) {
for (let i = 0; i < paramNames.length - 1; i++) {
data[paramNames[i]] = args[i];
}
// The rest parameter collects all remaining arguments into an array.
data[paramNames[paramNames.length - 1]] = args.slice(paramNames.length - 1);
} else {
for (let i = 0; i < paramNames.length; i++) {
data[paramNames[i]] = args[i];
}
}
return data;
}

/**
* Build the auto-generated implementation for an `auto dec` declaration.
*
Expand All @@ -29,9 +64,6 @@ export function createAutoDecoratorImplementation(
node: DecoratorDeclarationStatementNode,
): (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void {
const fqn = getFullyQualifiedSymbolName(symbol);
const paramNames = node.parameters.map((p) => p.id.sv);
const lastParamIsRest =
node.parameters.length > 0 && node.parameters[node.parameters.length - 1].rest;

const impl = (context: DecoratorContext, target: Type, ...args: unknown[]) => {
// Warn (but still store, so duplicates are last-write-wins like extern
Expand All @@ -40,26 +72,38 @@ export function createAutoDecoratorImplementation(
validateDecoratorUniqueOnNode(context, target, impl);
}

const data: Record<string, unknown> = {};
if (lastParamIsRest) {
for (let i = 0; i < paramNames.length - 1; i++) {
data[paramNames[i]] = args[i];
}
// The rest parameter collects all remaining arguments into an array.
data[paramNames[paramNames.length - 1]] = args.slice(paramNames.length - 1);
} else {
for (let i = 0; i < paramNames.length; i++) {
data[paramNames[i]] = args[i];
}
}
setAutoDecorator(context.program, fqn, target, data);
setAutoDecorator(context.program, fqn, target, buildAutoDecoratorData(node, args));
};
// The function name drives the `@<name>` text in the duplicate-decorator
// diagnostic; mirror the extern `$name` convention so the helper strips it.
Object.defineProperty(impl, "name", { value: `$${node.id.sv}` });
return impl;
}

/**
* Build the implementation for a *conditioned* (`when`-scoped) `auto dec` application.
*
* Unlike the unscoped implementation this does not warn on duplicates: several scoped
* applications on the same target are the whole point of the feature. The value is written
* to a separate state map so the unscoped `getAutoDecoratorValue` contract is unaffected.
* @internal
*/
export function createScopedAutoDecoratorImplementation(
symbol: Sym,
node: DecoratorDeclarationStatementNode,
scope: ScopeConditionSet,
): (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void {
const fqn = getFullyQualifiedSymbolName(symbol);
const impl = (context: DecoratorContext, target: Type, ...args: unknown[]) => {
addScopedDecoratorEntry(context.program, fqn, target, {
value: buildAutoDecoratorData(node, args),
scope,
});
};
Object.defineProperty(impl, "name", { value: `$${node.id.sv}` });
return impl;
}

/**
* Programmatically apply an auto decorator to a target, storing its argument values.
*
Expand Down Expand Up @@ -98,15 +142,32 @@ export function hasAutoDecorator(program: Program, decoratorFqn: string, target:
* @param program - The current program.
* @param decoratorFqn - The fully-qualified name of the decorator (e.g., "MyLib.myDec").
* @param target - The type to get the value for.
* @param scope - Optional scope used to resolve `when`-conditioned applications. A scoped
* application whose condition matches takes precedence over the unscoped value.
* @returns The stored record, or `undefined` if the decorator was not applied.
*/
export function getAutoDecoratorValue(
program: Program,
decoratorFqn: string,
target: Type,
scope?: Scope,
): Record<string, unknown> | undefined {
// Realm state maps only resolve state for types the realm owns, so a clone carries none of
// the state recorded against the type it was cloned from. Walk back to that source type.
const resolved = Realm.sourceOf(target);

if (scope !== undefined) {
const entries = getScopedDecoratorEntries(program, decoratorFqn, resolved);
if (entries !== undefined) {
const value = resolveScopedDecoratorValue(entries, scope);
if (value !== undefined) {
return value;
}
}
}

const key = getAutoDecoratorStateKey(decoratorFqn);
return program.stateMap(key).get(target) as Record<string, unknown> | undefined;
return program.stateMap(key).get(resolved) as Record<string, unknown> | undefined;
}

/**
Expand Down
Loading
Loading