Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .chronus/changes/ef-csharp-type-expression-total-2026-8-5.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
<Wrapper>
<TypeExpression type={type} />
</Wrapper>,
).toRenderTo(csType);
});

it("falls back to object instead of throwing", async () => {
const type = await compileType("int32 | boolean");
expect(
<Wrapper>
<TypeExpression type={type} />
</Wrapper>,
).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(
<Wrapper>
<EnumDeclaration type={Color} />
<hbr />
<TypeExpression type={test.type} />
</Wrapper>,
).toRenderTo(`
enum Color
{
red,
blue
}
Color
`);
});
124 changes: 74 additions & 50 deletions packages/emitter-framework/src/csharp/components/type-expression.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -21,50 +15,86 @@ export interface TypeExpressionProps {
export function TypeExpression(props: TypeExpressionProps): Children {
return (
<Experimental_OverridableComponent reference type={props.type}>
{() => {
if (props.type.kind === "Union") {
const nullabletype = getNullableUnionInnerType(props.type);
if (nullabletype) {
return code`${(<TypeExpression type={nullabletype} />)}?`;
}
}
const { $ } = useTsp();
if (isDeclaration($, props.type)) {
return <Reference refkey={efRefkey(props.type)} />;
}
if ($.scalar.is(props.type)) {
return getScalarIntrinsicExpression($, props.type);
} else if ($.array.is(props.type)) {
return code`${(<TypeExpression type={props.type.indexer.value} />)}[]`;
} else if ($.record.is(props.type)) {
return code`IDictionary<string, ${(<TypeExpression type={props.type.indexer.value} />)}>`;
} 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)})`,
);
}}
{() => <TypeExpressionBody type={props.type} />}
</Experimental_OverridableComponent>
);
}

/**
* 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 <TypeExpression type={type.type} />;

// 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 <TypeExpression type={type.enum} />;

case "Union": {
const innerType = getNullableUnionInnerType(type);
if (innerType) {
return code`${(<TypeExpression type={innerType} />)}?`;
}
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`${(<TypeExpression type={type.values[0]} />)}[]`
: code`object[]`;

case "StringTemplate":
return "string";

case "TemplateParameter":
return getTypeName(type);

case "Intrinsic":
return getScalarIntrinsicExpression($, type);
}

if (isDeclaration($, type)) {
return <Reference refkey={efRefkey(type)} />;
}
if ($.scalar.is(type)) {
return getScalarIntrinsicExpression($, type);
} else if ($.array.is(type)) {
return code`${(<TypeExpression type={type.indexer.value} />)}[]`;
} else if ($.record.is(type)) {
return code`IDictionary<string, ${(<TypeExpression type={type.indexer.value} />)}>`;
} 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<string, string | null>([
// 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
Expand Down Expand Up @@ -96,10 +126,7 @@ const intrinsicNameToCSharpType = new Map<string, string | null>([
["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)) {
Expand All @@ -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
}

Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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<string> = 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;
}
}
14 changes: 14 additions & 0 deletions packages/emitter-framework/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
});

Expand Down
Loading