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
8 changes: 8 additions & 0 deletions .chronus/changes/data-decorators-2026-2-30-17-38-53.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: internal
packages:
- "@typespec/html-program-viewer"
---

Data decorators
16 changes: 16 additions & 0 deletions .chronus/changes/data-decorators-2026-3-30.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Added `data` decorator modifier for declaring decorators that auto-store their arguments as metadata without requiring a JavaScript implementation.

```typespec
data dec label(target: Model, value: valueof string);

@label("my-model")
model Foo {}
```

Added compiler API `hasDataDecorator`, `getDataDecoratorValue`, and `getDataDecoratorTargets` for reading data decorator values by FQN.
7 changes: 7 additions & 0 deletions .chronus/changes/data-decorators-tspd-2026-3-30.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/tspd"
---

`tspd gen-extern-signature` now generates typed accessor functions for `data` decorators (e.g., `isMyFlag`, `getMyLabel`).
43 changes: 40 additions & 3 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Realm } from "../experimental/realm.js";
import { getDataDecoratorStateKey } from "../lib/data-decorator.js";
import { docFromCommentDecorator, getIndexer } from "../lib/intrinsic/decorators.js";
import { $ } from "../typekit/index.js";
import { DuplicateTracker } from "../utils/duplicate-tracker.js";
Expand Down Expand Up @@ -107,6 +108,7 @@ import {
ModelProperty,
ModelPropertyNode,
ModelStatementNode,
ModifierFlags,
Namespace,
NamespaceStatementNode,
NeverType,
Expand Down Expand Up @@ -2099,8 +2101,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
);
const name = node.id.sv;

const implementation = symbol.value;
if (implementation === undefined) {
const isData = (node.modifierFlags & ModifierFlags.Data) !== 0;
let implementation = symbol.value;
if (isData) {
implementation = createDataDecoratorImplementation(symbol, node);
} else if (implementation === undefined) {
reportCheckerDiagnostic(createDiagnostic({ code: "missing-implementation", target: node }));
}
const decoratorType: Decorator = createType({
Expand All @@ -2111,6 +2116,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
target: checkFunctionParameter(ctx, node.target, true),
parameters: node.parameters.map((param) => checkFunctionParameter(ctx, param, true)),
implementation: implementation ?? (() => {}),
declarationKind: isData ? "data" : "extern",
});

namespace.decoratorDeclarations.set(name, decoratorType);
Expand All @@ -2120,6 +2126,36 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
return decoratorType;
}

function createDataDecoratorImplementation(
symbol: Sym,
node: DecoratorDeclarationStatementNode,
): (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void {
const fqn = getFullyQualifiedSymbolName(symbol);
const stateKey = getDataDecoratorStateKey(fqn);
const paramNames = node.parameters.map((p) => p.id.sv);

if (paramNames.length === 0) {
// No args beyond target — store `true` as a boolean flag in stateMap
return (context: DecoratorContext, target: Type) => {
context.program.stateMap(stateKey).set(target, true);
};
} else if (paramNames.length === 1) {
// Single arg — store value directly
return (context: DecoratorContext, target: Type, value: unknown) => {
context.program.stateMap(stateKey).set(target, value);
};
} else {
// Multiple args — store as named record
return (context: DecoratorContext, target: Type, ...args: unknown[]) => {
const data: Record<string, unknown> = {};
for (let i = 0; i < paramNames.length; i++) {
data[paramNames[i]] = args[i];
}
context.program.stateMap(stateKey).set(target, data);
};
}
}

function checkFunctionDeclaration(
ctx: CheckContext,
node: FunctionDeclarationStatementNode,
Expand Down Expand Up @@ -5746,9 +5782,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
return undefined;
}

const impl = sym.value ?? symbolLinks.declaredType?.implementation;
return {
definition: symbolLinks.declaredType,
decorator: sym.value ?? ((...args: any[]) => {}),
decorator: impl ?? ((...args: any[]) => {}),
node: decNode,
args,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,8 @@ const diagnostics = {
messages: {
default: paramMessage`Modifier '${"modifier"}' is invalid.`,
"missing-required": paramMessage`Declaration of type '${"nodeKind"}' is missing required modifier '${"modifier"}'.`,
"missing-required-one-of": paramMessage`Declaration of type '${"nodeKind"}' is missing one of the required modifiers: ${"modifiers"}.`,
"mutually-exclusive": paramMessage`Modifiers '${"modifierA"}' and '${"modifierB"}' cannot be used together.`,
"not-allowed": paramMessage`Modifier '${"modifier"}' cannot be used on declarations of type '${"nodeKind"}'.`,
},
},
Expand Down
56 changes: 48 additions & 8 deletions packages/compiler/src/core/modifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { Declaration, Modifier, ModifierFlags, SyntaxKind } from "./types.js";
interface ModifierCompatibility {
/** A set of modifier flags that are allowed on the node type. */
readonly allowed: ModifierFlags;
/** A set of modifier flags that are _required_ on the node type. */
/** At least one of these modifier flags must be present. */
readonly required: ModifierFlags;
/** Pairs of modifier flags that cannot be used together. */
readonly mutuallyExclusive?: readonly [ModifierFlags, ModifierFlags][];
}

/**
Expand Down Expand Up @@ -44,7 +46,8 @@ const SYNTAX_MODIFIERS: Readonly<Record<Declaration["kind"], ModifierCompatibili
[SyntaxKind.ConstStatement]: DEFAULT_COMPATIBILITY,
[SyntaxKind.DecoratorDeclarationStatement]: {
allowed: ModifierFlags.All,
required: ModifierFlags.Extern,
required: ModifierFlags.Extern | ModifierFlags.Data,
mutuallyExclusive: [[ModifierFlags.Extern, ModifierFlags.Data]],
},
[SyntaxKind.FunctionDeclarationStatement]: {
allowed: ModifierFlags.All,
Expand Down Expand Up @@ -101,24 +104,54 @@ export function checkModifiers(program: Program, node: Declaration): boolean {
}
}

const missingRequiredModifiers = compatibility.required & ~node.modifierFlags;

if (missingRequiredModifiers) {
// There is at least one required modifier missing from this syntax node.
if (compatibility.required && !(node.modifierFlags & compatibility.required)) {
// None of the required modifiers are present.
isValid = false;

for (const missing of getNamesOfModifierFlags(missingRequiredModifiers)) {
const names = getNamesOfModifierFlags(compatibility.required);
if (names.length === 1) {
program.reportDiagnostic(
createDiagnostic({
code: "invalid-modifier",
messageId: "missing-required",
format: { modifier: missing, nodeKind: getDeclarationKindText(node.kind) },
format: { modifier: names[0], nodeKind: getDeclarationKindText(node.kind) },
target: node,
}),
);
} else {
program.reportDiagnostic(
createDiagnostic({
code: "invalid-modifier",
messageId: "missing-required-one-of",
format: {
modifiers: names.map((n) => `'${n}'`).join(" or "),
nodeKind: getDeclarationKindText(node.kind),
},
target: node,
}),
);
}
}

if (compatibility.mutuallyExclusive) {
for (const [a, b] of compatibility.mutuallyExclusive) {
if (node.modifierFlags & a && node.modifierFlags & b) {
isValid = false;

const nameA = getNamesOfModifierFlags(a)[0];
const nameB = getNamesOfModifierFlags(b)[0];
program.reportDiagnostic(
createDiagnostic({
code: "invalid-modifier",
messageId: "mutually-exclusive",
format: { modifierA: nameA, modifierB: nameB },
target: node,
}),
);
}
}
}

return isValid;
}

Expand Down Expand Up @@ -148,6 +181,8 @@ function modifierToFlag(modifier: Modifier): ModifierFlags {
return ModifierFlags.Extern;
case SyntaxKind.InternalKeyword:
return ModifierFlags.Internal;
case SyntaxKind.DataKeyword:
return ModifierFlags.Data;
default:
compilerAssert(false, `Unknown modifier kind: ${(modifier as Modifier).kind}`);
}
Expand All @@ -159,6 +194,8 @@ function getTextForModifier(modifier: Modifier): string {
return "extern";
case SyntaxKind.InternalKeyword:
return "internal";
case SyntaxKind.DataKeyword:
return "data";
default:
compilerAssert(false, `Unknown modifier kind: ${(modifier as Modifier).kind}`);
}
Expand All @@ -172,6 +209,9 @@ function getNamesOfModifierFlags(flags: ModifierFlags): string[] {
if (flags & ModifierFlags.Internal) {
names.push("internal");
}
if (flags & ModifierFlags.Data) {
names.push("data");
}
return names;
}

Expand Down
17 changes: 16 additions & 1 deletion packages/compiler/src/core/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
CallExpressionNode,
Comment,
ConstStatementNode,
DataKeywordNode,
Declaration,
DeclarationNode,
DecoratorDeclarationStatementNode,
Expand Down Expand Up @@ -461,6 +462,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
case Token.ConstKeyword:
case Token.ExternKeyword:
case Token.InternalKeyword:
case Token.DataKeyword:
case Token.FnKeyword:
case Token.DecKeyword:
item = parseDeclaration(pos, decorators, docs, directives);
Expand Down Expand Up @@ -532,6 +534,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
case Token.ConstKeyword:
case Token.ExternKeyword:
case Token.InternalKeyword:
case Token.DataKeyword:
case Token.FnKeyword:
case Token.DecKeyword:
item = parseDeclaration(pos, decorators, docs, directives);
Expand Down Expand Up @@ -1770,6 +1773,15 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
};
}

function parseDataKeyword(): DataKeywordNode {
const pos = tokenPos();
parseExpected(Token.DataKeyword);
return {
kind: SyntaxKind.DataKeyword,
...finishNode(pos),
};
}

function parseVoidKeyword(): VoidKeywordNode {
const pos = tokenPos();
parseExpected(Token.VoidKeyword);
Expand Down Expand Up @@ -2090,6 +2102,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
return parseExternKeyword();
case Token.InternalKeyword:
return parseInternalKeyword();
case Token.DataKeyword:
return parseDataKeyword();
default:
return undefined;
}
Expand All @@ -2101,7 +2115,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
): DecoratorDeclarationStatementNode {
const modifierFlags = modifiersToFlags(modifiers);
parseExpected(Token.DecKeyword);
const id = parseIdentifier();
const id = parseIdentifier({ allowReservedIdentifier: true });
const allParamListDetail = parseFunctionParameters();
let [target, ...parameters] = allParamListDetail.items;
if (target === undefined) {
Expand Down Expand Up @@ -3167,6 +3181,7 @@ export function visitChildren<T>(node: Node, cb: NodeCallback<T>): T | undefined
case SyntaxKind.NeverKeyword:
case SyntaxKind.ExternKeyword:
case SyntaxKind.InternalKeyword:
case SyntaxKind.DataKeyword:
case SyntaxKind.UnknownKeyword:
case SyntaxKind.JsSourceFile:
case SyntaxKind.JsNamespaceDeclaration:
Expand Down
3 changes: 3 additions & 0 deletions packages/compiler/src/core/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export enum Token {

ExternKeyword = __StartModifierKeyword,
InternalKeyword,
DataKeyword,

/** @internal */ __EndModifierKeyword,
///////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -310,6 +311,7 @@ export const TokenDisplay = getTokenDisplayTable([
[Token.NeverKeyword, "'never'"],
[Token.UnknownKeyword, "'unknown'"],
[Token.ExternKeyword, "'extern'"],
[Token.DataKeyword, "'data'"],

// Reserved keywords
[Token.StatemachineKeyword, "'statemachine'"],
Expand Down Expand Up @@ -383,6 +385,7 @@ export const Keywords: ReadonlyMap<string, Token> = new Map([
["never", Token.NeverKeyword],
["unknown", Token.UnknownKeyword],
["extern", Token.ExternKeyword],
["data", Token.DataKeyword],
["internal", Token.InternalKeyword],

// Reserved keywords
Expand Down
12 changes: 10 additions & 2 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,8 @@ export interface Decorator extends BaseType {
target: MixedFunctionParameter;
parameters: MixedFunctionParameter[];
implementation: (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void;
/** How this decorator was declared. */
declarationKind: "extern" | "data";
}

/**
Expand Down Expand Up @@ -1175,6 +1177,7 @@ export enum SyntaxKind {
CallExpression,
ScalarConstructor,
InternalKeyword,
DataKeyword,
FunctionTypeExpression,
}

Expand Down Expand Up @@ -1741,6 +1744,10 @@ export interface InternalKeywordNode extends BaseNode {
readonly kind: SyntaxKind.InternalKeyword;
}

export interface DataKeywordNode extends BaseNode {
readonly kind: SyntaxKind.DataKeyword;
}

export interface VoidKeywordNode extends BaseNode {
readonly kind: SyntaxKind.VoidKeyword;
}
Expand Down Expand Up @@ -1797,11 +1804,12 @@ export const enum ModifierFlags {
None,
Extern = 1 << 1,
Internal = 1 << 2,
Data = 1 << 3,

All = Extern | Internal,
All = Extern | Internal | Data,
}

export type Modifier = ExternKeywordNode | InternalKeywordNode;
export type Modifier = ExternKeywordNode | InternalKeywordNode | DataKeywordNode;

/**
* Represent a decorator declaration
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/formatter/print/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ export function printNode(
return "extern";
case SyntaxKind.InternalKeyword:
return "internal";
case SyntaxKind.DataKeyword:
return "data";
case SyntaxKind.VoidKeyword:
return "void";
case SyntaxKind.NeverKeyword:
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export { NodeHost } from "./core/node-host.js";
export { isNumeric, Numeric } from "./core/numeric.js";
export type { CompilerOptions } from "./core/options.js";
export { getPositionBeforeTrivia } from "./core/parser-utils.js";
export {
getDataDecoratorTargets,
getDataDecoratorValue,
hasDataDecorator,
} from "./lib/data-decorator.js";
export {
$defaultVisibility,
$discriminator,
Expand Down
Loading
Loading