diff --git a/.chronus/changes/sramsey-csharp-server-union-failure-2026-5-17-14-5-13.md b/.chronus/changes/sramsey-csharp-server-union-failure-2026-5-17-14-5-13.md
new file mode 100644
index 00000000000..9619c70081f
--- /dev/null
+++ b/.chronus/changes/sramsey-csharp-server-union-failure-2026-5-17-14-5-13.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+ - "@typespec/http-server-csharp"
+---
+
+Update the union definition to include unnamed string literals and null
\ No newline at end of file
diff --git a/packages/http-server-csharp/src/components/enums/enums.test.tsx b/packages/http-server-csharp/src/components/enums/enums.test.tsx
index e0583855c93..811d2cb2c2e 100644
--- a/packages/http-server-csharp/src/components/enums/enums.test.tsx
+++ b/packages/http-server-csharp/src/components/enums/enums.test.tsx
@@ -1,10 +1,18 @@
import { Tester } from "#test/tester.js";
import { type Children } from "@alloy-js/core";
-import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp";
+import {
+ Attribute,
+ createCSharpNamePolicy,
+ EnumDeclaration as CsEnumDeclaration,
+ EnumMember,
+ SourceFile,
+} from "@alloy-js/csharp";
+import { type Union } from "@typespec/compiler";
import { t, type TesterInstance } from "@typespec/compiler/testing";
import { Output } from "@typespec/emitter-framework";
import { EnumDeclaration } from "@typespec/emitter-framework/csharp";
import { beforeEach, describe, expect, it } from "vitest";
+import { getUnionEnumMembers, isUnionEnum } from "./enums.jsx";
let runner: TesterInstance;
@@ -21,6 +29,26 @@ function Wrapper(props: { children: Children }) {
);
}
+/**
+ * Renders a union-as-enum using the same logic as the Enums component,
+ * but without the file/namespace/useTsp wrapping.
+ */
+function UnionEnumDecl(props: { union: Union }) {
+ const members = getUnionEnumMembers(props.union);
+ return (
+
+ {members.map((member, i) => (
+ <>
+
+ {"\n"}
+
+ {i < members.length - 1 ? ",\n" : ""}
+ >
+ ))}
+
+ );
+}
+
describe("EnumDeclaration", () => {
it("renders a simple enum", async () => {
const { Color } = await runner.compile(t.code`
@@ -97,3 +125,188 @@ describe("EnumDeclaration", () => {
`);
});
});
+
+describe("isUnionEnum", () => {
+ it("returns true for extensible union with string base and named variants", async () => {
+ const { ReasoningEffort } = await runner.compile(t.code`
+ union ${t.union("ReasoningEffort")} {
+ string,
+ none: "none",
+ low: "low",
+ medium: "medium",
+ high: "high",
+ }
+ `);
+
+ expect(isUnionEnum(ReasoningEffort)).toBe(true);
+ });
+
+ it("returns true for fixed union with named variants only", async () => {
+ const { Priority } = await runner.compile(t.code`
+ union ${t.union("Priority")} {
+ low: "low",
+ medium: "medium",
+ high: "high",
+ }
+ `);
+
+ expect(isUnionEnum(Priority)).toBe(true);
+ });
+
+ it("returns false for union with non-string variant types", async () => {
+ const { Mixed } = await runner.compile(t.code`
+ model Foo { x: string; }
+ union ${t.union("Mixed")} {
+ Foo,
+ "bar",
+ }
+ `);
+
+ expect(isUnionEnum(Mixed)).toBe(false);
+ });
+});
+
+describe("union-as-enum rendering", () => {
+ it("renders union with unnamed string literals", async () => {
+ const { Priority } = await runner.compile(t.code`
+ union ${t.union("Priority")} {
+ "low",
+ "medium",
+ "high",
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ public enum Priority
+ {
+ [JsonStringEnumMemberName("low")]
+ Low,
+ [JsonStringEnumMemberName("medium")]
+ Medium,
+ [JsonStringEnumMemberName("high")]
+ High
+ }
+ `);
+ });
+
+ it("renders union with unnamed string literals and null (null is skipped)", async () => {
+ const { ReasoningEffort } = await runner.compile(t.code`
+ union ${t.union("ReasoningEffort")} {
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ null,
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ public enum ReasoningEffort
+ {
+ [JsonStringEnumMemberName("none")]
+ None,
+ [JsonStringEnumMemberName("minimal")]
+ Minimal,
+ [JsonStringEnumMemberName("low")]
+ Low,
+ [JsonStringEnumMemberName("medium")]
+ Medium,
+ [JsonStringEnumMemberName("high")]
+ High
+ }
+ `);
+ });
+
+ it("renders union with named variants and null (null is skipped)", async () => {
+ const { ReasoningEffort } = await runner.compile(t.code`
+ union ${t.union("ReasoningEffort")} {
+ none: "none",
+ medium: "medium",
+ high: "high",
+ null,
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ public enum ReasoningEffort
+ {
+ [JsonStringEnumMemberName("none")]
+ None,
+ [JsonStringEnumMemberName("medium")]
+ Medium,
+ [JsonStringEnumMemberName("high")]
+ High
+ }
+ `);
+ });
+
+ it("renders extensible union with string base, named variants, and null", async () => {
+ const { ReasoningEffort } = await runner.compile(t.code`
+ union ${t.union("ReasoningEffort")} {
+ string,
+ none: "none",
+ medium: "medium",
+ high: "high",
+ null,
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ public enum ReasoningEffort
+ {
+ [JsonStringEnumMemberName("none")]
+ None,
+ [JsonStringEnumMemberName("medium")]
+ Medium,
+ [JsonStringEnumMemberName("high")]
+ High
+ }
+ `);
+ });
+
+ it("renders union with inline anonymous union of string literals and null", async () => {
+ const { ReasoningEffort } = await runner.compile(t.code`
+ union ${t.union("ReasoningEffort")} {
+ "none" | "minimal" | "low" | "medium" | "high",
+ null,
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ public enum ReasoningEffort
+ {
+ [JsonStringEnumMemberName("none")]
+ None,
+ [JsonStringEnumMemberName("minimal")]
+ Minimal,
+ [JsonStringEnumMemberName("low")]
+ Low,
+ [JsonStringEnumMemberName("medium")]
+ Medium,
+ [JsonStringEnumMemberName("high")]
+ High
+ }
+ `);
+ });
+});
diff --git a/packages/http-server-csharp/src/components/enums/enums.tsx b/packages/http-server-csharp/src/components/enums/enums.tsx
index 9f3be70673b..8fdcf3d9fa7 100644
--- a/packages/http-server-csharp/src/components/enums/enums.tsx
+++ b/packages/http-server-csharp/src/components/enums/enums.tsx
@@ -133,35 +133,62 @@ export function Enums(props: EnumsProps): Children {
);
}
+/**
+ * Returns true if an anonymous inline union contains only string literal variants.
+ */
+function isInlineStringLiteralUnion(type: Type): boolean {
+ if (type.kind !== "Union" || type.name) return false;
+ for (const variant of type.variants.values()) {
+ if (variant.type.kind !== "String") return false;
+ }
+ return type.variants.size > 0;
+}
+
/**
* Returns true if a named union can be represented as a C# enum.
* Requires: named union, every named variant has a string value,
- * and optionally one unnamed scalar `string` variant (open/extensible).
+ * and optionally one unnamed scalar `string` variant (open/extensible)
+ * and/or a `null` variant. Also supports inline anonymous unions of
+ * string literals (e.g., `"a" | "b" | "c"` as a single variant).
*/
export function isUnionEnum(union: Union): boolean {
if (!union.name) return false;
const variants = Array.from(union.variants.values());
- let hasNamedStringVariant = false;
+ let hasStringVariant = false;
for (const variant of variants) {
// Allow a single open string scalar variant (extensible union)
if (variant.type.kind === "Scalar" && variant.type.name === "string") {
continue;
}
+ // Allow null variant (nullable union)
+ if (variant.type.kind === "Intrinsic" && variant.type.name === "null") {
+ continue;
+ }
// Named variant with a string literal value
if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") {
- hasNamedStringVariant = true;
+ hasStringVariant = true;
+ continue;
+ }
+ // Unnamed variant with a string literal value (e.g., union { "low", "medium", "high" })
+ if (variant.type.kind === "String" && typeof variant.name === "symbol") {
+ hasStringVariant = true;
+ continue;
+ }
+ // Inline anonymous union of string literals (e.g., "a" | "b" | "c" as a single variant)
+ if (isInlineStringLiteralUnion(variant.type)) {
+ hasStringVariant = true;
continue;
}
// Any other variant type means it's not a simple enum
return false;
}
- return hasNamedStringVariant;
+ return hasStringVariant;
}
-/** Gets the named string variants of a union-as-enum (skipping the open `string` variant). */
+/** Gets the named string variants of a union-as-enum (skipping the open `string` and `null` variants). */
export function getUnionEnumMembers(
union: Union,
): { name: string; value: string; variant: import("@typespec/compiler").UnionVariant }[] {
@@ -172,7 +199,22 @@ export function getUnionEnumMembers(
}[] = [];
for (const variant of union.variants.values()) {
if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") {
+ // Named variant with explicit key (e.g., none: "none")
members.push({ name: variant.name, value: variant.type.value, variant });
+ } else if (variant.type.kind === "String" && typeof variant.name === "symbol") {
+ // Unnamed string literal variant (e.g., "none") — derive name from the value
+ members.push({ name: variant.type.value, value: variant.type.value, variant });
+ } else if (isInlineStringLiteralUnion(variant.type)) {
+ // Inline anonymous union of string literals — flatten into individual members
+ for (const innerVariant of (variant.type as Union).variants.values()) {
+ if (innerVariant.type.kind === "String") {
+ members.push({
+ name: innerVariant.type.value,
+ value: innerVariant.type.value,
+ variant: innerVariant,
+ });
+ }
+ }
}
}
return members;