From 1071e340a6efc00fae9592126d8d624c00d7f44d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 7 Aug 2026 17:16:46 -0400 Subject: [PATCH 1/3] fix(emitter-framework): make C# TypeExpression handle all type kinds Adds handling for Tuple, StringTemplate, EnumMember, ModelProperty, UnionVariant, template parameters and the full Intrinsic set. An unsupported type now reports a diagnostic and falls back to `object` instead of throwing. Also fixes the C# components reporting a TypeScript diagnostic for unsupported scalars, and corrects the C# expressions emitted for the `null` and `never` intrinsics. Adds an `isCSharpValueType` util. --- ...f-csharp-type-expression-total-2026-8-5.md | 9 ++ .../components/type-expression.test.tsx | 52 ++++++++ .../src/csharp/components/type-expression.tsx | 124 +++++++++++------- .../src/csharp/components/utils/index.ts | 1 + .../src/csharp/components/utils/value-type.ts | 64 +++++++++ packages/emitter-framework/src/lib.ts | 14 ++ 6 files changed, 214 insertions(+), 50 deletions(-) create mode 100644 .chronus/changes/ef-csharp-type-expression-total-2026-8-5.md create mode 100644 packages/emitter-framework/src/csharp/components/utils/value-type.ts diff --git a/.chronus/changes/ef-csharp-type-expression-total-2026-8-5.md b/.chronus/changes/ef-csharp-type-expression-total-2026-8-5.md new file mode 100644 index 00000000000..5890ab4c847 --- /dev/null +++ b/.chronus/changes/ef-csharp-type-expression-total-2026-8-5.md @@ -0,0 +1,9 @@ +--- +changeKind: fix +packages: + - "@typespec/emitter-framework" +--- + +Make the C# `TypeExpression` handle every type kind instead of throwing + +`Tuple`, `StringTemplate`, `EnumMember`, `ModelProperty`, `UnionVariant`, template parameters and the full `Intrinsic` set are now supported, and an unsupported type reports a diagnostic and falls back to `object` rather than throwing. Also fixes the C# components reporting a TypeScript diagnostic for unsupported scalars, and corrects the C# expressions for the `null` and `never` intrinsics. diff --git a/packages/emitter-framework/src/csharp/components/type-expression.test.tsx b/packages/emitter-framework/src/csharp/components/type-expression.test.tsx index 8a6354aa648..c9d9e5f498c 100644 --- a/packages/emitter-framework/src/csharp/components/type-expression.test.tsx +++ b/packages/emitter-framework/src/csharp/components/type-expression.test.tsx @@ -6,6 +6,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; import { Output } from "../../core/index.js"; import { ClassDeclaration } from "./class/declaration.js"; +import { EnumDeclaration } from "./enum/declaration.jsx"; import { TypeExpression } from "./type-expression.jsx"; let runner: TesterInstance; @@ -143,3 +144,54 @@ describe("Literal types", () => { `); }); }); + +describe("types with no direct C# equivalent", () => { + it.each([ + ["string template", "string", `"a-\${string}"`], + ["tuple", "int[]", "[int32, int32]"], + ["unknown", "object", "unknown"], + ["void", "void", "void"], + ["never", "void", "never"], + ["null", "object", "null"], + ])("%s => %s", async (_label, csType, tspType) => { + const type = await compileType(tspType); + expect( + + + , + ).toRenderTo(csType); + }); + + it("falls back to object instead of throwing", async () => { + const type = await compileType("int32 | boolean"); + expect( + + + , + ).toRenderTo("object"); + }); +}); + +it("renders an enum member using the enum it belongs to", async () => { + const { test, Color } = await runner.compile(t.code` + enum ${t.enum("Color")} { red, blue } + model Test { + ${t.modelProperty("test")}: Color.red; + } + `); + + expect( + + + + + , + ).toRenderTo(` + enum Color + { + red, + blue + } + Color + `); +}); diff --git a/packages/emitter-framework/src/csharp/components/type-expression.tsx b/packages/emitter-framework/src/csharp/components/type-expression.tsx index d4a1932ea30..bd9d480f00a 100644 --- a/packages/emitter-framework/src/csharp/components/type-expression.tsx +++ b/packages/emitter-framework/src/csharp/components/type-expression.tsx @@ -1,16 +1,10 @@ import { Experimental_OverridableComponent } from "#core/index.js"; import { code, type Children } from "@alloy-js/core"; import { Reference } from "@alloy-js/csharp"; -import { - getTypeName, - isVoidType, - type IntrinsicType, - type Scalar, - type Type, -} from "@typespec/compiler"; +import { getTypeName, type IntrinsicType, type Scalar, type Type } from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; import { useTsp } from "../../core/index.js"; -import { reportTypescriptDiagnostic } from "../../typescript/lib.js"; +import { reportDiagnostic } from "../../lib.js"; import { getNullableUnionInnerType } from "./utils/nullable-util.js"; import { efRefkey } from "./utils/refkey.js"; @@ -21,50 +15,86 @@ export interface TypeExpressionProps { export function TypeExpression(props: TypeExpressionProps): Children { return ( - {() => { - if (props.type.kind === "Union") { - const nullabletype = getNullableUnionInnerType(props.type); - if (nullabletype) { - return code`${()}?`; - } - } - const { $ } = useTsp(); - if (isDeclaration($, props.type)) { - return ; - } - if ($.scalar.is(props.type)) { - return getScalarIntrinsicExpression($, props.type); - } else if ($.array.is(props.type)) { - return code`${()}[]`; - } else if ($.record.is(props.type)) { - return code`IDictionary)}>`; - } else if ($.literal.isString(props.type)) { - // c# doesn't have literal types, so we map them to their corresponding C# types in general - return code`string`; - } else if ($.literal.isNumeric(props.type)) { - return Number.isInteger(props.type.value) ? code`int` : code`double`; - } else if ($.literal.isBoolean(props.type)) { - return code`bool`; - } else if (isVoidType(props.type)) { - return code`void`; - } - - throw new Error( - `Unsupported type for TypeExpression: ${props.type.kind} (${getTypeName(props.type)})`, - ); - }} + {() => } ); } +/** + * Resolves a TypeSpec type to the C# type expression that represents it. + * + * This never throws: type kinds with no C# equivalent report a diagnostic and fall back to + * `object`, so that a single unsupported type does not abort the whole emit. + */ +function TypeExpressionBody(props: TypeExpressionProps): Children { + const { $ } = useTsp(); + const type = props.type; + + switch (type.kind) { + // Wrappers that carry the type we actually want to render. + case "ModelProperty": + case "UnionVariant": + return ; + + // C# has no way to type something as one specific enum member, so a property typed + // `kind: Color.red` is rendered using the enum the member belongs to. + case "EnumMember": + return ; + + case "Union": { + const innerType = getNullableUnionInnerType(type); + if (innerType) { + return code`${()}?`; + } + break; // Named unions are declarations; anything else falls through. + } + + // C# has no tuple-of-values type; an array of the element type is the closest match. + case "Tuple": + return type.values.length > 0 + ? code`${()}[]` + : code`object[]`; + + case "StringTemplate": + return "string"; + + case "TemplateParameter": + return getTypeName(type); + + case "Intrinsic": + return getScalarIntrinsicExpression($, type); + } + + if (isDeclaration($, type)) { + return ; + } + if ($.scalar.is(type)) { + return getScalarIntrinsicExpression($, type); + } else if ($.array.is(type)) { + return code`${()}[]`; + } else if ($.record.is(type)) { + return code`IDictionary)}>`; + } else if ($.literal.isString(type)) { + // c# doesn't have literal types, so we map them to their corresponding C# types in general + return code`string`; + } else if ($.literal.isNumeric(type)) { + return Number.isInteger(type.value) ? code`int` : code`double`; + } else if ($.literal.isBoolean(type)) { + return code`bool`; + } + + reportDiagnostic($.program, { code: "csharp-unsupported-type", target: type }); + return "object"; +} + const intrinsicNameToCSharpType = new Map([ // Core types ["unknown", "object"], // Matches C#'s `object` ["string", "string"], // Matches C#'s `string` ["boolean", "bool"], // Matches C#'s `bool` - ["null", "null"], // Matches C#'s `null` + ["null", "object"], // C# has no null type; `object` is the only thing null inhabits ["void", "void"], // Matches C#'s `void` - ["never", null], // No direct equivalent in C# + ["never", "void"], // C# has no bottom type; `void` is the closest equivalent ["bytes", "byte[]"], // Matches C#'s `byte[]` // Numeric types @@ -96,10 +126,7 @@ const intrinsicNameToCSharpType = new Map([ ["url", "Uri"], // Matches C#'s `Uri` ]); -export function getScalarIntrinsicExpression( - $: Typekit, - type: Scalar | IntrinsicType, -): string | null { +export function getScalarIntrinsicExpression($: Typekit, type: Scalar | IntrinsicType): string { let intrinsicName: string; if ($.scalar.isUtcDateTime(type) || $.scalar.extendsUtcDateTime(type)) { @@ -114,7 +141,7 @@ export function getScalarIntrinsicExpression( const csType = intrinsicNameToCSharpType.get(intrinsicName); if (!csType) { - reportTypescriptDiagnostic($.program, { code: "typescript-unsupported-scalar", target: type }); + reportDiagnostic($.program, { code: "csharp-unsupported-scalar", target: type }); return "object"; // Fallback to object if unsupported } @@ -127,10 +154,7 @@ function isDeclaration($: Typekit, type: Type): boolean { case "Interface": case "Enum": case "Operation": - case "EnumMember": return true; - case "UnionVariant": - return false; case "Model": if ($.array.is(type) || $.record.is(type)) { diff --git a/packages/emitter-framework/src/csharp/components/utils/index.ts b/packages/emitter-framework/src/csharp/components/utils/index.ts index 2d6469f152e..1c3a62a5934 100644 --- a/packages/emitter-framework/src/csharp/components/utils/index.ts +++ b/packages/emitter-framework/src/csharp/components/utils/index.ts @@ -1,3 +1,4 @@ export { getDocComments } from "./doc-comments.jsx"; export { getNullableUnionInnerType } from "./nullable-util.js"; export { declarationRefkeys, efRefkey } from "./refkey.js"; +export { isCSharpValueType } from "./value-type.js"; diff --git a/packages/emitter-framework/src/csharp/components/utils/value-type.ts b/packages/emitter-framework/src/csharp/components/utils/value-type.ts new file mode 100644 index 00000000000..ec32e9d5cf7 --- /dev/null +++ b/packages/emitter-framework/src/csharp/components/utils/value-type.ts @@ -0,0 +1,64 @@ +import type { Type } from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; + +/** + * TypeSpec std scalars whose C# representation is a value type (struct) rather than a + * reference type. Anything not listed here — notably `string`, `bytes` and `url` — maps to + * a C# reference type. + */ +const valueTypeScalarNames: ReadonlySet = new Set([ + "numeric", + "integer", + "float", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "safeint", + "float32", + "float64", + "decimal", + "decimal128", + "boolean", + "plainDate", + "plainTime", + "utcDateTime", + "offsetDateTime", + "duration", + "unixTimestamp32", +]); + +/** + * Returns true when the TypeSpec type is emitted as a C# value type (struct). + * + * This is what decides whether an optional value needs an explicit `?` suffix: reference + * types are already nullable under `#nullable enable`, value types are not. + */ +export function isCSharpValueType($: Typekit, type: Type): boolean { + switch (type.kind) { + case "Boolean": + case "Number": + return true; + case "String": + case "StringTemplate": + return false; + case "Enum": + return true; + case "EnumMember": + return true; + case "Union": + // A union that maps onto a C# enum is a value type; any other union degrades to + // `object`, which is not. + return Boolean(type.name) && $.union.isValidEnum(type); + case "ModelProperty": + return isCSharpValueType($, type.type); + case "Scalar": + return valueTypeScalarNames.has($.scalar.getStdBase(type)?.name ?? type.name); + default: + return false; + } +} diff --git a/packages/emitter-framework/src/lib.ts b/packages/emitter-framework/src/lib.ts index 7c8e3aa3ccf..86efc0bad82 100644 --- a/packages/emitter-framework/src/lib.ts +++ b/packages/emitter-framework/src/lib.ts @@ -10,6 +10,20 @@ export const $lib = createTypeSpecLibrary({ severity: "error", description: "A type declaration must have a name", }, + "csharp-unsupported-scalar": { + severity: "warning", + messages: { + default: "Unsupported scalar type, falling back to object", + }, + description: "This scalar has no C# equivalent", + }, + "csharp-unsupported-type": { + severity: "warning", + messages: { + default: "Unsupported type, falling back to object", + }, + description: "This type has no C# equivalent", + }, }, }); From e8069604b358f55ed0e42400fc3403bfd7257b73 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 7 Aug 2026 17:17:52 -0400 Subject: [PATCH 2/3] feat(emitter-framework): dispatch declaration overrides in Experimental_ComponentOverrides The `declaration` descriptor existed but nothing dispatched to it, so an emitter could override how a type is referenced but not how it is declared. The C# `ClassDeclaration`, `Property` and `EnumDeclaration` now render through the override point. --- .../ef-declaration-overrides-2026-8-5.md | 20 +++ .../overrides/component-overrides.tsx | 79 ++++++++++-- .../src/core/components/overrides/config.ts | 24 +++- .../components/class/declaration.test.tsx | 116 ++++++++++++++++++ .../csharp/components/class/declaration.tsx | 15 ++- .../csharp/components/enum/declaration.tsx | 15 ++- .../csharp/components/property/property.tsx | 15 ++- 7 files changed, 266 insertions(+), 18 deletions(-) create mode 100644 .chronus/changes/ef-declaration-overrides-2026-8-5.md 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..7d0ba6fbc44 --- /dev/null +++ b/.chronus/changes/ef-declaration-overrides-2026-8-5.md @@ -0,0 +1,20 @@ +--- +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. + +```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..16bf05a0cf1 100644 --- a/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx +++ b/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx @@ -51,26 +51,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,11 +140,32 @@ 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(); @@ -133,5 +182,17 @@ export function Experimental_OverridableComponent( return ; } + if ("declaration" in props && props.declaration && descriptor.declaration) { + const CustomComponent = descriptor.declaration; + 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..0ae64d5e337 100644 --- a/packages/emitter-framework/src/core/components/overrides/config.ts +++ b/packages/emitter-framework/src/core/components/overrides/config.ts @@ -1,6 +1,9 @@ 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"); @@ -14,19 +17,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); 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..fa265a16875 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,119 @@ 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 + } + `); + }); +}); 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); From 387a153866ebbe1c549e8fc124974e9ab34f6486 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 7 Aug 2026 17:18:17 -0400 Subject: [PATCH 3/3] feat(emitter-framework): make the C# declaration components authorable ClassDeclaration takes an explicit property list and extra members, Property takes the full Alloy property prop set plus name/csharpType overrides, EnumDeclaration takes an explicit member list and jsonAttributes, and JsonConverter takes doc, access modifiers, extra members and an explicit csharpType. --- ...csharp-authorable-declarations-2026-8-5.md | 18 +++ .../csharp/components/class/declaration.tsx | 59 +++++---- .../components/enum/declaration.test.tsx | 59 +++++++++ .../csharp/components/enum/declaration.tsx | 125 +++++++++++++----- .../json-converter/json-converter.tsx | 39 +++++- .../components/property/property.test.tsx | 59 +++++++++ .../csharp/components/property/property.tsx | 46 ++++--- 7 files changed, 323 insertions(+), 82 deletions(-) create mode 100644 .chronus/changes/ef-csharp-authorable-declarations-2026-8-5.md diff --git a/.chronus/changes/ef-csharp-authorable-declarations-2026-8-5.md b/.chronus/changes/ef-csharp-authorable-declarations-2026-8-5.md new file mode 100644 index 00000000000..1861b9330f9 --- /dev/null +++ b/.chronus/changes/ef-csharp-authorable-declarations-2026-8-5.md @@ -0,0 +1,18 @@ +--- +changeKind: feature +packages: + - "@typespec/emitter-framework" +--- + +Let emitters author the C# declaration components instead of forking them + +- `ClassDeclaration` accepts an explicit `properties` list and extra members as `children`. +- `Property` accepts every Alloy property prop, plus `name` and `csharpType` overrides. +- `EnumDeclaration` accepts an explicit `members` list and a `jsonAttributes` prop. +- `JsonConverter` accepts `doc`, access modifiers, extra members, an explicit `csharpType`, and a `readReturns` override. + +```tsx + + + +``` diff --git a/packages/emitter-framework/src/csharp/components/class/declaration.tsx b/packages/emitter-framework/src/csharp/components/class/declaration.tsx index 65c109690c0..adb2c84ed0a 100644 --- a/packages/emitter-framework/src/csharp/components/class/declaration.tsx +++ b/packages/emitter-framework/src/csharp/components/class/declaration.tsx @@ -1,6 +1,6 @@ import { For, type Children } from "@alloy-js/core"; import * as cs from "@alloy-js/csharp"; -import type { Interface, Model } from "@typespec/compiler"; +import type { Interface, Model, ModelProperty } from "@typespec/compiler"; import { isVoidType } from "@typespec/compiler"; import { Experimental_OverridableComponent, useTsp } from "../../../core/index.js"; import { Property } from "../property/property.jsx"; @@ -15,10 +15,18 @@ export interface ClassDeclarationProps extends Omit - - ) : undefined) - } - doc={getDocComments($, props.type)} - > - {props.type.kind === "Model" && ( - - )} - {props.type.kind === "Interface" && } - - + + ) : undefined) + } + doc={getDocComments($, type)} + {...classProps} + > + {children} + {type.kind === "Model" && ( + + )} + {type.kind === "Interface" && } + ); } function ClassProperties(props: ClassPropertiesProps): Children { // Ignore 'void' type properties which is not valid in csharp - const properties = Array.from(props.type.properties.entries()).filter( - ([_, p]) => !isVoidType(p.type), + const properties = (props.properties ?? Array.from(props.type.properties.values())).filter( + (p) => !isVoidType(p.type), ); return ( - {([name, property]) => } + {(property) => } ); } diff --git a/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx b/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx index 565fb66032b..c71550d44b8 100644 --- a/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx +++ b/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx @@ -256,3 +256,62 @@ it("renders an enum with a type-level doc comment", async () => { } `); }); + +it("adds json serialization attributes", async () => { + const { TestEnum } = await runner.compile(t.code` + enum ${t.enum("TestEnum")} { + Value1: "value-1"; + Value2: "value-2"; + } + `); + + expect( + + + , + ).toRenderTo(` + using System.Text.Json.Serialization; + + [JsonConverter(typeof(JsonStringEnumConverter))] + enum TestEnum + { + [JsonStringEnumMemberName("value-1")] + Value1, + [JsonStringEnumMemberName("value-2")] + Value2 + } + `); +}); + +it("renders an explicit member list", async () => { + const { TestEnum } = await runner.compile(t.code` + enum ${t.enum("TestEnum")} { + Value1; + Value2; + } + `); + + expect( + + + , + ).toRenderTo(` + using System.Text.Json.Serialization; + + [JsonConverter(typeof(JsonStringEnumConverter))] + enum TestEnum + { + [JsonStringEnumMemberName("onlyMe")] + OnlyMe, + [JsonStringEnumMemberName("andMe")] + AndMe + } + `); +}); diff --git a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx index 9e57afbbd1f..fd1e654d661 100644 --- a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx +++ b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx @@ -1,14 +1,42 @@ -import { Experimental_OverridableComponent, useTsp } from "#core/index.js"; -import { type Children, For } from "@alloy-js/core"; +import { Experimental_OverridableComponent } from "#core/components/index.js"; +import { useTsp } from "#core/context/tsp-context.js"; +import { code, For, REFKEYABLE, type Children, type Refkey } from "@alloy-js/core"; import * as cs from "@alloy-js/csharp"; +import { Serialization } from "@alloy-js/csharp/global/System/Text/Json"; import type { Enum, Union } from "@typespec/compiler"; import { reportDiagnostic } from "../../../lib.js"; import { getDocComments } from "../utils/doc-comments.jsx"; import { declarationRefkeys, efRefkey } from "../utils/refkey.js"; +/** A single member of a generated C# enum. */ +export interface EnumDeclarationMember { + /** Member name, before the C# name policy is applied. */ + name: string; + /** Refkey references to this member resolve to. */ + refkey?: Refkey; + /** Doc comment for the member. */ + doc?: Children; + /** + * Name this member serializes to in JSON. Only used when + * {@link EnumDeclarationProps.jsonAttributes} is set. Defaults to the member name. + */ + jsonValue?: string; +} + export interface EnumDeclarationProps extends Omit { name?: string; type: Union | Enum; + /** + * The members to render. Defaults to every member of the enum, or every variant of the + * union. + */ + members?: EnumDeclarationMember[]; + /** + * If set the enum will add the json serialization attributes (using System.Text.Json): + * `[JsonConverter(typeof(JsonStringEnumConverter))]` on the enum and + * `[JsonStringEnumMemberName]` on each member. + */ + jsonAttributes?: boolean; } export function EnumDeclaration(props: EnumDeclarationProps): Children { @@ -26,44 +54,75 @@ export function EnumDeclaration(props: EnumDeclarationProps): Children { function EnumDeclarationBody(props: EnumDeclarationProps): Children { const { $ } = useTsp(); - let type: Enum; - if ($.union.is(props.type)) { - if (!$.union.isValidEnum(props.type)) { - throw new Error("The provided union type cannot be represented as an enum"); - } - type = $.enum.createFromUnion(props.type); - } else { - type = props.type; - } + const { type: tspType, name, members, jsonAttributes, refkey, ...enumProps } = props; - if (!props.type.name) { - reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type }); + if (!tspType.name) { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: tspType }); } - const refkeys = declarationRefkeys(props.refkey, props.type)[0]; // TODO: support multiple refkeys for declarations in alloy - const name = props.name ?? cs.useCSharpNamePolicy().getName(props.type.name!, "enum"); - const members = Array.from(type.members.entries()); + const refkeys = declarationRefkeys(refkey, tspType)[0]; // TODO: support multiple refkeys for declarations in alloy + const enumName = name ?? cs.useCSharpNamePolicy().getName(tspType.name!, "enum"); + const enumMembers = members ?? defaultMembers($, tspType); return ( <> - - - {([key, value]) => { - return ( - <> - - - - ); - }} + {jsonAttributes && ( + <> + + + + )} + + + {(member) => ( + <> + + {jsonAttributes && ( + <> + + + + )} + + + )} ); } + +function defaultMembers( + $: ReturnType["$"], + tspType: Union | Enum, +): EnumDeclarationMember[] { + let type: Enum; + if ($.union.is(tspType)) { + if (!$.union.isValidEnum(tspType)) { + throw new Error("The provided union type cannot be represented as an enum"); + } + type = $.enum.createFromUnion(tspType); + } else { + type = tspType; + } + + return Array.from(type.members.entries()).map(([key, member]) => ({ + name: key, + refkey: $.union.is(tspType) ? efRefkey(tspType.variants.get(key)) : efRefkey(member), + doc: getDocComments($, member), + jsonValue: typeof member.value === "string" ? member.value : key, + })); +} diff --git a/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx index ae26ef0efde..e97976c2fbc 100644 --- a/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx +++ b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx @@ -8,10 +8,31 @@ import { type Type } from "@typespec/compiler"; import { capitalize } from "@typespec/compiler/casing"; import { TypeExpression } from "../type-expression.jsx"; -interface JsonConverterProps { +export interface JsonConverterProps { name: string | Namekey; - type: Type; + /** The TypeSpec type being converted. Required unless {@link csharpType} is set. */ + type?: Type; + /** + * The C# type being converted. Defaults to the C# expression for {@link type}. Set this + * for converters of types that have no TypeSpec equivalent (e.g. `DateTimeOffset`). + */ + csharpType?: Children; refkey?: Refkey; + /** Doc comment for the generated class. */ + doc?: Children; + /** Emit the class as `public`. Defaults to `internal`. */ + public?: boolean; + /** Emit the class as `internal`. Defaults to `true` unless {@link public} is set. */ + internal?: boolean; + /** Emit the class as `sealed`. Defaults to `true`. */ + sealed?: boolean; + /** Extra class members rendered before `Read` and `Write`. */ + children?: Children; + /** + * Return type of `Read`. Defaults to the converted type. Set this to make the converter + * return a nullable value. + */ + readReturns?: Children; /** Decode and return value from reader*/ decodeAndReturn: (reader: Namekey, typeToConvert: Namekey, options: Namekey) => Children; /** Encode the given value and send to writer*/ @@ -29,16 +50,22 @@ export function JsonConverter(props: JsonConverterProps) { const writeParamWriter: Namekey = namekey("writer"); const writeParamValue: Namekey = namekey("value"); const writeParamOptions: Namekey = namekey("options"); - const propTypeExpression = code`${()}`; + if (!props.type && !props.csharpType) { + throw new Error("JsonConverter requires either a `type` or a `csharpType`."); + } + const propTypeExpression = props.csharpType ?? code`${()}`; return ( `} > + {props.children} {code`${props.decodeAndReturn(readParamReader, readParamTypeToConvert, readParamOptions)}`} diff --git a/packages/emitter-framework/src/csharp/components/property/property.test.tsx b/packages/emitter-framework/src/csharp/components/property/property.test.tsx index 1ebf1bf380d..a10d98b7163 100644 --- a/packages/emitter-framework/src/csharp/components/property/property.test.tsx +++ b/packages/emitter-framework/src/csharp/components/property/property.test.tsx @@ -281,3 +281,62 @@ describe("jsonAttributes", () => { `); }); }); + +describe("overriding the framework defaults", () => { + it("uses an alternative name", async () => { + const { prop1 } = await tester.compile(t.code` + model TestModel { + ${t.modelProperty("prop1")}: string; + } + `); + + expect( + + + , + ).toRenderTo(` + class Test + { + public required string Renamed { get; set; } + } + `); + }); + + it("uses an alternative C# type", async () => { + const { prop1 } = await tester.compile(t.code` + model TestModel { + ${t.modelProperty("prop1")}: string[]; + } + `); + + expect( + + + , + ).toRenderTo(` + class Test + { + public required ISet Prop1 { get; set; } + } + `); + }); + + it("forwards Alloy property props and lets them win over the defaults", async () => { + const { prop1 } = await tester.compile(t.code` + model TestModel { + ${t.modelProperty("prop1")}: string; + } + `); + + expect( + + + , + ).toRenderTo(` + class Test + { + public string Prop1 { get; } = "fixed"; + } + `); + }); +}); diff --git a/packages/emitter-framework/src/csharp/components/property/property.tsx b/packages/emitter-framework/src/csharp/components/property/property.tsx index 7030e0e408f..b4be45fa2cf 100644 --- a/packages/emitter-framework/src/csharp/components/property/property.tsx +++ b/packages/emitter-framework/src/csharp/components/property/property.tsx @@ -1,4 +1,4 @@ -import { code, REFKEYABLE, type Children } from "@alloy-js/core"; +import { code, REFKEYABLE, type Children, type Namekey } from "@alloy-js/core"; import * as cs from "@alloy-js/csharp"; import { Attribute } from "@alloy-js/csharp"; import { Serialization } from "@alloy-js/csharp/global/System/Text/Json"; @@ -15,8 +15,16 @@ import { TypeExpression } from "../type-expression.jsx"; import { getDocComments } from "../utils/doc-comments.jsx"; import { getNullableUnionInnerType } from "../utils/nullable-util.js"; -export interface PropertyProps { +export interface PropertyProps extends Omit { + /** The TypeSpec property to create the C# property from. */ type: ModelProperty; + /** Set an alternative name for the property. Otherwise default to the TypeSpec property name. */ + name?: Namekey | string; + /** + * Set an alternative C# type for the property. Otherwise default to rendering + * {@link PropertyProps.type}, unwrapping a nullable union if there is one. + */ + csharpType?: Children; /** If set the property will add the json serialization attributes(using System.Text.Json.Serialization). * - the JsonPropertyName attribute * - the JsonConverter attribute if the property has encoding and a JsonConverterResolver context is available @@ -42,14 +50,15 @@ export function Property(props: PropertyProps): Children { function PropertyBody(props: PropertyProps): Children { const { $ } = useTsp(); - const result = preprocessPropertyType(props.type); + const { type: tspProperty, name, csharpType, jsonAttributes, ...propertyProps } = props; + const result = preprocessPropertyType(tspProperty); let overrideType: "" | "override" | "new" = ""; let isVirtual = false; - if (props.type.model) { - if (props.type.model.baseModel) { - const base = props.type.model.baseModel; - const baseProperty = getProperty(base, props.type.name); + if (tspProperty.model) { + if (tspProperty.model.baseModel) { + const base = tspProperty.model.baseModel; + const baseProperty = getProperty(base, tspProperty.name); if (baseProperty) { const baseResult = preprocessPropertyType(baseProperty); if (baseResult.nullable === result.nullable && baseResult.type === result.type) { @@ -61,11 +70,11 @@ function PropertyBody(props: PropertyProps): Children { } if ( overrideType === "" && - props.type.model.derivedModels && - props.type.model.derivedModels.length > 0 + tspProperty.model.derivedModels && + tspProperty.model.derivedModels.length > 0 ) { - isVirtual = props.type.model.derivedModels.some((derived) => { - const derivedProperty = derived.properties.get(props.type.name); + isVirtual = tspProperty.model.derivedModels.some((derived) => { + const derivedProperty = derived.properties.get(tspProperty.name); if (derivedProperty) { const derivedResult = preprocessPropertyType(derivedProperty); return derivedResult.nullable === result.nullable && derivedResult.type === result.type; @@ -74,9 +83,9 @@ function PropertyBody(props: PropertyProps): Children { } } const attributes = []; - if (props.jsonAttributes) { - attributes.push(); - const encodeData = getEncode($.program, props.type); + if (jsonAttributes) { + attributes.push(); + const encodeData = getEncode($.program, tspProperty); if (encodeData) { const JsonConverterResolver = useJsonConverterResolver(); if (JsonConverterResolver) { @@ -92,18 +101,19 @@ function PropertyBody(props: PropertyProps): Children { return ( } + name={name ?? tspProperty.name} + type={csharpType ?? } override={overrideType === "override"} new={overrideType === "new"} public virtual={isVirtual} - required={!props.type.optional} + required={!tspProperty.optional} nullable={result.nullable} - doc={getDocComments($, props.type)} + doc={getDocComments($, tspProperty)} attributes={attributes} get set + {...propertyProps} /> ); }