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/.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/.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/.chronus/changes/http-server-csharp-alloy-naming-2026-8-5.md b/.chronus/changes/http-server-csharp-alloy-naming-2026-8-5.md
new file mode 100644
index 00000000000..f40a55dbec7
--- /dev/null
+++ b/.chronus/changes/http-server-csharp-alloy-naming-2026-8-5.md
@@ -0,0 +1,9 @@
+---
+changeKind: internal
+packages:
+ - "@typespec/http-server-csharp"
+---
+
+Use Alloy's C# keyword handling and `System.Text.Json` symbols instead of local copies
+
+Deletes the emitter's own 217-line C# keyword table and its re-declaration of the `System.Text.Json.Serialization` attributes, which are both provided by `@alloy-js/csharp`. Namespace segments that collide with common BCL type names are still renamed, now in a dedicated `getCSharpNamespaceName` helper.
diff --git a/.chronus/changes/http-server-csharp-error-model-scalars-2026-8-5.md b/.chronus/changes/http-server-csharp-error-model-scalars-2026-8-5.md
new file mode 100644
index 00000000000..9ef6944b720
--- /dev/null
+++ b/.chronus/changes/http-server-csharp-error-model-scalars-2026-8-5.md
@@ -0,0 +1,9 @@
+---
+changeKind: fix
+packages:
+ - "@typespec/http-server-csharp"
+---
+
+Fix generated error model constructors and numeric constraint attributes using the wrong C# types
+
+Error model constructors declared parameters such as `DateOnly`, `Uri` and `sbyte` while the matching properties were `DateTime`, `string` and `SByte`, producing code that did not compile. `NumericConstraintAttribute` had the same mismatch, which stopped the converter from binding.
diff --git a/.chronus/changes/http-server-csharp-models-on-ef-2026-8-5.md b/.chronus/changes/http-server-csharp-models-on-ef-2026-8-5.md
new file mode 100644
index 00000000000..0cc23afee95
--- /dev/null
+++ b/.chronus/changes/http-server-csharp-models-on-ef-2026-8-5.md
@@ -0,0 +1,9 @@
+---
+changeKind: internal
+packages:
+ - "@typespec/http-server-csharp"
+---
+
+Build model, property and enum generation on `@typespec/emitter-framework` components
+
+The emitter carried private forks of the framework's `ClassDeclaration`, `Property` and `EnumDeclaration`. They are now consumed directly, with the emitter's own behavior expressed as a declaration override and component props. Generated output is unchanged.
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..adb2c84ed0a 100644
--- a/packages/emitter-framework/src/csharp/components/class/declaration.tsx
+++ b/packages/emitter-framework/src/csharp/components/class/declaration.tsx
@@ -1,8 +1,8 @@
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 { 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";
@@ -15,10 +15,18 @@ export interface ClassDeclarationProps extends Omit
+
+
+ );
+}
+
+function ClassDeclarationBody(props: ClassDeclarationProps): Children {
const { $ } = useTsp();
+ const { type, name, jsonAttributes, properties, children, refkey, baseType, ...classProps } =
+ props;
const namePolicy = cs.useCSharpNamePolicy();
- const className = props.name ?? namePolicy.getName(props.type.name, "class");
+ const className = name ?? namePolicy.getName(type.name, "class");
- const refkeys = declarationRefkeys(props.refkey, props.type)[0]; // TODO: support multiple refkeys for declarations in alloy
+ const refkeys = declarationRefkeys(refkey, type)[0]; // TODO: support multiple refkeys for declarations in alloy
return (
- <>
-
- ) : 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 e49a5474c47..fd1e654d661 100644
--- a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
+++ b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
@@ -1,56 +1,128 @@
+import { Experimental_OverridableComponent } from "#core/components/index.js";
import { useTsp } from "#core/context/tsp-context.js";
-import { type Children, For } from "@alloy-js/core";
+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 {
+ return (
+
+
+
+ );
+}
+
+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 8863892e06f..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";
@@ -9,14 +9,22 @@ 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";
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
@@ -28,15 +36,29 @@ 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);
+ 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) {
@@ -48,11 +70,11 @@ export function Property(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;
@@ -61,9 +83,9 @@ export function Property(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) {
@@ -79,18 +101,19 @@ export function Property(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}
/>
);
}
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",
+ },
},
});
diff --git a/packages/http-server-csharp/src/components/controller-action/controller-action.tsx b/packages/http-server-csharp/src/components/controller-action/controller-action.tsx
index 6588a190d1e..a79b1f3ef76 100644
--- a/packages/http-server-csharp/src/components/controller-action/controller-action.tsx
+++ b/packages/http-server-csharp/src/components/controller-action/controller-action.tsx
@@ -3,9 +3,9 @@ import * as cs from "@alloy-js/csharp";
import { Attribute } from "@alloy-js/csharp";
import { isErrorModel, isVoidType } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { AspNetMvc } from "../../utils/csharp-libs.jsx";
-import { getDocComments } from "../../utils/doc-comments.jsx";
import { getHttpVerbAttribute, getRouteTemplate } from "../../utils/http-helpers.js";
import type { RequestModelInfo } from "../request-models.jsx";
import { TypeExpression } from "../type-expression/type-expression.jsx";
diff --git a/packages/http-server-csharp/src/components/enums/enums.tsx b/packages/http-server-csharp/src/components/enums/enums.tsx
index 8fdcf3d9fa7..64880c0f389 100644
--- a/packages/http-server-csharp/src/components/enums/enums.tsx
+++ b/packages/http-server-csharp/src/components/enums/enums.tsx
@@ -1,7 +1,5 @@
-import type { Refkey } from "@alloy-js/core";
import { For, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
-import { Attribute } from "@alloy-js/csharp";
import {
type Enum,
type Namespace as TspNamespace,
@@ -9,51 +7,46 @@ import {
type Union,
} from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
-import { JsonSerialization } from "../../utils/csharp-libs.jsx";
-import { getDocComments } from "../../utils/doc-comments.jsx";
+import {
+ EnumDeclaration as EfEnumDeclaration,
+ getDocComments,
+ type EnumDeclarationMember,
+} from "@typespec/emitter-framework/csharp";
import { getSubNamespaceParts } from "../../utils/namespace-utils.js";
import { CSharpFile } from "../csharp-file.jsx";
import { efRefkey } from "../type-expression/type-expression.jsx";
-/** Normalized member info shared by both enums and union-enums. */
-interface EnumMemberInfo {
- name: string;
- serializedValue: string;
- docSource: Type;
- memberRefkey?: Refkey;
-}
-
-/** Normalized enum info that abstracts over Enum and union-as-enum types. */
+/** Normalized declaration info that abstracts over `Enum` and union-as-enum types. */
interface EnumInfo {
name: string;
type: Enum | Union;
namespace: TspNamespace | undefined;
- members: EnumMemberInfo[];
+ members: EnumDeclarationMember[];
}
-function normalizeEnum(en: Enum): EnumInfo {
+function normalizeEnum($: ReturnType["$"], en: Enum): EnumInfo {
return {
name: en.name,
type: en,
namespace: en.namespace,
- members: Array.from(en.members.entries()).map(([key, value]) => ({
+ members: Array.from(en.members.entries()).map(([key, member]) => ({
name: key,
- serializedValue: typeof value.value === "string" ? value.value : key,
- docSource: value,
+ jsonValue: typeof member.value === "string" ? member.value : key,
+ doc: getDocComments($, member),
})),
};
}
-function normalizeUnionEnum(union: Union): EnumInfo {
+function normalizeUnionEnum($: ReturnType["$"], union: Union): EnumInfo {
return {
name: union.name!,
type: union,
namespace: union.namespace,
members: getUnionEnumMembers(union).map(({ name, value, variant }) => ({
name,
- serializedValue: value,
- docSource: variant,
- memberRefkey: efRefkey(union, name),
+ jsonValue: value,
+ doc: getDocComments($, variant),
+ refkey: efRefkey(union, name),
})),
};
}
@@ -75,47 +68,24 @@ export function Enums(props: EnumsProps): Children {
const { $ } = useTsp();
const allEnums: EnumInfo[] = [
- ...props.enums.map(normalizeEnum),
- ...props.unionEnums.map(normalizeUnionEnum),
+ ...props.enums.map((en) => normalizeEnum($, en)),
+ ...props.unionEnums.map((union) => normalizeUnionEnum($, union)),
];
return (
{(info) => {
- const namePolicy = cs.useCSharpNamePolicy();
const subNsParts = getSubNamespaceParts(info.namespace, props.serviceNamespace);
const enumDecl = (
- <>
-
-
-
-
- {(member) => (
- <>
-
-
-
-
- >
- )}
-
-
- >
+
);
const wrappedContent = subNsParts.reduceRight(
diff --git a/packages/http-server-csharp/src/components/interfaces/interfaces.tsx b/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
index c49fc290cbc..6d9374001e0 100644
--- a/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
+++ b/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
@@ -3,9 +3,9 @@ import * as cs from "@alloy-js/csharp";
import type { Interface, Operation } from "@typespec/compiler";
import { isTemplateDeclaration, isVoidType } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { getUniqueItems } from "@typespec/json-schema";
-import { getDocComments } from "../../utils/doc-comments.jsx";
import { getSuccessReturnType } from "../../utils/return-type-helpers.js";
import { TypeExpression } from "../type-expression/type-expression.jsx";
diff --git a/packages/http-server-csharp/src/components/models/error-models.tsx b/packages/http-server-csharp/src/components/models/error-models.tsx
index c0b7b0e3cc4..9a65d076d2f 100644
--- a/packages/http-server-csharp/src/components/models/error-models.tsx
+++ b/packages/http-server-csharp/src/components/models/error-models.tsx
@@ -1,7 +1,8 @@
import { type Children } from "@alloy-js/core";
import type { ParameterProps } from "@alloy-js/csharp";
import * as cs from "@alloy-js/csharp";
-import { isErrorModel, type Model, type Program } from "@typespec/compiler";
+import { isErrorModel, type Model } from "@typespec/compiler";
+import type { Typekit } from "@typespec/compiler/typekit";
import { getHeaderFieldName, isHeader, isStatusCode } from "@typespec/http";
import {
getAllProperties,
@@ -13,18 +14,20 @@ import {
} from "./model-helpers.js";
/** Generates the constructor for an error model. */
-export function getErrorConstructor(program: Program, model: Model, className: string): Children {
- const statusCode = getErrorStatusCode(program, model);
- const isChild = model.baseModel && isErrorModel(program, model.baseModel);
+export function getErrorConstructor($: Typekit, model: Model, className: string): Children {
+ const statusCode = getErrorStatusCode($.program, model);
+ const isChild = model.baseModel && isErrorModel($.program, model.baseModel);
const namePolicy = cs.createCSharpNamePolicy();
// For child error models, only use own properties (not inherited)
// For root error models, use all properties including inherited
- const props = isChild ? Array.from(model.properties.values()) : getAllProperties(program, model);
+ const props = isChild
+ ? Array.from(model.properties.values())
+ : getAllProperties($.program, model);
// Separate properties into required and optional/default
const sortedProps = props
- .filter((p) => !isStatusCode(program, p))
+ .filter((p) => !isStatusCode($.program, p))
.map((prop) => {
const defaultValue = prop.defaultValue ? getDefaultValueString(prop.defaultValue) : undefined;
const literalValue = getLiteralValue(prop.type);
@@ -53,13 +56,13 @@ export function getErrorConstructor(program: Program, model: Model, className: s
propName = propName === "Value" ? "ValueName" : `${propName}Prop`;
}
- const csharpType = getCSharpTypeString(program, prop.type);
+ const csharpType = getCSharpTypeString($, prop.type);
const defaultStr = defaultValue ? defaultValue : prop.optional ? "default" : undefined;
parameters.push({ name: prop.name, type: csharpType, default: defaultStr });
bodyParts.push(`${propName} = ${prop.name};`);
- if (isHeader(program, prop)) {
- const headerName = getHeaderFieldName(program, prop);
+ if (isHeader($.program, prop)) {
+ const headerName = getHeaderFieldName($.program, prop);
headerParts.push(`{"${headerName}", ${prop.name}}`);
} else {
valueParts.push(`${prop.name} = ${prop.name}`);
diff --git a/packages/http-server-csharp/src/components/models/model-helpers.ts b/packages/http-server-csharp/src/components/models/model-helpers.ts
index 094d5189b84..7510ff5a3af 100644
--- a/packages/http-server-csharp/src/components/models/model-helpers.ts
+++ b/packages/http-server-csharp/src/components/models/model-helpers.ts
@@ -7,12 +7,12 @@ import {
type ModelProperty,
type Program,
type Type,
- type Union,
type Value,
} from "@typespec/compiler";
import type { useTsp } from "@typespec/emitter-framework";
import { isStatusCode } from "@typespec/http";
import { getUnionEnumMembers, isUnionEnum } from "../enums/enums.jsx";
+import { getServerScalarName } from "../type-expression/scalar-overrides.js";
import { assignAnonymousName } from "./anonymous-models.js";
/** Gets the string representation of a literal or default value. */
@@ -142,46 +142,6 @@ export function hasNonIntegerValues(en: Enum): boolean {
return false;
}
-/** Returns true if the TypeSpec type maps to a C# value type (struct). */
-export function isValueType($: ReturnType["$"], type: Type): boolean {
- // Handle literal types
- if (type.kind === "Boolean" || type.kind === "Number") return true;
- if (type.kind === "String") return false;
-
- if ($.scalar.is(type)) {
- const baseName = $.scalar.getStdBase(type)?.name ?? type.name;
- const valueTypes = new Set([
- "int8",
- "int16",
- "int32",
- "int64",
- "uint8",
- "uint16",
- "uint32",
- "uint64",
- "safeint",
- "float32",
- "float64",
- "decimal",
- "decimal128",
- "boolean",
- "numeric",
- "integer",
- "float",
- "plainDate",
- "plainTime",
- "utcDateTime",
- "offsetDateTime",
- "duration",
- "unixTimestamp32",
- ]);
- return valueTypes.has(baseName);
- }
- if ($.enum.is(type)) return true;
- if (type.kind === "Union" && isUnionEnum(type as Union)) return true;
- return false;
-}
-
/** Returns true if any property of the model uses Record (mapped to JsonObject). */
export function modelNeedsJsonNodes($: ReturnType["$"], model: Model): boolean {
for (const prop of model.properties.values()) {
@@ -246,34 +206,16 @@ export function getErrorStatusCode(
return { value: minVal ?? "default" };
}
-/** Gets a simple C# type name string for a TypeSpec type. */
-export function getCSharpTypeString(program: Program, type: Type): string {
+/**
+ * Gets a simple C# type name string for a TypeSpec type.
+ *
+ * Used where a type name is needed as plain text rather than a rendered reference, such as
+ * error-model constructor parameters. Scalars resolve through {@link getServerScalarName} so
+ * the parameter type always agrees with the type of the property it is assigned to.
+ */
+export function getCSharpTypeString($: ReturnType["$"], type: Type): string {
if (type.kind === "Scalar") {
- const scalarMap: Record = {
- string: "string",
- int8: "sbyte",
- int16: "short",
- int32: "int",
- int64: "long",
- uint8: "byte",
- uint16: "ushort",
- uint32: "uint",
- uint64: "ulong",
- float32: "float",
- float64: "double",
- boolean: "bool",
- plainDate: "DateOnly",
- plainTime: "TimeOnly",
- utcDateTime: "DateTimeOffset",
- offsetDateTime: "DateTimeOffset",
- duration: "TimeSpan",
- bytes: "byte[]",
- decimal: "decimal",
- decimal128: "decimal",
- url: "Uri",
- safeint: "long",
- };
- return scalarMap[type.name] ?? type.name;
+ return getServerScalarName($, type);
}
if (type.kind === "String") return "string";
if (type.kind === "Boolean") return "bool";
diff --git a/packages/http-server-csharp/src/components/models/models.tsx b/packages/http-server-csharp/src/components/models/models.tsx
index 4e8bdffe46c..3f08a03544c 100644
--- a/packages/http-server-csharp/src/components/models/models.tsx
+++ b/packages/http-server-csharp/src/components/models/models.tsx
@@ -1,37 +1,14 @@
-import { code, For, type Children } from "@alloy-js/core";
+import { For, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
-import { Attribute } from "@alloy-js/csharp";
-import {
- isErrorModel,
- isVoidType,
- type Model,
- type ModelProperty,
- type Namespace as TspNamespace,
-} from "@typespec/compiler";
+import { isErrorModel, type Model, type Namespace as TspNamespace } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { ClassDeclaration as EfClassDeclaration } from "@typespec/emitter-framework/csharp";
import { isStatusCode } from "@typespec/http";
-import { getUniqueItems } from "@typespec/json-schema";
-import { useEmitterOptions } from "../../context/emitter-options-context.js";
-import { getPropertyAttributes } from "../../utils/attributes.jsx";
-import { JsonSerialization } from "../../utils/csharp-libs.jsx";
-import { getDocComments } from "../../utils/doc-comments.jsx";
import { getSubNamespaceParts } from "../../utils/namespace-utils.js";
import { CSharpFile } from "../csharp-file.jsx";
-import { efRefkey, TypeExpression } from "../type-expression/type-expression.jsx";
+import { efRefkey } from "../type-expression/type-expression.jsx";
import { getErrorConstructor } from "./error-models.jsx";
-import {
- getDefaultValueString,
- getEnumDefaultInitializer,
- getLiteralValue,
- getModelEmitName,
- getScalarForLiteral,
- getUnionVariantInitializer,
- hasNonIntegerValues,
- hasPropertyInChain,
- isDuplicateExceptionName,
- isValueType,
- modelNeedsJsonNodes,
-} from "./model-helpers.js";
+import { getModelEmitName, modelNeedsJsonNodes } from "./model-helpers.js";
// Re-export public API used by other modules
export { getAnonymousModelName } from "./anonymous-models.js";
@@ -99,39 +76,29 @@ function ServerClassDeclaration(props: ServerClassDeclarationProps): Children {
const { $ } = useTsp();
const namePolicy = cs.useCSharpNamePolicy();
const className = namePolicy.getName(props.emitName ?? props.type.name, "class");
- const refkeys = efRefkey(props.type);
const isError = isErrorModel($.program, props.type);
- const properties = Array.from(props.type.properties.entries()).filter(
- ([_, p]) => !isVoidType(p.type),
+ // `@statusCode` is carried by the generated exception, not by a property.
+ const properties = Array.from(props.type.properties.values()).filter(
+ (p) => !(isError && isStatusCode($.program, p)),
);
- // Determine base type
- let baseType: Children | undefined;
- if (props.type.baseModel) {
- baseType = ;
- } else if (isError) {
- baseType = "HttpServiceException";
- }
+ const errorConstructor = isError ? getErrorConstructor($, props.type, className) : undefined;
- // Generate constructor for error models
- const errorConstructor = isError
- ? getErrorConstructor($.program, props.type, className)
- : undefined;
-
- // For error models with base model, check if base is also an error (child constructor)
+ // An error model that is itself subclassed needs a constructor its children can chain to.
const hasChildConstructor =
isError && props.type.derivedModels && props.type.derivedModels.length > 0;
return (
-
{errorConstructor}
{errorConstructor && }
@@ -147,126 +114,6 @@ function ServerClassDeclaration(props: ServerClassDeclarationProps): Children {
/>
)}
{hasChildConstructor && }
-
- {([_, property]) => {
- // Skip statusCode properties for error models
- if (isError && isStatusCode($.program, property)) return undefined;
- return (
-
- );
- }}
-
-
- );
-}
-
-interface ServerPropertyProps {
- type: ModelProperty;
- errorClassName?: string;
- baseModel?: Model;
-}
-
-/**
- * Server-specific property that matches old emitter output.
- * No `required`, no `[JsonPropertyName]`, no nullable `?` for reference types.
- */
-function ServerProperty(props: ServerPropertyProps): Children {
- const { $ } = useTsp();
- const namePolicy = cs.useCSharpNamePolicy();
- const propType = props.type.type;
- const attrs = getPropertyAttributes($.program, props.type);
-
- // Determine property name, handling error model conflicts
- let propName = props.type.name;
- if (props.errorClassName) {
- const csharpPropName = namePolicy.getName(propName, "class-property");
- if (csharpPropName === props.errorClassName || isDuplicateExceptionName(csharpPropName)) {
- propName = csharpPropName === "Value" ? "ValueName" : `${csharpPropName}Prop`;
- }
- }
-
- // Add JsonPropertyName if the C# name differs from the original TypeSpec name
- const csharpName = namePolicy.getName(propName, "class-property");
- if (csharpName !== props.type.name) {
- attrs.unshift(
- ,
- );
- }
-
- // Check if this property overrides a base model property (discriminator pattern)
- const isOverride = props.baseModel ? hasPropertyInChain(props.baseModel, props.type.name) : false;
-
- // Check for union variant type (e.g., kind: PetType.Dog) — used as enum member initializer
- const unionVariantInit = getUnionVariantInitializer(propType, namePolicy);
-
- // Check for enum default value (e.g., variety: WolfBreed = WolfBreed.dire)
- const enumDefaultInit = getEnumDefaultInitializer(props.type, namePolicy);
-
- // For error models, properties get values from constructor, not as literals
- const isErrorProp = !!props.errorClassName;
-
- // Check for literal values (the type itself is a literal)
- const { collectionType } = useEmitterOptions();
- const literalInfo = isErrorProp
- ? undefined
- : (unionVariantInit ?? getLiteralValue(propType, collectionType));
- // Check for default values
- const defaultValue = isErrorProp
- ? undefined
- : (enumDefaultInit ??
- (props.type.defaultValue ? getDefaultValueString(props.type.defaultValue) : undefined));
-
- const initializer = literalInfo ?? defaultValue;
- const isLiteralOnly = literalInfo !== undefined && defaultValue === undefined;
-
- // Check if the property type is a non-integer enum (C# enums can only be integers)
- const isFloatEnum =
- $.enum.is(propType) && hasNonIntegerValues(propType as import("@typespec/compiler").Enum);
-
- // For error model properties with literal types, use the scalar base type
- // But not for union variant types — those should resolve to the enum type
- const resolveToScalar = (isLiteralOnly && !unionVariantInit) || isErrorProp;
- const resolvedType = resolveToScalar ? getScalarForLiteral(propType) : propType;
- const needsNullable = props.type.optional && (isFloatEnum || isValueType($, resolvedType));
-
- // Check if this is a @uniqueItems array → ISet
- const isUniqueItems = getUniqueItems($.program, props.type);
- const isArrayType = propType.kind === "Model" && $.array.is(propType);
-
- let typeExpr: Children;
- if (isFloatEnum) {
- typeExpr = code`double`;
- } else if (isUniqueItems && isArrayType && propType.indexer?.value) {
- typeExpr = (
- <>
- ISet<
-
- >
- >
- );
- } else {
- typeExpr = ;
- }
-
- return (
- 0 ? attrs : undefined}
- get
- set={!isLiteralOnly}
- initializer={initializer}
- />
+
);
}
diff --git a/packages/http-server-csharp/src/components/models/server-property.tsx b/packages/http-server-csharp/src/components/models/server-property.tsx
new file mode 100644
index 00000000000..9f5daf8f026
--- /dev/null
+++ b/packages/http-server-csharp/src/components/models/server-property.tsx
@@ -0,0 +1,142 @@
+import { code, type Children } 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";
+import { isErrorModel, type Enum, type Model, type ModelProperty } from "@typespec/compiler";
+import type {
+ Experimental_OverrideDeclarationComponent,
+ Experimental_OverrideDeclareProps,
+} from "@typespec/emitter-framework";
+import { useTsp } from "@typespec/emitter-framework";
+import { isCSharpValueType, type PropertyProps } from "@typespec/emitter-framework/csharp";
+import { getUniqueItems } from "@typespec/json-schema";
+import { useEmitterOptions } from "../../context/emitter-options-context.js";
+import { getPropertyAttributes } from "../../utils/attributes.jsx";
+import { TypeExpression } from "../type-expression/type-expression.jsx";
+import {
+ getDefaultValueString,
+ getEnumDefaultInitializer,
+ getLiteralValue,
+ getModelEmitName,
+ getScalarForLiteral,
+ getUnionVariantInitializer,
+ hasNonIntegerValues,
+ hasPropertyInChain,
+ isDuplicateExceptionName,
+} from "./model-helpers.js";
+
+/**
+ * Renders a property the way the pre-Alloy emitter did, rather than the way the framework
+ * would by default:
+ *
+ * - no `required` keyword
+ * - `[JsonPropertyName]` only when the C# name differs from the wire name
+ * - no nullable `?` suffix on reference types (they are already nullable under
+ * `#nullable enable`)
+ * - `new` rather than `override`/`virtual` for discriminator properties
+ * - literal-typed properties become get-only properties with an initializer
+ *
+ * It is registered as a `ModelProperty` declaration override so that every property the
+ * framework emits — including the ones it renders from inside `ClassDeclaration` — picks it
+ * up. The framework's own rendering is still reached through `props.Declaration`, so the
+ * doc comments, name policy and nullable-union unwrapping are not reimplemented here.
+ */
+export const ServerPropertyOverride: Experimental_OverrideDeclarationComponent<
+ ModelProperty,
+ PropertyProps
+> = (props: Experimental_OverrideDeclareProps): Children => {
+ const { $ } = useTsp();
+ const { collectionType } = useEmitterOptions();
+ const namePolicy = cs.useCSharpNamePolicy();
+
+ const property = props.type;
+ const propType = property.type;
+ const declaringModel: Model | undefined = property.model;
+ const isErrorProp = declaringModel ? isErrorModel($.program, declaringModel) : false;
+ const attrs = getPropertyAttributes($, property);
+
+ // Error models derive from `HttpServiceException`, so a property whose C# name collides
+ // with the class name or with an inherited exception member has to be renamed.
+ let propName = property.name;
+ if (isErrorProp && declaringModel) {
+ const errorClassName = namePolicy.getName(getModelEmitName($.program, declaringModel), "class");
+ const csharpPropName = namePolicy.getName(propName, "class-property");
+ if (csharpPropName === errorClassName || isDuplicateExceptionName(csharpPropName)) {
+ propName = csharpPropName === "Value" ? "ValueName" : `${csharpPropName}Prop`;
+ }
+ }
+
+ // Only carry the wire name when the C# name policy actually changed it.
+ const csharpName = namePolicy.getName(propName, "class-property");
+ if (csharpName !== property.name) {
+ attrs.unshift(
+ ,
+ );
+ }
+
+ // Discriminator properties redeclare a base property; the old emitter used `new`.
+ const isOverride = declaringModel?.baseModel
+ ? hasPropertyInChain(declaringModel.baseModel, property.name)
+ : false;
+
+ // e.g. `kind: PetType.Dog` — the property is pinned to a single enum member.
+ const unionVariantInit = getUnionVariantInitializer(propType, namePolicy);
+ // e.g. `variety: WolfBreed = WolfBreed.dire`
+ const enumDefaultInit = getEnumDefaultInitializer(property, namePolicy);
+
+ // Error model properties are populated by the generated constructor instead.
+ const literalInfo = isErrorProp
+ ? undefined
+ : (unionVariantInit ?? getLiteralValue(propType, collectionType));
+ const defaultValue = isErrorProp
+ ? undefined
+ : (enumDefaultInit ??
+ (property.defaultValue ? getDefaultValueString(property.defaultValue) : undefined));
+
+ const initializer = literalInfo ?? defaultValue;
+ const isLiteralOnly = literalInfo !== undefined && defaultValue === undefined;
+
+ // C# enums are integral, so an enum with fractional values has to widen to `double`.
+ const isFloatEnum = $.enum.is(propType) && hasNonIntegerValues(propType as Enum);
+
+ // A literal-typed property is declared as its scalar base; a union variant keeps its enum.
+ const resolveToScalar = (isLiteralOnly && !unionVariantInit) || isErrorProp;
+ const resolvedType = resolveToScalar ? getScalarForLiteral(propType) : propType;
+ const needsNullable = property.optional && (isFloatEnum || isCSharpValueType($, resolvedType));
+
+ const isUniqueItems = getUniqueItems($.program, property);
+ const isArrayType = propType.kind === "Model" && $.array.is(propType);
+
+ let csharpType: Children;
+ if (isFloatEnum) {
+ csharpType = code`double`;
+ } else if (isUniqueItems && isArrayType && propType.indexer?.value) {
+ csharpType = (
+ <>
+ ISet<
+
+ >
+ >
+ );
+ } else {
+ csharpType = ;
+ }
+
+ return (
+ 0 ? attrs : undefined}
+ get
+ set={!isLiteralOnly}
+ initializer={initializer}
+ />
+ );
+};
diff --git a/packages/http-server-csharp/src/components/request-models.tsx b/packages/http-server-csharp/src/components/request-models.tsx
index f5434c769e8..de0aaa73d34 100644
--- a/packages/http-server-csharp/src/components/request-models.tsx
+++ b/packages/http-server-csharp/src/components/request-models.tsx
@@ -1,11 +1,11 @@
import { For, type Children } 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";
import { isVoidType } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
-import { JsonSerialization } from "../utils/csharp-libs.jsx";
-import { getDocComments } from "../utils/doc-comments.jsx";
import { CSharpFile } from "./csharp-file.jsx";
import { TypeExpression } from "./type-expression/type-expression.jsx";
@@ -72,7 +72,7 @@ function RequestModelClass(props: RequestModelClassProps): Children {
if (propName !== property.name) {
attrs.push(
,
);
diff --git a/packages/http-server-csharp/src/components/type-expression/scalar-overrides.test.ts b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.test.ts
new file mode 100644
index 00000000000..04b1865a0be
--- /dev/null
+++ b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.test.ts
@@ -0,0 +1,60 @@
+import { Tester } from "#test/tester.js";
+import { type TesterInstance } from "@typespec/compiler/testing";
+import { $ } from "@typespec/compiler/typekit";
+import { beforeEach, expect, it } from "vitest";
+import { getServerScalarName } from "./scalar-overrides.js";
+
+let runner: TesterInstance;
+
+beforeEach(async () => {
+ runner = await Tester.createInstance();
+});
+
+async function scalarName(ref: string): Promise {
+ await runner.compile(`
+ model Test { test: ${ref}; }
+ `);
+ const tk = $(runner.program);
+ const model = runner.program.resolveTypeReference("Test")[0];
+ const scalar = (model as any).properties.get("test").type;
+ return getServerScalarName(tk, scalar);
+}
+
+it.each([
+ // Server overrides of the emitter-framework defaults.
+ ["plainDate", "DateTime"],
+ ["plainTime", "DateTime"],
+ ["url", "string"],
+ ["safeint", "long"],
+ ["int8", "SByte"],
+ ["uint8", "Byte"],
+ ["int16", "Int16"],
+ ["uint16", "UInt16"],
+ ["uint32", "UInt32"],
+ ["uint64", "UInt64"],
+ // Inherited from the emitter-framework defaults.
+ ["string", "string"],
+ ["int32", "int"],
+ ["int64", "long"],
+ ["float32", "float"],
+ ["float64", "double"],
+ ["boolean", "bool"],
+ ["bytes", "byte[]"],
+ ["decimal", "decimal"],
+ ["utcDateTime", "DateTimeOffset"],
+ ["offsetDateTime", "DateTimeOffset"],
+ ["duration", "TimeSpan"],
+])("%s => %s", async (tspType, csType) => {
+ expect(await scalarName(tspType)).toBe(csType);
+});
+
+it("resolves custom scalars through the base they extend", async () => {
+ await runner.compile(`
+ scalar myDate extends plainDate;
+ model Test { test: myDate; }
+ `);
+ const tk = $(runner.program);
+ const model = runner.program.resolveTypeReference("Test")[0];
+ const scalar = (model as any).properties.get("test").type;
+ expect(getServerScalarName(tk, scalar)).toBe("DateTime");
+});
diff --git a/packages/http-server-csharp/src/components/type-expression/scalar-overrides.ts b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.ts
new file mode 100644
index 00000000000..589b522546d
--- /dev/null
+++ b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.ts
@@ -0,0 +1,58 @@
+import type { Scalar } from "@typespec/compiler";
+import type { Typekit } from "@typespec/compiler/typekit";
+import { getScalarIntrinsicExpression } from "@typespec/emitter-framework/csharp";
+
+/**
+ * The scalars whose C# representation differs from the emitter-framework defaults:
+ *
+ * - `plainDate` / `plainTime` → `DateTime` (not `DateOnly` / `TimeOnly`)
+ * - `url` → `string` (not `Uri`)
+ * - `safeint` → `long` (not `int`)
+ * - sized integers use CLR type names (`SByte`, `Int16`, …) rather than C# keywords
+ *
+ * These reproduce the output of the pre-Alloy emitter and are deliberate, not oversights.
+ */
+export function getServerScalarOverrides($: Typekit): [Scalar, string][] {
+ return [
+ [$.builtin.plainDate, "DateTime"],
+ [$.builtin.plainTime, "DateTime"],
+ [$.builtin.url, "string"],
+ [$.builtin.int8, "SByte"],
+ [$.builtin.uint8, "Byte"],
+ [$.builtin.int16, "Int16"],
+ [$.builtin.uint16, "UInt16"],
+ [$.builtin.uint32, "UInt32"],
+ [$.builtin.uint64, "UInt64"],
+ [$.builtin.safeInt, "long"],
+ ];
+}
+
+/**
+ * Resolves the C# type name for a scalar, applying the server overrides on top of the
+ * emitter-framework defaults.
+ *
+ * This is the single source of truth for scalar naming. Anywhere a C# type *name* is needed
+ * outside of a rendering context — constraint attribute type arguments, error constructor
+ * parameter types — must go through here so that the name always agrees with what
+ * `TypeExpression` renders for the same scalar.
+ */
+export function getServerScalarName($: Typekit, scalar: Scalar): string {
+ const overrides = new Map(getServerScalarOverrides($));
+ // Custom scalars (`scalar myDate extends plainDate`) inherit their base's mapping.
+ let current: Scalar | undefined = scalar;
+ while (current) {
+ const override = overrides.get(current);
+ if (override) return override;
+ current = current.baseScalar;
+ }
+ return getScalarIntrinsicExpression($, scalar);
+}
+
+/**
+ * Like {@link getServerScalarName}, but returns undefined for scalars that do not derive
+ * from a TypeSpec std scalar, where no meaningful C# type name can be produced.
+ */
+export function tryGetServerScalarName($: Typekit, scalar: Scalar): string | undefined {
+ if (!$.scalar.getStdBase(scalar)) return undefined;
+ return getServerScalarName($, scalar);
+}
diff --git a/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx
index 712cbc6010f..0d6f98299e3 100644
--- a/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx
+++ b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx
@@ -7,7 +7,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing";
import { $ } from "@typespec/compiler/typekit";
import { Experimental_ComponentOverrides, Output } from "@typespec/emitter-framework";
import { beforeEach, describe, expect, it } from "vitest";
-import { createServerScalarOverrides, efRefkey, TypeExpression } from "./type-expression.jsx";
+import { createServerOverrides, efRefkey, TypeExpression } from "./type-expression.jsx";
let runner: TesterInstance;
@@ -17,7 +17,7 @@ beforeEach(async () => {
function Wrapper(props: { children: Children }) {
const policy = createCSharpNamePolicy();
- const overrides = createServerScalarOverrides($(runner.program));
+ const overrides = createServerOverrides($(runner.program));
return (