diff --git a/packages/graphql/src/components/types/enum-type.tsx b/packages/graphql/src/components/types/enum-type.tsx
new file mode 100644
index 00000000000..8b98089d2d1
--- /dev/null
+++ b/packages/graphql/src/components/types/enum-type.tsx
@@ -0,0 +1,25 @@
+import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+
+export interface EnumTypeProps {
+ type: Enum;
+}
+
+export function EnumType(props: EnumTypeProps) {
+ const { program } = useTsp();
+ const doc = getDoc(program, props.type);
+ const members = [...props.type.members.values()];
+
+ return (
+
+ {members.map((member) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts
new file mode 100644
index 00000000000..00f4a951e33
--- /dev/null
+++ b/packages/graphql/src/components/types/index.ts
@@ -0,0 +1,3 @@
+export { EnumType, type EnumTypeProps } from "./enum-type.js";
+export { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
+export { UnionType, type UnionTypeProps, type GraphQLUnion } from "./union-type.js";
diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx
new file mode 100644
index 00000000000..4ea4410fbc6
--- /dev/null
+++ b/packages/graphql/src/components/types/scalar-type.tsx
@@ -0,0 +1,21 @@
+import { type Scalar, getDoc } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+
+export interface ScalarTypeProps {
+ type: Scalar;
+ specificationUrl?: string;
+}
+
+export function ScalarType(props: ScalarTypeProps) {
+ const { program } = useTsp();
+ const doc = getDoc(program, props.type);
+
+ return (
+
+ );
+}
diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx
new file mode 100644
index 00000000000..387861f03b1
--- /dev/null
+++ b/packages/graphql/src/components/types/union-type.tsx
@@ -0,0 +1,24 @@
+import { type Model, type Union, getDoc } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+
+/**
+ * A Union guaranteed to have a name after mutation.
+ * The mutation engine ensures this: anonymous unions get derived names.
+ */
+export interface GraphQLUnion extends Union {
+ name: string;
+}
+
+export interface UnionTypeProps {
+ type: GraphQLUnion;
+}
+
+export function UnionType(props: UnionTypeProps) {
+ const { program } = useTsp();
+ const doc = getDoc(program, props.type);
+ const variants = [...props.type.variants.values()];
+ const members = variants.map((v) => (v.type as Model).name);
+
+ return ;
+}
diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts
index 9b9946e4513..4e4033a206a 100644
--- a/packages/graphql/src/lib.ts
+++ b/packages/graphql/src/lib.ts
@@ -162,6 +162,12 @@ export const libDef = {
default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`,
},
},
+ "type-name-collision": {
+ severity: "error",
+ messages: {
+ default: paramMessage`Type "${"name"}" collides with another type of the same name in the GraphQL schema. Consider renaming one of the types.`,
+ },
+ },
"empty-schema": {
severity: "warning",
messages: {
diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts
index e8ee273d9c2..ddc53652d6c 100644
--- a/packages/graphql/src/mutation-engine/mutations/union.ts
+++ b/packages/graphql/src/mutation-engine/mutations/union.ts
@@ -159,31 +159,8 @@ export class GraphQLUnionMutation extends UnionMutation {
- return tk.unionVariant.create({
- name: variantNameToString(variant.name),
- type: variant.type,
- });
- });
-
- const flattenedUnion = tk.union.create({
- name: unionName,
- variants: variantArray,
- });
-
- this.#flattenedUnion = flattenedUnion;
- } else {
- this.#mutationNode.mutate((union) => {
- union.name = unionName;
- });
- }
-
- if (hasNull) {
- setNullable(this.mutatedType);
- }
-
// GraphQL unions can only contain object types — wrap scalars in synthetic models
+ // and substitute the wrapper into the variant so the union is self-contained.
for (const variant of mutatedVariants) {
const isScalar = variant.type.kind === "Scalar" || variant.type.kind === "Intrinsic";
@@ -204,8 +181,37 @@ export class GraphQLUnionMutation extends UnionMutation 0) {
+ const variantArray = mutatedVariants.map((variant) => {
+ return tk.unionVariant.create({
+ name: variantNameToString(variant.name),
+ type: variant.type,
+ });
+ });
+
+ const flattenedUnion = tk.type.clone(this.sourceType);
+ flattenedUnion.name = unionName;
+ flattenedUnion.variants.clear();
+ for (const variant of variantArray) {
+ flattenedUnion.variants.set(variant.name, variant);
+ variant.union = flattenedUnion;
+ }
+ tk.type.finishType(flattenedUnion);
+
+ this.#flattenedUnion = flattenedUnion;
+ } else {
+ this.#mutationNode.mutate((union) => {
+ union.name = unionName;
+ });
+ }
+
+ if (hasNull) {
+ setNullable(this.mutatedType);
+ }
}
/**
diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts
index ee6559028cb..cc0c6978f20 100644
--- a/packages/graphql/src/mutation-engine/schema-mutator.ts
+++ b/packages/graphql/src/mutation-engine/schema-mutator.ts
@@ -11,7 +11,8 @@ import {
type Union,
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
-import type { TypeUsageResolver } from "../type-usage.js";
+import { reportDiagnostic } from "../lib.js";
+import { GraphQLTypeUsage, type TypeUsageResolver } from "../type-usage.js";
import type { GraphQLMutationEngine } from "./engine.js";
import { GraphQLTypeContext } from "./options.js";
import { buildTypeGraph, type TypeGraph } from "./type-graph.js";
@@ -22,6 +23,9 @@ import { buildTypeGraph, type TypeGraph } from "./type-graph.js";
*
* Filtering (unreachable types, array models, nullable unions) happens here
* so the engine only processes types that belong in the schema.
+ *
+ * Models used as both input and output get two mutations (Output and Input),
+ * producing separate entries in the TypeGraph (e.g., `Book` and `BookInput`).
*/
export function mutateSchema(
program: Program,
@@ -37,8 +41,18 @@ export function mutateSchema(
if (isArrayModelType(node)) return;
if (typeUsage.isUnreachable(node)) return;
- const mutation = engine.mutateModel(node, GraphQLTypeContext.Output);
- mutatedTypes.push(mutation.mutatedType);
+ const usage = typeUsage.getUsage(node);
+ const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false;
+ const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false;
+
+ if (usedAsOutput || !usage) {
+ const mutation = engine.mutateModel(node, GraphQLTypeContext.Output);
+ mutatedTypes.push(mutation.mutatedType);
+ }
+ if (usedAsInput) {
+ const mutation = engine.mutateModel(node, GraphQLTypeContext.Input);
+ mutatedTypes.push(mutation.mutatedType);
+ }
},
enum: (node: Enum) => {
if (typeUsage.isUnreachable(node)) return;
@@ -66,5 +80,20 @@ export function mutateSchema(
},
});
+ const seen = new Map();
+ for (const type of mutatedTypes) {
+ if (!("name" in type) || !type.name) continue;
+ const name = type.name as string;
+ if (seen.has(name)) {
+ reportDiagnostic(program, {
+ code: "type-name-collision",
+ format: { name },
+ target: type,
+ });
+ } else {
+ seen.set(name, type);
+ }
+ }
+
return buildTypeGraph(program, tk, mutatedTypes);
}
diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx
new file mode 100644
index 00000000000..dbc52639e86
--- /dev/null
+++ b/packages/graphql/test/components/enum-type.test.tsx
@@ -0,0 +1,102 @@
+import { t } from "@typespec/compiler/testing";
+import { beforeEach, describe, expect, it } from "vitest";
+import { EnumType } from "../../src/components/types/index.js";
+import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js";
+import { Tester } from "../test-host.js";
+import { renderToSDL } from "./test-utils.js";
+
+describe("EnumType component", () => {
+ let tester: Awaited>;
+ beforeEach(async () => {
+ tester = await Tester.createInstance();
+ });
+
+ it("renders a basic enum", async () => {
+ const { Color } = await tester.compile(
+ t.code`enum ${t.enum("Color")} { Red, Green, Blue }`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Color).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("enum Color");
+ expect(sdl).toContain("RED");
+ expect(sdl).toContain("GREEN");
+ expect(sdl).toContain("BLUE");
+ });
+
+ it("renders enum with doc comment as description", async () => {
+ const { Role } = await tester.compile(
+ t.code`
+ /** The role a user can have */
+ enum ${t.enum("Role")} { Admin, User }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Role).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain('"The role a user can have"');
+ expect(sdl).toContain("enum Role");
+ });
+
+ it("renders enum with member descriptions", async () => {
+ const { Status } = await tester.compile(
+ t.code`
+ enum ${t.enum("Status")} {
+ /** Currently active */
+ Active,
+ /** No longer active */
+ Inactive,
+ }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Status).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain('"Currently active"');
+ expect(sdl).toContain('"No longer active"');
+ });
+
+ it("renders enum with deprecated members", async () => {
+ const { Status } = await tester.compile(
+ t.code`
+ enum ${t.enum("Status")} {
+ Active,
+ #deprecated "use Active instead"
+ Legacy,
+ }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Status).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("@deprecated");
+ expect(sdl).toContain("use Active instead");
+ });
+
+ it("renders enum with mutation-engine-sanitized member names", async () => {
+ const { E } = await tester.compile(
+ t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(E).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ // Mutation engine: sanitize → CONSTANT_CASE
+ expect(sdl).toContain("_VAL_1");
+ expect(sdl).toContain("VAL_2");
+ });
+});
diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx
new file mode 100644
index 00000000000..d71ed1b89bf
--- /dev/null
+++ b/packages/graphql/test/components/scalar-type.test.tsx
@@ -0,0 +1,79 @@
+import { t } from "@typespec/compiler/testing";
+import { beforeEach, describe, expect, it } from "vitest";
+import { ScalarType } from "../../src/components/types/index.js";
+import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js";
+import { getSpecifiedBy } from "../../src/lib/specified-by.js";
+import { Tester } from "../test-host.js";
+import { renderToSDL } from "./test-utils.js";
+
+describe("ScalarType component", () => {
+ let tester: Awaited>;
+ beforeEach(async () => {
+ tester = await Tester.createInstance();
+ });
+
+ it("renders a custom scalar", async () => {
+ const { DateTime } = await tester.compile(
+ t.code`scalar ${t.scalar("DateTime")} extends string;`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(DateTime).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("scalar DateTime");
+ });
+
+ it("renders a scalar with doc comment description", async () => {
+ const { JSON } = await tester.compile(
+ t.code`
+ /** Arbitrary JSON blob */
+ scalar ${t.scalar("JSON")} extends string;
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(JSON).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain('"Arbitrary JSON blob"');
+ expect(sdl).toContain("scalar JSON");
+ });
+
+ it("renders a scalar with @specifiedBy", async () => {
+ const { MyScalar } = await tester.compile(
+ t.code`
+ @specifiedBy("https://example.com/spec")
+ scalar ${t.scalar("MyScalar")} extends string;
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(MyScalar).mutatedType;
+ const specUrl = getSpecifiedBy(tester.program, mutated);
+
+ const sdl = renderToSDL(
+ tester.program,
+ ,
+ );
+
+ expect(sdl).toContain("@specifiedBy");
+ expect(sdl).toContain("https://example.com/spec");
+ });
+
+ it("renders a scalar without @specifiedBy when not present", async () => {
+ const { MyScalar } = await tester.compile(
+ t.code`scalar ${t.scalar("MyScalar")} extends string;`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(MyScalar).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("scalar MyScalar");
+ expect(sdl).not.toContain("@specifiedBy");
+ });
+});
diff --git a/packages/graphql/test/components/test-utils.tsx b/packages/graphql/test/components/test-utils.tsx
new file mode 100644
index 00000000000..e88af9200e5
--- /dev/null
+++ b/packages/graphql/test/components/test-utils.tsx
@@ -0,0 +1,35 @@
+import { type Children } from "@alloy-js/core";
+import * as gql from "@alloy-js/graphql";
+import { renderSchema } from "@alloy-js/graphql";
+import { printSchema } from "graphql";
+import type { Program } from "@typespec/compiler";
+import { TspContext } from "@typespec/emitter-framework";
+import { GraphQLSchemaContext } from "../../src/context/index.js";
+import type { TypeGraph } from "../../src/mutation-engine/type-graph.js";
+
+/**
+ * Render GraphQL components in isolation and return SDL string.
+ * Wraps children in required context providers and adds a placeholder Query
+ * (graphql-js requires at least one query field).
+ */
+export function renderToSDL(program: Program, children: Children): string {
+ const typeGraph: TypeGraph = {
+ globalNamespace: program.getGlobalNamespaceType(),
+ };
+
+ const schema = renderSchema(
+
+
+ {children}
+
+
+
+
+ ,
+ { namePolicy: null },
+ );
+
+ // Cast needed: alloy uses graphql@17-alpha internally, our package uses graphql@16.
+ // At runtime both are deduped via vitest config; the type mismatch is superficial.
+ return printSchema(schema as any);
+}
diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx
new file mode 100644
index 00000000000..897bdd67d48
--- /dev/null
+++ b/packages/graphql/test/components/union-type.test.tsx
@@ -0,0 +1,140 @@
+import { t } from "@typespec/compiler/testing";
+import * as gql from "@alloy-js/graphql";
+import { beforeEach, describe, expect, it } from "vitest";
+import { UnionType, type GraphQLUnion } from "../../src/components/types/index.js";
+import {
+ createGraphQLMutationEngine,
+ GraphQLTypeContext,
+} from "../../src/mutation-engine/index.js";
+import { Tester } from "../test-host.js";
+import { renderToSDL } from "./test-utils.js";
+
+describe("UnionType component", () => {
+ let tester: Awaited>;
+ beforeEach(async () => {
+ tester = await Tester.createInstance();
+ });
+
+ it("renders a union of model types", async () => {
+ const { Pet } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ model ${t.model("Dog")} { breed: string; }
+ union ${t.union("Pet")} { cat: Cat; dog: Dog; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ // Union members must be registered for graphql-js to validate the schema
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Pet = Cat | Dog");
+ });
+
+ it("renders a union with doc comment description", async () => {
+ const { Result } = await tester.compile(
+ t.code`
+ model ${t.model("Success")} { value: string; }
+ model ${t.model("Failure")} { message: string; }
+ /** The result of an operation */
+ union ${t.union("Result")} { success: Success; failure: Failure; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain('"The result of an operation"');
+ expect(sdl).toContain("union Result = Success | Failure");
+ });
+
+ it("references wrapper model names for scalar variants", async () => {
+ const { Mixed } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ union ${t.union("Mixed")} { cat: Cat; text: string; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ // Register the wrapper model and Cat so graphql-js can validate
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Mixed = Cat | MixedTextUnionVariant");
+ });
+
+ it("renders a union with three model members", async () => {
+ const { Shape } = await tester.compile(
+ t.code`
+ model ${t.model("Circle")} { radius: float32; }
+ model ${t.model("Square")} { side: float32; }
+ model ${t.model("Triangle")} { base: float32; }
+ union ${t.union("Shape")} { circle: Circle; square: Square; triangle: Triangle; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Shape = Circle | Square | Triangle");
+ });
+});
diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts
index c69327594a2..5fd9684c287 100644
--- a/packages/graphql/test/mutation-engine/schema-mutator.test.ts
+++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts
@@ -150,6 +150,88 @@ describe("mutateSchema", () => {
expect(typeGraph.globalNamespace.models.has("MixedNumUnionVariant")).toBe(true);
});
+ it("produces Input variant for models used as operation parameters", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Book")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ op ${t.op("createBook")}(input: Book): Book;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ // Book is used as both output (return) and input (parameter),
+ // so both variants should appear in the TypeGraph
+ expect(typeGraph.globalNamespace.models.has("Book")).toBe(true);
+ expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(true);
+ });
+
+ it("does not produce Input variant for output-only models", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Book")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.models.has("Book")).toBe(true);
+ expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(false);
+ });
+
+ it("does not produce Output variant for input-only models", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Book")} { title: string; }
+ model ${t.model("Payload")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ op ${t.op("createBook")}(input: Payload): Book;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, true);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ // Payload is only used as input — should only appear as Input variant (PayloadInput)
+ expect(typeGraph.globalNamespace.models.has("PayloadInput")).toBe(true);
+ // Should NOT have an Output variant
+ expect(typeGraph.globalNamespace.models.has("Payload")).toBe(false);
+ });
+
+ it("reports diagnostic when two types produce the same GraphQL name", async () => {
+ const [_, diagnostics] = await tester.compileAndDiagnose(
+ t.code`
+ model ${t.model("BookInput")} { x: int32; }
+ model ${t.model("Book")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ op ${t.op("createBook")}(input: Book): Book;
+ `,
+ );
+
+ // Book used as input → Input mutation → "BookInput"
+ // BookInput declared explicitly → Output mutation → "BookInput"
+ // This should produce a collision diagnostic
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ mutateSchema(tester.program, engine, ns, typeUsage);
+
+ const collisions = tester.program.diagnostics.filter(
+ (d) => d.code === "@typespec/graphql/type-name-collision",
+ );
+ expect(collisions.length).toBeGreaterThan(0);
+ });
+
it("skips array models (they are list types, not object types)", async () => {
await tester.compile(
t.code`
diff --git a/packages/graphql/test/mutation-engine/unions.test.ts b/packages/graphql/test/mutation-engine/unions.test.ts
index 08c2ba8d4df..723d2d01637 100644
--- a/packages/graphql/test/mutation-engine/unions.test.ts
+++ b/packages/graphql/test/mutation-engine/unions.test.ts
@@ -1,4 +1,4 @@
-import type { Model, Union } from "@typespec/compiler";
+import { getDoc, type Model, type Union } from "@typespec/compiler";
import { t } from "@typespec/compiler/testing";
import { beforeEach, describe, expect, it } from "vitest";
import { isNullable } from "../../src/lib/nullable.js";
@@ -71,6 +71,32 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(mutation.wrapperModels[0].name).toBe("MixedTextUnionVariant");
});
+ it("substitutes wrapper models into union variant types", async () => {
+ const { Mixed } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ union ${t.union("Mixed")} { cat: Cat; text: string; }
+ `,
+ );
+
+ const engine = createTestEngine(tester.program);
+ const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as Union;
+
+ const variants = [...mutatedUnion.variants.values()];
+ expect(variants).toHaveLength(2);
+
+ // Model variant points to the mutated model
+ const catVariant = variants.find((v) => v.name === "cat")!;
+ expect(catVariant.type.kind).toBe("Model");
+ expect((catVariant.type as Model).name).toBe("Cat");
+
+ // Scalar variant points to the wrapper model, not the raw scalar
+ const textVariant = variants.find((v) => v.name === "text")!;
+ expect(textVariant.type.kind).toBe("Model");
+ expect((textVariant.type as Model).name).toBe("MixedTextUnionVariant");
+ });
+
it("does not create wrappers for model-only unions", async () => {
const { Pet } = await tester.compile(
t.code`
@@ -111,6 +137,13 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(mutation.wrapperModels).toHaveLength(2);
const names = mutation.wrapperModels.map((m) => m.name).sort();
expect(names).toEqual(["MixedCountUnionVariant", "MixedTextUnionVariant"]);
+
+ // All union variants point to Models (originals or wrappers)
+ const mutatedUnion = mutation.mutatedType as Union;
+ const variants = [...mutatedUnion.variants.values()];
+ for (const variant of variants) {
+ expect(variant.type.kind).toBe("Model");
+ }
});
it("names anonymous return type union as OperationUnion", async () => {
@@ -185,6 +218,22 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(variantNames).toEqual(["AdAccount", "Board"]);
});
+ it("preserves decorator state (e.g. @doc) on flattened unions", async () => {
+ const { MaybePet } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ model ${t.model("Dog")} { breed: string; }
+ /** A pet or nothing */
+ union ${t.union("MaybePet")} { cat: Cat; dog: Dog; null; }
+ `,
+ );
+
+ const engine = createTestEngine(tester.program);
+ const mutation = engine.mutateUnion(MaybePet, GraphQLTypeContext.Output);
+
+ expect(getDoc(tester.program, mutation.mutatedType)).toBe("A pet or nothing");
+ });
+
it("T | null replacement gets its mutation pipeline applied", async () => {
await tester.compile(
t.code`
diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts
index b45007f3475..c35108ef7bb 100644
--- a/packages/graphql/vitest.config.ts
+++ b/packages/graphql/vitest.config.ts
@@ -10,5 +10,9 @@ export default mergeConfig(
sourcemap: "both",
},
plugins: [alloyPlugin()],
+ resolve: {
+ conditions: ["development"],
+ dedupe: ["@alloy-js/core", "graphql"],
+ },
}),
);