diff --git a/.chronus/changes/ef-declaration-overrides-2026-8-5.md b/.chronus/changes/ef-declaration-overrides-2026-8-5.md
new file mode 100644
index 00000000000..e778b864941
--- /dev/null
+++ b/.chronus/changes/ef-declaration-overrides-2026-8-5.md
@@ -0,0 +1,22 @@
+---
+changeKind: feature
+packages:
+ - "@typespec/emitter-framework"
+---
+
+Support declaration overrides in `Experimental_ComponentOverrides`
+
+Only `reference` overrides were dispatched, so an emitter could customize how a type is referenced but not how it is declared, forcing it to fork the framework's declaration components. The C# `ClassDeclaration`, `Property` and `EnumDeclaration` now render through the override point.
+
+Override precedence is resolved per override kind, so a type-level override that only defines `reference` does not shadow a kind-level `declaration` override, and vice versa.
+
+```tsx
+const overrides = Experimental_ComponentOverridesConfig().forTypeKind("ModelProperty", {
+ declaration: (props) =>
+ props.type.name === "id" ? (
+
+ ) : (
+ props.default
+ ),
+});
+```
diff --git a/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx b/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx
index a56e352a756..a29e05ad61f 100644
--- a/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx
+++ b/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx
@@ -11,11 +11,7 @@ import type {
UnionVariant,
} from "@typespec/compiler";
import { useTsp } from "../../context/index.js";
-import {
- type Experimental_ComponentOverridesConfig,
- getOverrideForType,
- getOverridesForTypeKind,
-} from "./config.js";
+import { type Experimental_ComponentOverridesConfig, getOverrideComponent } from "./config.js";
import { type ComponentOverridesContext, OverridesContext, useOverrides } from "./context.js";
export interface Experimental_OverrideEmitPropsBase {
@@ -51,26 +47,54 @@ export interface Experimental_OverrideReferenceProps<
member?: ModelProperty;
}
+/**
+ * Fallback props type for declaration overrides.
+ *
+ * Declaration props are language specific (`cs.ClassDeclarationProps`, `ts.VarDeclarationProps`,
+ * ...) and cannot be derived from the TypeSpec type, so they default to a permissive record.
+ * Pass the concrete props type explicitly to
+ * {@link Experimental_ComponentOverridesClass.forType} /
+ * {@link Experimental_ComponentOverridesClass.forTypeKind} to get full type checking.
+ */
+export type Experimental_DefaultDeclarationProps = Record;
+
export interface Experimental_OverrideDeclareProps<
TCustomType extends Type,
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
> extends Experimental_OverrideEmitPropsBase {
- Declaration: ComponentDefinition>;
- declarationProps: Experimental_CustomTypeToProps;
+ /**
+ * The component that produces the default declaration. Call it with (a modified copy of)
+ * {@link declarationProps} to reuse the framework's rendering.
+ */
+ Declaration: ComponentDefinition;
+ /** The props the framework would have used to render the declaration. */
+ declarationProps: TDeclarationProps;
}
-export type Experimental_OverrideDeclarationComponent =
- ComponentDefinition>;
+export type Experimental_OverrideDeclarationComponent<
+ TCustomType extends Type,
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
+> = ComponentDefinition>;
export type Experimental_OverrideReferenceComponent = ComponentDefinition<
Experimental_OverrideReferenceProps
>;
-export interface Experimental_ComponentOverridesConfigBase {
+export interface Experimental_ComponentOverridesConfigBase<
+ TCustomType extends Type,
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
+> {
/**
* Override when this type is referenced.
* e.g. When used in
*/
reference?: Experimental_OverrideReferenceComponent;
+
+ /**
+ * Override when this type is declared.
+ * e.g. When used in
+ */
+ declaration?: Experimental_OverrideDeclarationComponent;
}
export interface Experimental_ComponentOverridesProps {
@@ -112,25 +136,65 @@ export interface Experimental_OverridableComponentReferenceProps<
member?: ModelProperty;
}
-export type Experimental_OverridableComponentProps =
- Experimental_OverridableComponentReferenceProps;
+export interface Experimental_OverridableComponentDeclarationProps<
+ T extends Type,
+ TDeclarationProps,
+> extends Experimental_OverrideTypeComponentCommonProps {
+ /**
+ * Pass when rendering a declaration of the provided type or type kind.
+ */
+ declaration: true;
+
+ /**
+ * The component that produces the default declaration.
+ */
+ Declaration: ComponentDefinition;
+
+ /**
+ * The props the framework would have used to render the declaration.
+ */
+ declarationProps: TDeclarationProps;
+}
+
+export type Experimental_OverridableComponentProps =
+ | Experimental_OverridableComponentReferenceProps
+ | Experimental_OverridableComponentDeclarationProps;
-export function Experimental_OverridableComponent(
- props: Experimental_OverridableComponentProps,
+export function Experimental_OverridableComponent(
+ props: Experimental_OverridableComponentProps,
) {
const options = useOverrides();
const { $ } = useTsp();
- const descriptor =
- getOverrideForType($.program, props.type, options.overrides) ??
- getOverridesForTypeKind($.program, props.type.kind, options.overrides);
- if (!descriptor) {
- return <>{props.children}>;
+ if ("reference" in props && props.reference) {
+ const CustomComponent = getOverrideComponent(
+ $.program,
+ props.type,
+ "reference",
+ options.overrides,
+ );
+ if (CustomComponent) {
+ return ;
+ }
}
- if ("reference" in props && props.reference && descriptor.reference) {
- const CustomComponent = descriptor.reference;
- return ;
+ if ("declaration" in props && props.declaration) {
+ const CustomComponent = getOverrideComponent(
+ $.program,
+ props.type,
+ "declaration",
+ options.overrides,
+ );
+ if (CustomComponent) {
+ return (
+
+ );
+ }
}
return <>{props.children}>;
diff --git a/packages/emitter-framework/src/core/components/overrides/config.ts b/packages/emitter-framework/src/core/components/overrides/config.ts
index ea7047f14e4..98f8f8b4e27 100644
--- a/packages/emitter-framework/src/core/components/overrides/config.ts
+++ b/packages/emitter-framework/src/core/components/overrides/config.ts
@@ -1,10 +1,21 @@
import type { Program, Scalar, Type } from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
-import type { Experimental_ComponentOverridesConfigBase } from "./component-overrides.jsx";
+import type {
+ Experimental_ComponentOverridesConfigBase,
+ Experimental_DefaultDeclarationProps,
+} from "./component-overrides.jsx";
const getOverrideForTypeSym: unique symbol = Symbol.for("ef-ts:getOverrideForType");
const getOverrideForTypeKindSym: unique symbol = Symbol.for("ef-ts:getOverrideForTypeKind");
+/**
+ * The ways a type can be rendered, and therefore overridden.
+ */
+export type Experimental_OverrideKind = keyof Experimental_ComponentOverridesConfigBase;
+
+type OverrideComponent =
+ Experimental_ComponentOverridesConfigBase[K];
+
export type Experimental_ComponentOverridesConfig = Experimental_ComponentOverridesClass;
export const Experimental_ComponentOverridesConfig = function () {
return new Experimental_ComponentOverridesClass();
@@ -14,19 +25,28 @@ export const Experimental_ComponentOverridesConfig = function () {
};
export class Experimental_ComponentOverridesClass {
- #typeEmitOptions: Map> = new Map();
- #typeKindEmitOptions: Map> =
+ #typeEmitOptions: Map> = new Map();
+ #typeKindEmitOptions: Map> =
new Map();
- forType(type: T, options: Experimental_ComponentOverridesConfigBase) {
+ forType(
+ type: T,
+ options: Experimental_ComponentOverridesConfigBase,
+ ) {
this.#typeEmitOptions.set(type, options);
return this;
}
- forTypeKind(
+ forTypeKind<
+ const TKind extends Type["kind"],
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
+ >(
typeKind: TKind,
- options: Experimental_ComponentOverridesConfigBase>,
+ options: Experimental_ComponentOverridesConfigBase<
+ Extract,
+ TDeclarationProps
+ >,
) {
this.#typeKindEmitOptions.set(typeKind, options);
@@ -34,52 +54,62 @@ export class Experimental_ComponentOverridesClass {
}
/**
+ * Look up the override for a single override kind, walking up the scalar hierarchy when the
+ * type is a scalar. Resolution is per override kind, so a derived scalar that only overrides
+ * `reference` does not hide a `declaration` override registered on its base scalar.
+ *
* @internal
*/
- [getOverrideForTypeSym](program: Program, type: Type) {
- const options = this.#typeEmitOptions.get(type);
- if (options || !$(program).scalar.is(type) /** || isBuiltIn(program, type) */) {
- return options;
- }
-
- // have a scalar, it's not a built-in scalar, and didn't find options, so
- // see if we have options for a base scalar.
- let currentScalar: Scalar | undefined = type;
- while (
- currentScalar &&
- // !isBuiltIn(program, currentScalar) &&
- !this.#typeEmitOptions.has(currentScalar)
- ) {
- currentScalar = currentScalar?.baseScalar;
+ [getOverrideForTypeSym](
+ program: Program,
+ type: Type,
+ overrideKind: K,
+ ): OverrideComponent {
+ const own = this.#typeEmitOptions.get(type)?.[overrideKind];
+ if (own || !$(program).scalar.is(type) /** || isBuiltIn(program, type) */) {
+ return own;
}
- if (!currentScalar) {
- return undefined;
+ // have a scalar, it's not a built-in scalar, and didn't find an override, so
+ // see if a base scalar has one.
+ let currentScalar: Scalar | undefined = type.baseScalar;
+ while (currentScalar /** && !isBuiltIn(program, currentScalar) */) {
+ const inherited = this.#typeEmitOptions.get(currentScalar)?.[overrideKind];
+ if (inherited) {
+ return inherited;
+ }
+ currentScalar = currentScalar.baseScalar;
}
- return this.#typeEmitOptions.get(currentScalar);
+ return undefined;
}
/**
* @internal
*/
- [getOverrideForTypeKindSym](program: Program, typeKind: Type["kind"]) {
- return this.#typeKindEmitOptions.get(typeKind);
+ [getOverrideForTypeKindSym](
+ typeKind: Type["kind"],
+ overrideKind: K,
+ ): OverrideComponent {
+ return this.#typeKindEmitOptions.get(typeKind)?.[overrideKind];
}
}
-export function getOverrideForType(
+/**
+ * Resolve the component that overrides how `type` is rendered for the given override kind.
+ *
+ * Precedence is resolved independently per override kind: a type-level override that only
+ * defines `reference` does not prevent a kind-level `declaration` override from applying, and
+ * vice versa.
+ */
+export function getOverrideComponent(
program: Program,
type: Type,
+ overrideKind: K,
options?: Experimental_ComponentOverridesConfig,
-) {
- return options?.[getOverrideForTypeSym](program, type);
-}
-
-export function getOverridesForTypeKind(
- program: Program,
- typeKind: Type["kind"],
- options?: Experimental_ComponentOverridesConfig,
-) {
- return options?.[getOverrideForTypeKindSym](program, typeKind);
+): OverrideComponent {
+ return (
+ options?.[getOverrideForTypeSym](program, type, overrideKind) ??
+ options?.[getOverrideForTypeKindSym](type.kind, overrideKind)
+ );
}
diff --git a/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx b/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx
index 3f008f01893..eabf1bbe681 100644
--- a/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx
+++ b/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx
@@ -359,3 +359,196 @@ describe("with doc comments", () => {
`);
});
});
+
+describe("declaration overrides", () => {
+ it("replaces a class declaration entirely", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {
+ Prop1: string;
+ }
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Model", {
+ declaration: () => "class Replaced {}",
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`class Replaced {}`);
+ });
+
+ it("re-renders the default declaration with modified props", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {}
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Model", {
+ declaration: (props) => (
+
+ ),
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`partial class Renamed {}`);
+ });
+
+ it("falls back to the default when only a reference override is configured", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {}
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Model", {
+ reference: () => "Nope",
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`class TestModel {}`);
+ });
+
+ it("overrides a property declaration", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {
+ Prop1: string;
+ Prop2: int32;
+ }
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("ModelProperty", {
+ declaration: (props) =>
+ props.type.name === "Prop1" ? "public string Custom { get; }" : props.default,
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(d`
+ class TestModel
+ {
+ public string Custom { get; }
+
+ public required int Prop2 { get; set; }
+ }
+ `);
+ });
+
+ it("overrides an enum declaration", async () => {
+ const { TestEnum } = await runner.compile(t.code`
+ enum ${t.enum("TestEnum")} {
+ A,
+ B,
+ }
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Enum", {
+ declaration: (props) => ,
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(d`
+ enum RenamedEnum
+ {
+ A,
+ B
+ }
+ `);
+ });
+});
+
+describe("override precedence", () => {
+ it("does not let a type-level reference override hide a kind-level declaration override", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {}
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig()
+ .forType(TestModel, { reference: () => "Unrelated" })
+ .forTypeKind("Model", {
+ declaration: (props) => ,
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`class Renamed {}`);
+ });
+
+ it("does not let a type-level declaration override hide a kind-level reference override", async () => {
+ const { Container, TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {}
+ model ${t.model("Container")} {
+ prop: TestModel;
+ }
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig()
+ .forType(TestModel, { declaration: () => "unreachable" })
+ .forTypeKind("Model", { reference: () => "Referenced" });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(d`
+ class Container
+ {
+ public required Referenced Prop { get; set; }
+ }
+ `);
+ });
+
+ it("resolves each override kind independently up the scalar hierarchy", async () => {
+ const { Container, myBase, myDerived } = await runner.compile(t.code`
+ scalar ${t.scalar("myBase")} extends string;
+ scalar ${t.scalar("myDerived")} extends myBase;
+ model ${t.model("Container")} {
+ prop: myDerived;
+ }
+ `);
+
+ // `myDerived` only overrides `declaration`, so the `reference` override on its base
+ // scalar must still be found.
+ const overrides = Experimental_ComponentOverridesConfig()
+ .forType(myDerived, { declaration: () => "unreachable" })
+ .forType(myBase, { reference: () => "BaseRef" });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(d`
+ class Container
+ {
+ public required BaseRef Prop { get; set; }
+ }
+ `);
+ });
+});
diff --git a/packages/emitter-framework/src/csharp/components/class/declaration.tsx b/packages/emitter-framework/src/csharp/components/class/declaration.tsx
index bade989ad42..65c109690c0 100644
--- a/packages/emitter-framework/src/csharp/components/class/declaration.tsx
+++ b/packages/emitter-framework/src/csharp/components/class/declaration.tsx
@@ -2,7 +2,7 @@ import { For, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
import type { Interface, Model } from "@typespec/compiler";
import { isVoidType } from "@typespec/compiler";
-import { useTsp } from "../../../core/index.js";
+import { Experimental_OverridableComponent, useTsp } from "../../../core/index.js";
import { Property } from "../property/property.jsx";
import { TypeExpression } from "../type-expression.jsx";
import { getDocComments } from "../utils/doc-comments.jsx";
@@ -28,6 +28,19 @@ interface ClassMethodsProps {
}
export function ClassDeclaration(props: ClassDeclarationProps): Children {
+ return (
+
+
+
+ );
+}
+
+function ClassDeclarationBody(props: ClassDeclarationProps): Children {
const { $ } = useTsp();
const namePolicy = cs.useCSharpNamePolicy();
diff --git a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
index e49a5474c47..9e57afbbd1f 100644
--- a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
+++ b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
@@ -1,4 +1,4 @@
-import { useTsp } from "#core/context/tsp-context.js";
+import { Experimental_OverridableComponent, useTsp } from "#core/index.js";
import { type Children, For } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
import type { Enum, Union } from "@typespec/compiler";
@@ -12,6 +12,19 @@ export interface EnumDeclarationProps extends Omit
+
+
+ );
+}
+
+function EnumDeclarationBody(props: EnumDeclarationProps): Children {
const { $ } = useTsp();
let type: Enum;
if ($.union.is(props.type)) {
diff --git a/packages/emitter-framework/src/csharp/components/property/property.tsx b/packages/emitter-framework/src/csharp/components/property/property.tsx
index 8863892e06f..7030e0e408f 100644
--- a/packages/emitter-framework/src/csharp/components/property/property.tsx
+++ b/packages/emitter-framework/src/csharp/components/property/property.tsx
@@ -9,7 +9,7 @@ import {
type ModelProperty,
type Type,
} from "@typespec/compiler";
-import { useTsp } from "../../../core/index.js";
+import { Experimental_OverridableComponent, useTsp } from "../../../core/index.js";
import { useJsonConverterResolver } from "../json-converter/json-converter-resolver.jsx";
import { TypeExpression } from "../type-expression.jsx";
import { getDocComments } from "../utils/doc-comments.jsx";
@@ -28,6 +28,19 @@ export interface PropertyProps {
* Create a C# property declaration from a TypeSpec property type.
*/
export function Property(props: PropertyProps): Children {
+ return (
+
+
+
+ );
+}
+
+function PropertyBody(props: PropertyProps): Children {
const { $ } = useTsp();
const result = preprocessPropertyType(props.type);