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