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",
+ },
},
});