diff --git a/.chronus/changes/sramsey-csharp-duplicate-nullable-suffixes-2026-09-08.md b/.chronus/changes/sramsey-csharp-duplicate-nullable-suffixes-2026-09-08.md
new file mode 100644
index 00000000000..16727ede5e1
--- /dev/null
+++ b/.chronus/changes/sramsey-csharp-duplicate-nullable-suffixes-2026-09-08.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+ - "@typespec/http-server-csharp"
+---
+
+Prevent optional nullable value parameters from emitting duplicate nullable suffixes in generated C# interfaces and mocks.
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 a79b1f3ef76..8146aac19d6 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
@@ -8,7 +8,10 @@ import type { OperationHttpCanonicalization } from "@typespec/http-canonicalizat
import { AspNetMvc } from "../../utils/csharp-libs.jsx";
import { getHttpVerbAttribute, getRouteTemplate } from "../../utils/http-helpers.js";
import type { RequestModelInfo } from "../request-models.jsx";
-import { TypeExpression } from "../type-expression/type-expression.jsx";
+import {
+ getNullableValueTypeUnionInnerType,
+ TypeExpression,
+} from "../type-expression/type-expression.jsx";
import { getBindingAttribute, getLiteralDefaultValue } from "./parameter-binding.js";
import { getSuccessStatusCode } from "./response-analysis.js";
@@ -49,12 +52,15 @@ export function ControllerAction(props: ControllerActionProps): Children {
if (p.property.isContentTypeProperty) continue;
const isOptional = p.property.sourceType.optional;
const literalDefault = getLiteralDefaultValue(p.property.sourceType.type);
+ const nullableValueType = isOptional
+ ? getNullableValueTypeUnionInnerType($, p.property.sourceType.type)
+ : undefined;
if (p.kind === "path") {
const paramName = namePolicy.getName(p.property.sourceType.name, "parameter");
const attr = getBindingAttribute(p, paramName);
pathParams.push({
name: paramName,
- type: ,
+ type: ,
attributes: attr ? [attr] : undefined,
optional: isOptional,
default: literalDefault,
@@ -63,7 +69,7 @@ export function ControllerAction(props: ControllerActionProps): Children {
const attr = getBindingAttribute(p);
queryHeaderParams.push({
name: namePolicy.getName(p.property.sourceType.name, "parameter"),
- type: ,
+ type: ,
attributes: attr ? [attr] : undefined,
optional: isOptional,
default: literalDefault,
diff --git a/packages/http-server-csharp/src/components/interfaces/interfaces.test.tsx b/packages/http-server-csharp/src/components/interfaces/interfaces.test.tsx
index d48f245e5cf..de7ed1edac3 100644
--- a/packages/http-server-csharp/src/components/interfaces/interfaces.test.tsx
+++ b/packages/http-server-csharp/src/components/interfaces/interfaces.test.tsx
@@ -1,9 +1,10 @@
import { Tester } from "#test/tester.js";
import { type Children } from "@alloy-js/core";
-import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp";
+import { createCSharpNamePolicy, EnumDeclaration, SourceFile } from "@alloy-js/csharp";
import { t, type TesterInstance } from "@typespec/compiler/testing";
import { Output } from "@typespec/emitter-framework";
import { beforeEach, expect, it } from "vitest";
+import { efRefkey } from "../type-expression/type-expression.jsx";
import { BusinessLogicInterface } from "./interfaces.jsx";
let runner: TesterInstance;
@@ -61,3 +62,34 @@ it("renders an interface with void return type", async () => {
}
`);
});
+
+it("renders one nullable suffix for optional nullable value parameters", async () => {
+ const { Choice, PetStore } = await runner.compile(t.code`
+ enum ${t.enum("Choice")} {
+ one,
+ }
+
+ interface ${t.interface("PetStore")} {
+ update(value?: int32 | null, choice?: Choice | null): void;
+ }
+ `);
+
+ expect(
+
+
+ One
+
+
+
+ ,
+ ).toRenderTo(`
+ enum Choice
+ {
+ One
+ }
+ public interface IPetStore
+ {
+ Task UpdateAsync(int? value, Choice? choice);
+ }
+ `);
+});
diff --git a/packages/http-server-csharp/src/components/interfaces/interfaces.tsx b/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
index 6d9374001e0..5eecce71cb6 100644
--- a/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
+++ b/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
@@ -7,7 +7,10 @@ import { getDocComments } from "@typespec/emitter-framework/csharp";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { getUniqueItems } from "@typespec/json-schema";
import { getSuccessReturnType } from "../../utils/return-type-helpers.js";
-import { TypeExpression } from "../type-expression/type-expression.jsx";
+import {
+ getNullableValueTypeUnionInnerType,
+ TypeExpression,
+} from "../type-expression/type-expression.jsx";
const interfaceRefKeyPrefix = Symbol.for("http-server-csharp:interface");
@@ -129,6 +132,9 @@ function BusinessLogicMethod(props: BusinessLogicMethodProps): Children {
.map(([pName, prop]) => {
const isUnique = getUniqueItems($.program, prop);
const isArrayType = prop.type.kind === "Model" && $.array.is(prop.type);
+ const nullableValueType = prop.optional
+ ? getNullableValueTypeUnionInnerType($, prop.type)
+ : undefined;
let typeExpr: Children;
if (isUnique && isArrayType && prop.type.kind === "Model" && prop.type.indexer?.value) {
typeExpr = (
@@ -139,7 +145,7 @@ function BusinessLogicMethod(props: BusinessLogicMethodProps): Children {
>
);
} else {
- typeExpr = ;
+ typeExpr = ;
}
return {
name: namePolicy.getName(pName, "parameter"),
diff --git a/packages/http-server-csharp/src/components/scaffolding/mock-implementations.tsx b/packages/http-server-csharp/src/components/scaffolding/mock-implementations.tsx
index d987a801986..36195ed7325 100644
--- a/packages/http-server-csharp/src/components/scaffolding/mock-implementations.tsx
+++ b/packages/http-server-csharp/src/components/scaffolding/mock-implementations.tsx
@@ -4,7 +4,10 @@ import type { Interface, Operation, Program } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { CSharpFile } from "../csharp-file.jsx";
-import { TypeExpression } from "../type-expression/type-expression.jsx";
+import {
+ getNullableValueTypeUnionInnerType,
+ TypeExpression,
+} from "../type-expression/type-expression.jsx";
import {
getGetBodyPropNames,
getMockReturnStatement,
@@ -112,6 +115,7 @@ interface MockMethodsProps {
function MockMethods(props: MockMethodsProps): Children {
const namePolicy = cs.useCSharpNamePolicy();
+ const { $ } = useTsp();
return (
{([name, op]) => {
@@ -147,11 +151,16 @@ function MockMethods(props: MockMethodsProps): Children {
const parameters = Array.from(op.parameters.properties.entries())
.filter(([pName]) => !bodyPropNames.has(pName))
.filter(([pName]) => !multipartBodyPropNames.has(pName))
- .map(([pName, prop]) => ({
- name: namePolicy.getName(pName, "parameter"),
- type: ,
- optional: prop.optional,
- }))
+ .map(([pName, prop]) => {
+ const nullableValueType = prop.optional
+ ? getNullableValueTypeUnionInnerType($, prop.type)
+ : undefined;
+ return {
+ name: namePolicy.getName(pName, "parameter"),
+ type: ,
+ optional: prop.optional,
+ };
+ })
// Required parameters must come before optional ones in C#
.sort((a, b) => (a.optional === b.optional ? 0 : a.optional ? 1 : -1));
diff --git a/packages/http-server-csharp/src/components/type-expression/type-expression.tsx b/packages/http-server-csharp/src/components/type-expression/type-expression.tsx
index be6a9860b29..866ac105f9f 100644
--- a/packages/http-server-csharp/src/components/type-expression/type-expression.tsx
+++ b/packages/http-server-csharp/src/components/type-expression/type-expression.tsx
@@ -20,6 +20,23 @@ export interface TypeExpressionProps {
// Re-export efRefkey for consumers that were using serverRefkey
export { efRefkey } from "@typespec/emitter-framework/csharp";
+export function getNullableValueTypeUnionInnerType($: Typekit, type: Type): Type | undefined {
+ if (type.kind !== "Union" || isUnionEnum(type)) return undefined;
+ let current: Type = type;
+ const visited = new Set();
+
+ while (current.kind === "Union" && !isUnionEnum(current)) {
+ if (visited.has(current)) return undefined;
+ visited.add(current);
+
+ const innerType = getNullableUnionInnerType(current);
+ if (innerType === undefined) return undefined;
+ current = innerType;
+ }
+
+ return isValueType($, current) ? current : undefined;
+}
+
/**
* Wrapper around emitter-framework's TypeExpression that handles
* additional type kinds the server emitter encounters.
@@ -199,10 +216,11 @@ function resolveUnionType($: Typekit, union: import("@typespec/compiler").Union)
return code`object`;
}
// Nullable value type → T?
- if (isValueType($, innerType)) {
+ const nullableValueType = getNullableValueTypeUnionInnerType($, union);
+ if (nullableValueType) {
return (
<>
- ?
+ ?
>
);
}
diff --git a/packages/http-server-csharp/test/nullable-parameters.test.ts b/packages/http-server-csharp/test/nullable-parameters.test.ts
new file mode 100644
index 00000000000..db239f02c58
--- /dev/null
+++ b/packages/http-server-csharp/test/nullable-parameters.test.ts
@@ -0,0 +1,52 @@
+import { expect, it } from "vitest";
+import { EmitterTester, getStandardService } from "./test-host.js";
+
+it("emits one nullable suffix for optional nullable value parameters", async () => {
+ const { outputs } = await EmitterTester.compile(
+ getStandardService(`
+ enum Choice {
+ one,
+ }
+
+ union MaybeInt {
+ int32,
+ null,
+ }
+
+ @route("/nullable")
+ interface NullableParameters {
+ @get test(
+ @query value?: int32 | null,
+ @query choice?: Choice | null,
+ @query maybeInt?: MaybeInt | null,
+ ): void;
+ }
+ `),
+ {
+ compilerOptions: {
+ options: {
+ "@typespec/http-server-csharp": {
+ "emit-mocks": "mocks-only",
+ "skip-format": true,
+ },
+ },
+ },
+ },
+ );
+
+ const interfaceContent = outputs["generated/operations/INullableParameters.cs"];
+ const mockContent = outputs["mocks/NullableParameters.cs"];
+ const controllerContent = outputs["generated/controllers/NullableParametersController.cs"];
+
+ expect(interfaceContent).toBeDefined();
+ expect(mockContent).toBeDefined();
+ expect(controllerContent).toBeDefined();
+ expect(interfaceContent).toContain("TestAsync(int? value, Choice? choice, int? maybeInt)");
+ expect(mockContent).toContain("TestAsync(int? value, Choice? choice, int? maybeInt)");
+ expect(controllerContent).toContain("int? value");
+ expect(controllerContent).toContain("Choice? choice");
+ expect(controllerContent).toContain("int? maybeInt");
+ expect(interfaceContent).not.toMatch(/\w+\?\?\s+\w+/);
+ expect(mockContent).not.toMatch(/\w+\?\?\s+\w+/);
+ expect(controllerContent).not.toMatch(/\w+\?\?\s+\w+/);
+});