Skip to content
Merged
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/ef-declaration-overrides-2026-8-5.md
Original file line number Diff line number Diff line change
@@ -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.Declaration {...props.declarationProps} name="Identifier" />
) : (
props.default
),
});
```
Original file line number Diff line number Diff line change
Expand Up @@ -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<TCustomType extends Type> {
Expand Down Expand Up @@ -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<string, any>;

export interface Experimental_OverrideDeclareProps<
TCustomType extends Type,
TDeclarationProps = Experimental_DefaultDeclarationProps,
> extends Experimental_OverrideEmitPropsBase<TCustomType> {
Declaration: ComponentDefinition<Experimental_CustomTypeToProps<TCustomType>>;
declarationProps: Experimental_CustomTypeToProps<TCustomType>;
/**
* The component that produces the default declaration. Call it with (a modified copy of)
* {@link declarationProps} to reuse the framework's rendering.
*/
Declaration: ComponentDefinition<TDeclarationProps>;
/** The props the framework would have used to render the declaration. */
declarationProps: TDeclarationProps;
}

export type Experimental_OverrideDeclarationComponent<TCustomType extends Type> =
ComponentDefinition<Experimental_OverrideDeclareProps<TCustomType>>;
export type Experimental_OverrideDeclarationComponent<
TCustomType extends Type,
TDeclarationProps = Experimental_DefaultDeclarationProps,
> = ComponentDefinition<Experimental_OverrideDeclareProps<TCustomType, TDeclarationProps>>;

export type Experimental_OverrideReferenceComponent<TCustomType extends Type> = ComponentDefinition<
Experimental_OverrideReferenceProps<TCustomType>
>;

export interface Experimental_ComponentOverridesConfigBase<TCustomType extends Type> {
export interface Experimental_ComponentOverridesConfigBase<
TCustomType extends Type,
TDeclarationProps = Experimental_DefaultDeclarationProps,
> {
/**
* Override when this type is referenced.
* e.g. When used in <TypeExpression type={type} />
*/
reference?: Experimental_OverrideReferenceComponent<TCustomType>;

/**
* Override when this type is declared.
* e.g. When used in <ClassDeclaration type={type} />
*/
declaration?: Experimental_OverrideDeclarationComponent<TCustomType, TDeclarationProps>;
}

export interface Experimental_ComponentOverridesProps {
Expand Down Expand Up @@ -112,25 +136,65 @@ export interface Experimental_OverridableComponentReferenceProps<
member?: ModelProperty;
}

export type Experimental_OverridableComponentProps<T extends Type> =
Experimental_OverridableComponentReferenceProps<T>;
export interface Experimental_OverridableComponentDeclarationProps<
T extends Type,
TDeclarationProps,
> extends Experimental_OverrideTypeComponentCommonProps<T> {
/**
* Pass when rendering a declaration of the provided type or type kind.
*/
declaration: true;

/**
* The component that produces the default declaration.
*/
Declaration: ComponentDefinition<TDeclarationProps>;

/**
* The props the framework would have used to render the declaration.
*/
declarationProps: TDeclarationProps;
}

export type Experimental_OverridableComponentProps<T extends Type, TDeclarationProps = unknown> =
| Experimental_OverridableComponentReferenceProps<T>
| Experimental_OverridableComponentDeclarationProps<T, TDeclarationProps>;

export function Experimental_OverridableComponent<T extends Type>(
props: Experimental_OverridableComponentProps<T>,
export function Experimental_OverridableComponent<T extends Type, TDeclarationProps = unknown>(
props: Experimental_OverridableComponentProps<T, TDeclarationProps>,
) {
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 <CustomComponent type={props.type} member={props.member} default={props.children} />;
}
}

if ("reference" in props && props.reference && descriptor.reference) {
const CustomComponent = descriptor.reference;
return <CustomComponent type={props.type} member={props.member} default={props.children} />;
if ("declaration" in props && props.declaration) {
const CustomComponent = getOverrideComponent(
$.program,
props.type,
"declaration",
options.overrides,
);
if (CustomComponent) {
return (
<CustomComponent
type={props.type}
default={props.children}
Declaration={props.Declaration}
declarationProps={props.declarationProps}
/>
);
}
}

return <>{props.children}</>;
Expand Down
104 changes: 67 additions & 37 deletions packages/emitter-framework/src/core/components/overrides/config.ts
Original file line number Diff line number Diff line change
@@ -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<any, any>;

type OverrideComponent<K extends Experimental_OverrideKind> =
Experimental_ComponentOverridesConfigBase<any, any>[K];

export type Experimental_ComponentOverridesConfig = Experimental_ComponentOverridesClass;
export const Experimental_ComponentOverridesConfig = function () {
return new Experimental_ComponentOverridesClass();
Expand All @@ -14,72 +25,91 @@ export const Experimental_ComponentOverridesConfig = function () {
};

export class Experimental_ComponentOverridesClass {
#typeEmitOptions: Map<Type, Experimental_ComponentOverridesConfigBase<any>> = new Map();
#typeKindEmitOptions: Map<Type["kind"], Experimental_ComponentOverridesConfigBase<any>> =
#typeEmitOptions: Map<Type, Experimental_ComponentOverridesConfigBase<any, any>> = new Map();
#typeKindEmitOptions: Map<Type["kind"], Experimental_ComponentOverridesConfigBase<any, any>> =
new Map();

forType<const T extends Type>(type: T, options: Experimental_ComponentOverridesConfigBase<T>) {
forType<const T extends Type, TDeclarationProps = Experimental_DefaultDeclarationProps>(
type: T,
options: Experimental_ComponentOverridesConfigBase<T, TDeclarationProps>,
) {
this.#typeEmitOptions.set(type, options);

return this;
}

forTypeKind<const TKind extends Type["kind"]>(
forTypeKind<
const TKind extends Type["kind"],
TDeclarationProps = Experimental_DefaultDeclarationProps,
>(
typeKind: TKind,
options: Experimental_ComponentOverridesConfigBase<Extract<Type, { kind: TKind }>>,
options: Experimental_ComponentOverridesConfigBase<
Extract<Type, { kind: TKind }>,
TDeclarationProps
>,
) {
this.#typeKindEmitOptions.set(typeKind, options);

return this;
}

/**
* 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]<K extends Experimental_OverrideKind>(
program: Program,
type: Type,
overrideKind: K,
): OverrideComponent<K> {
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]<K extends Experimental_OverrideKind>(
typeKind: Type["kind"],
overrideKind: K,
): OverrideComponent<K> {
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<K extends Experimental_OverrideKind>(
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<K> {
return (
options?.[getOverrideForTypeSym](program, type, overrideKind) ??
options?.[getOverrideForTypeKindSym](type.kind, overrideKind)
);
}
Loading
Loading