diff --git a/packages/graphql/src/components/graphql-schema.tsx b/packages/graphql/src/components/graphql-schema.tsx
new file mode 100644
index 00000000000..94b78461316
--- /dev/null
+++ b/packages/graphql/src/components/graphql-schema.tsx
@@ -0,0 +1,32 @@
+import { type Children } from "@alloy-js/core";
+import type { Program } from "@typespec/compiler";
+import { TspContext } from "@typespec/emitter-framework";
+import {
+ GraphQLSchemaContext,
+ type GraphQLSchemaContextValue,
+} from "../context/index.js";
+
+export interface GraphQLSchemaProps {
+ /** TypeSpec program instance */
+ program: Program;
+ /** Context value containing classified types and type maps */
+ contextValue: GraphQLSchemaContextValue;
+ /** Child components to render */
+ children?: Children;
+}
+
+/**
+ * Root component for GraphQL schema generation
+ *
+ * Provides TspContext (program + typekit) from @typespec/emitter-framework
+ * and GraphQL-specific context to all child components.
+ */
+export function GraphQLSchema(props: GraphQLSchemaProps) {
+ return (
+
+
+ {props.children}
+
+
+ );
+}
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..68a19ea1d60
--- /dev/null
+++ b/packages/graphql/src/components/types/enum-type.tsx
@@ -0,0 +1,34 @@
+import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+
+export interface EnumTypeProps {
+ /** The enum type to render */
+ type: Enum;
+}
+
+/**
+ * Renders a GraphQL enum type declaration with members
+ */
+export function EnumType(props: EnumTypeProps) {
+ const { program } = useTsp();
+ const doc = getDoc(program, props.type);
+ const members = Array.from(props.type.members.values());
+
+ return (
+
+ {members.map((member) => {
+ const memberDoc = getDoc(program, member);
+ const deprecation = getDeprecationDetails(program, member);
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts
new file mode 100644
index 00000000000..f0c8f56e711
--- /dev/null
+++ b/packages/graphql/src/components/types/index.ts
@@ -0,0 +1,3 @@
+export { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
+export { EnumType, type EnumTypeProps } from "./enum-type.js";
+export { UnionType, type UnionTypeProps } 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..8a8926e8e94
--- /dev/null
+++ b/packages/graphql/src/components/types/scalar-type.tsx
@@ -0,0 +1,27 @@
+import { type Scalar, getDoc } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { useGraphQLSchema } from "../../context/index.js";
+
+export interface ScalarTypeProps {
+ /** The scalar type to render */
+ type: Scalar;
+}
+
+/**
+ * Renders a GraphQL scalar type declaration with optional @specifiedBy directive
+ */
+export function ScalarType(props: ScalarTypeProps) {
+ const { program } = useTsp();
+ const { scalarSpecifications } = useGraphQLSchema();
+ const doc = getDoc(program, props.type);
+ const specificationUrl = scalarSpecifications.get(props.type.name);
+
+ 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..4e950106d8b
--- /dev/null
+++ b/packages/graphql/src/components/types/union-type.tsx
@@ -0,0 +1,50 @@
+import { type Union, getDoc } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { getUnionName, isScalarLikeType, toTypeName } from "../../lib/type-utils.js";
+
+export interface UnionTypeProps {
+ /** The union type to render */
+ type: Union;
+}
+
+/**
+ * Renders a GraphQL union type declaration
+ * Scalars are wrapped in object types since GraphQL unions can only contain object types
+ * This wrapping is done by the mutation engine
+ */
+export function UnionType(props: UnionTypeProps) {
+ const { program } = useTsp();
+ const name = getUnionName(props.type, program);
+ const doc = getDoc(program, props.type);
+ const variants = Array.from(props.type.variants.values());
+
+ // Build the union member list, using wrapper names for scalars
+ // The wrapper models are created by the mutation engine
+ const unionMembers = variants.map((variant) => {
+ const variantName =
+ typeof variant.name === "string" ? variant.name : String(variant.name);
+
+ if (isScalarLikeType(variant.type)) {
+ // Reference the wrapper type for scalars (created by mutation engine)
+ // Include union name to match wrapper model naming convention
+ return toTypeName(name) + toTypeName(variantName) + "UnionVariant";
+ } else {
+ // For non-scalars, use the type name directly
+ if (variant.type.kind === "Model") {
+ return variant.type.name;
+ } else if (
+ "name" in variant.type &&
+ typeof variant.type.name === "string"
+ ) {
+ return variant.type.name;
+ }
+ throw new Error(
+ `Unexpected union variant type kind "${variant.type.kind}" in union "${name}". ` +
+ `This is a bug in the GraphQL emitter.`,
+ );
+ }
+ });
+
+ return ;
+}
diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts
index 50fc9aba3ed..90123714379 100644
--- a/packages/graphql/src/lib/type-utils.ts
+++ b/packages/graphql/src/lib/type-utils.ts
@@ -318,6 +318,11 @@ function getTemplateStringInternal(
return args.length > 0 ? args.map(toTypeName).join(options.conjunction) : "";
}
+/** Check if a type is a scalar (built-in or custom) or an intrinsic type like `unknown`. */
+export function isScalarLikeType(type: Type): boolean {
+ return type.kind === "Scalar" || type.kind === "Intrinsic";
+}
+
/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */
export function isTrueModel(model: Model): boolean {
return !(
diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts
index 651cc1bb81f..d8115c70622 100644
--- a/packages/graphql/src/types.d.ts
+++ b/packages/graphql/src/types.d.ts
@@ -1,22 +1,5 @@
-import type { Diagnostic } from "@typespec/compiler";
-import type { GraphQLSchema } from "graphql";
-import type { Schema } from "./lib/schema.ts";
-
-/**
- * A record containing the GraphQL schema corresponding to
- * a particular schema definition.
- */
-export interface GraphQLSchemaRecord {
- /** The declared schema that generated this GraphQL schema */
- readonly schema: Schema;
-
- /** The GraphQLSchema */
- readonly graphQLSchema: GraphQLSchema;
-
- /** The diagnostics created for this schema */
- readonly diagnostics: readonly Diagnostic[];
-}
-
declare const tags: unique symbol;
-type Tagged = BaseType & { [tags]: { [K in Tag]: void } };
+export type Tagged = BaseType & {
+ [tags]: { [K in Tag]: void };
+};
diff --git a/packages/graphql/test/components/component-test-utils.tsx b/packages/graphql/test/components/component-test-utils.tsx
new file mode 100644
index 00000000000..a70bd980e77
--- /dev/null
+++ b/packages/graphql/test/components/component-test-utils.tsx
@@ -0,0 +1,50 @@
+import { type Children } from "@alloy-js/core";
+import * as gql from "@alloy-js/graphql";
+import { renderSchema, printSchema } from "@alloy-js/graphql";
+import type { Program } from "@typespec/compiler";
+import { GraphQLSchema } from "../../src/components/graphql-schema.js";
+import type { GraphQLSchemaContextValue } from "../../src/context/index.js";
+
+/**
+ * Renders GraphQL components in isolation and returns the printed SDL.
+ *
+ * Wraps children in the required context providers (TspContext + GraphQLSchemaContext)
+ * and always includes a placeholder Query type (required by graphql-js).
+ *
+ * Tests should assert on fragments of the returned SDL, ignoring the placeholder Query.
+ */
+export function renderComponentToSDL(
+ program: Program,
+ children: Children,
+ contextOverrides?: Partial,
+): string {
+ const contextValue: GraphQLSchemaContextValue = {
+ classifiedTypes: {
+ interfaces: [],
+ outputModels: [],
+ inputModels: [],
+ enums: [],
+ scalars: [],
+ scalarVariants: [],
+ unions: [],
+ queries: [],
+ mutations: [],
+ subscriptions: [],
+ },
+ modelVariants: { outputModels: new Map(), inputModels: new Map() },
+ scalarSpecifications: new Map(),
+ ...contextOverrides,
+ };
+
+ const schema = renderSchema(
+
+ {children}
+
+
+
+ ,
+ { namePolicy: null },
+ );
+
+ return printSchema(schema);
+}
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..418579ac461
--- /dev/null
+++ b/packages/graphql/test/components/enum-type.test.tsx
@@ -0,0 +1,107 @@
+import { t } from "@typespec/compiler/testing";
+import { describe, expect, it, beforeEach } 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 { renderComponentToSDL } from "./component-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 = renderComponentToSDL(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 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 = renderComponentToSDL(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 = renderComponentToSDL(tester.program, );
+
+ expect(sdl).toContain("Currently active");
+ expect(sdl).toContain("Active");
+ expect(sdl).toContain("No longer active");
+ expect(sdl).toContain("Inactive");
+ });
+
+ 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 = renderComponentToSDL(tester.program, );
+
+ expect(sdl).toContain("Active");
+ expect(sdl).toContain("Legacy");
+ expect(sdl).toContain("@deprecated");
+ expect(sdl).toContain("use Active instead");
+ });
+
+ it("renders enum with 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 = renderComponentToSDL(tester.program, );
+
+ expect(sdl).toContain("_val1_");
+ expect(sdl).toContain("val_2");
+ expect(sdl).not.toContain("$val1$");
+ expect(sdl).not.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..14da1e0b36a
--- /dev/null
+++ b/packages/graphql/test/components/scalar-type.test.tsx
@@ -0,0 +1,102 @@
+import { t } from "@typespec/compiler/testing";
+import { describe, expect, it, beforeEach } 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 { renderComponentToSDL } from "./component-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 mutation = engine.mutateScalar(DateTime);
+
+ const sdl = renderComponentToSDL(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 mutation = engine.mutateScalar(JSON);
+
+ const sdl = renderComponentToSDL(tester.program, );
+
+ expect(sdl).toContain("Arbitrary JSON blob");
+ expect(sdl).toContain("scalar JSON");
+ });
+
+ it("renders a scalar with @specifiedBy from context", 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 mutation = engine.mutateScalar(MyScalar);
+
+ // Build scalarSpecifications map like the emitter does
+ const specUrl = getSpecifiedBy(tester.program, mutation.mutatedType);
+ const scalarSpecifications = new Map();
+ if (specUrl) {
+ scalarSpecifications.set(mutation.mutatedType.name, specUrl);
+ }
+
+ const sdl = renderComponentToSDL(
+ tester.program,
+ ,
+ { scalarSpecifications },
+ );
+
+ expect(sdl).toContain("scalar MyScalar");
+ expect(sdl).toContain("@specifiedBy");
+ expect(sdl).toContain("https://example.com/spec");
+ });
+
+ it("renders a scalar without @specifiedBy when not in context", async () => {
+ const { MyScalar } = await tester.compile(
+ t.code`scalar ${t.scalar("MyScalar")} extends string;`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateScalar(MyScalar);
+
+ const sdl = renderComponentToSDL(tester.program, );
+
+ expect(sdl).toContain("scalar MyScalar");
+ expect(sdl).not.toContain("@specifiedBy");
+ });
+
+ it("renders a scalar with sanitized name", async () => {
+ await tester.compile(
+ t.code`scalar ${t.scalar("$Bad$")} extends string;`,
+ );
+
+ const BadScalar = tester.program.getGlobalNamespaceType().scalars.get("$Bad$")!;
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateScalar(BadScalar);
+
+ const sdl = renderComponentToSDL(tester.program, );
+
+ expect(sdl).toContain("scalar _Bad_");
+ expect(sdl).not.toContain("$Bad$");
+ });
+});
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..5b19b2e7d0a
--- /dev/null
+++ b/packages/graphql/test/components/union-type.test.tsx
@@ -0,0 +1,160 @@
+import { type Union } from "@typespec/compiler";
+import { t } from "@typespec/compiler/testing";
+import * as gql from "@alloy-js/graphql";
+import { describe, expect, it, beforeEach } from "vitest";
+import { UnionType } from "../../src/components/types/index.js";
+import {
+ createGraphQLMutationEngine,
+ type GraphQLUnionMutation,
+ GraphQLTypeContext,
+} from "../../src/mutation-engine/index.js";
+import { Tester } from "../test-host.js";
+import { renderComponentToSDL } from "./component-test-utils.js";
+
+/** Assert that an output-context union mutation produced a Union (not a Model). */
+function assertUnionResult(mutation: GraphQLUnionMutation): Union {
+ if (mutation.mutatedType.kind !== "Union") {
+ throw new Error(
+ `Expected Union from output-context mutation, got ${mutation.mutatedType.kind}`,
+ );
+ }
+ return mutation.mutatedType;
+}
+
+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 = assertUnionResult(mutation);
+
+ // Union members must be registered in the schema for buildSchema to resolve them
+ const sdl = renderComponentToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Pet =");
+ expect(sdl).toContain("Cat");
+ expect(sdl).toContain("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 = assertUnionResult(mutation);
+
+ const sdl = renderComponentToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("The result of an operation");
+ expect(sdl).toContain("union Result =");
+ });
+
+ it("renders a union with multiple 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 = assertUnionResult(mutation);
+
+ const sdl = renderComponentToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Shape =");
+ expect(sdl).toContain("Circle");
+ expect(sdl).toContain("Square");
+ expect(sdl).toContain("Triangle");
+ });
+
+ it("references wrapper type 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 = assertUnionResult(mutation);
+
+ // Register Cat and the wrapper type that the union will reference
+ const sdl = renderComponentToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ // Scalar variant should reference wrapper type name
+ expect(sdl).toContain("union Mixed =");
+ expect(sdl).toContain("MixedTextUnionVariant");
+ expect(sdl).toContain("Cat");
+ });
+});