diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index 23e32a7590c..aa3715047ac 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -30,15 +30,16 @@
"node": ">=20.0.0"
},
"dependencies": {
- "@alloy-js/core": "^0.11.0",
- "@alloy-js/typescript": "^0.11.0",
+ "@alloy-js/core": "^0.22.0",
+ "@alloy-js/graphql": "^0.1.0",
+ "@alloy-js/typescript": "^0.22.0",
"change-case": "^5.4.4",
"graphql": "^16.9.0"
},
"scripts": {
"clean": "rimraf ./dist ./temp",
- "build": "tsc -p .",
- "watch": "tsc --watch",
+ "build": "alloy build",
+ "watch": "alloy build --watch",
"test": "vitest run",
"test:watch": "vitest -w",
"lint": "eslint . --max-warnings=0",
@@ -56,6 +57,8 @@
"@typespec/mutator-framework": "workspace:~"
},
"devDependencies": {
+ "@alloy-js/cli": "^0.22.0",
+ "@alloy-js/rollup-plugin": "^0.1.0",
"@types/node": "~22.13.13",
"@typespec/compiler": "workspace:~",
"@typespec/emitter-framework": "workspace:~",
diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx
new file mode 100644
index 00000000000..16718f0597a
--- /dev/null
+++ b/packages/graphql/src/components/fields/field.tsx
@@ -0,0 +1,62 @@
+import { type ModelProperty, getDoc, getDeprecationDetails } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { isNullable, hasNullableElements } from "../../lib/nullable.js";
+import { GraphQLTypeExpression } from "./type-expression.js";
+
+export interface FieldProps {
+ /** The model property to render as a field */
+ property: ModelProperty;
+ /** Whether this field is in an input type context */
+ isInput: boolean;
+}
+
+export function Field(props: FieldProps) {
+ const { program } = useTsp();
+
+ const doc = getDoc(program, props.property);
+ const deprecation = getDeprecationDetails(program, props.property);
+
+ return (
+
+ {(typeInfo) => {
+ if (props.isInput) {
+ return (
+
+ {typeInfo.isList ? (
+
+ ) : undefined}
+
+ );
+ }
+
+ return (
+
+ {typeInfo.isList ? (
+
+ ) : undefined}
+
+ );
+ }}
+
+ );
+}
diff --git a/packages/graphql/src/components/fields/index.ts b/packages/graphql/src/components/fields/index.ts
new file mode 100644
index 00000000000..8eb36ee9c42
--- /dev/null
+++ b/packages/graphql/src/components/fields/index.ts
@@ -0,0 +1,7 @@
+export { Field, type FieldProps } from "./field.js";
+export { OperationField, type OperationFieldProps } from "./operation-field.js";
+export {
+ GraphQLTypeExpression,
+ type GraphQLTypeExpressionProps,
+ type GraphQLTypeInfo,
+} from "./type-expression.js";
diff --git a/packages/graphql/src/components/fields/operation-field.tsx b/packages/graphql/src/components/fields/operation-field.tsx
new file mode 100644
index 00000000000..960b4957512
--- /dev/null
+++ b/packages/graphql/src/components/fields/operation-field.tsx
@@ -0,0 +1,80 @@
+import { type Operation, getDoc, getDeprecationDetails } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { isNullable, hasNullableElements } from "../../lib/nullable.js";
+import { GraphQLTypeExpression } from "./type-expression.js";
+
+export interface OperationFieldProps {
+ /** The operation to render as a field */
+ operation: Operation;
+}
+
+/**
+ * Renders an operation as a field with arguments, used for @operationFields.
+ */
+export function OperationField(props: OperationFieldProps) {
+ const { program } = useTsp();
+ const params = Array.from(props.operation.parameters.properties.values());
+ const doc = getDoc(program, props.operation);
+ const deprecation = getDeprecationDetails(program, props.operation);
+
+ return (
+
+ {(returnTypeInfo) => (
+
+ {returnTypeInfo.isList ? (
+
+ ) : undefined}
+ {params.map((param) => (
+
+ {(paramTypeInfo) => (
+
+ {paramTypeInfo.isList ? (
+
+ ) : undefined}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/graphql/src/components/fields/type-expression.tsx b/packages/graphql/src/components/fields/type-expression.tsx
new file mode 100644
index 00000000000..89273e49b9f
--- /dev/null
+++ b/packages/graphql/src/components/fields/type-expression.tsx
@@ -0,0 +1,172 @@
+import {
+ type Type,
+ type Scalar,
+ type ModelProperty,
+ getEncode,
+ isUnknownType,
+} from "@typespec/compiler";
+import { type Children } from "@alloy-js/core";
+import { useTsp } from "@typespec/emitter-framework";
+import { useGraphQLSchema } from "../../context/index.js";
+import { isNullable } from "../../lib/nullable.js";
+import { unwrapNullableUnion, getUnionName } from "../../lib/type-utils.js";
+import { getGraphQLBuiltinName, getScalarMapping } from "../../lib/scalar-mappings.js";
+
+/**
+ * Information about a resolved GraphQL type
+ */
+export interface GraphQLTypeInfo {
+ /** The base type name (without wrappers) */
+ typeName: string;
+ /** Whether this is a list type */
+ isList: boolean;
+ /** Whether the field itself is non-null */
+ isNonNull: boolean;
+ /** Whether list items are non-null (only meaningful if isList is true) */
+ itemNonNull: boolean;
+}
+
+export interface GraphQLTypeExpressionProps {
+ type: Type;
+ isOptional: boolean;
+ /** Whether this type is in an input position (operation parameter or input model field) */
+ isInput: boolean;
+ /** Whether this type was marked nullable (from property-level tracking) */
+ isNullable?: boolean;
+ /** Whether this property's array elements were originally T | null (from property-level tracking) */
+ hasNullableElements?: boolean;
+ /** The property or parameter that contains the type (for @encode checking) */
+ targetType?: Type;
+ children: (typeInfo: GraphQLTypeInfo) => Children;
+}
+
+export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) {
+ const { $, program } = useTsp();
+ const { modelVariants } = useGraphQLSchema();
+
+ const nullable = props.isNullable || isNullable(props.type);
+
+ // Input fields are non-null unless nullable; optionality is expressed via
+ // default values. Output fields are non-null unless optional or nullable.
+ const isNonNull = nullable ? false : props.isInput || !props.isOptional;
+
+ // Unwrap T | null unions the mutation engine didn't process (e.g., array
+ // elements, operation parameters that arrive here still wrapped).
+ if ($.union.is(props.type)) {
+ const innerType = unwrapNullableUnion(props.type);
+ if (innerType) {
+ return (
+
+ {(innerInfo) =>
+ props.children({
+ ...innerInfo,
+ isNonNull: false,
+ })
+ }
+
+ );
+ }
+ }
+
+ if ($.array.is(props.type)) {
+ const elementType = $.array.getElementType(props.type);
+ // Element nullability: from mutation engine property-level tracking, from the
+ // element type's own state map, or from an inline T | null union still present.
+ const elementIsNullable =
+ props.hasNullableElements ||
+ isNullable(elementType) ||
+ ($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined);
+
+ return (
+
+ {(elementInfo) =>
+ props.children({
+ typeName: elementInfo.typeName,
+ isList: true,
+ isNonNull,
+ itemNonNull: !elementIsNullable,
+ })
+ }
+
+ );
+ }
+
+ const typeName = resolveBaseTypeName();
+
+ return props.children({
+ typeName,
+ isList: false,
+ isNonNull,
+ itemNonNull: false,
+ });
+
+ function resolveBaseTypeName(): string {
+ const type = props.type;
+
+ if (isUnknownType(type)) {
+ return "Unknown";
+ }
+
+ if ($.scalar.is(type)) {
+ const builtinName = getGraphQLBuiltinName(program, type);
+ if (builtinName) return builtinName;
+
+ // Std scalars with encoding-specific mappings (e.g., bytes + base64 -> Bytes)
+ if (program.checker.isStdType(type)) {
+ if (
+ props.targetType &&
+ ($.scalar.is(props.targetType) || props.targetType.kind === "ModelProperty")
+ ) {
+ const encodeData = getEncode(
+ program,
+ props.targetType as Scalar | ModelProperty,
+ );
+ const mapping = getScalarMapping(program, type, encodeData?.encoding);
+ if (mapping) return mapping.graphqlName;
+ }
+
+ const mapping = getScalarMapping(program, type);
+ if (mapping) return mapping.graphqlName;
+ }
+
+ return type.name;
+ }
+
+ if ($.model.is(type)) {
+ // Both input and output variants share the same source model identity,
+ // so we use name-based lookup to determine if both variants exist.
+ const hasOutputVariant = modelVariants.outputModels.has(type.name);
+ const hasInputVariant = modelVariants.inputModels.has(type.name);
+
+ if (props.isInput && hasOutputVariant && hasInputVariant) {
+ return `${type.name}Input`;
+ }
+ return type.name;
+ }
+
+ if ($.enum.is(type)) {
+ return type.name;
+ }
+
+ if ($.union.is(type)) {
+ return getUnionName(type, program);
+ }
+
+ throw new Error(
+ `Unexpected type kind "${type.kind}" in resolveBaseTypeName. ` +
+ `This is a bug in the GraphQL emitter.`,
+ );
+ }
+}
diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx
new file mode 100644
index 00000000000..db817783a95
--- /dev/null
+++ b/packages/graphql/src/context/graphql-schema-context.tsx
@@ -0,0 +1,99 @@
+import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core";
+import {
+ type Model,
+ type Enum,
+ type IntrinsicType,
+ type Scalar,
+ type Union,
+ type Operation,
+} from "@typespec/compiler";
+
+/**
+ * Classified types separated by category for schema generation
+ */
+export interface ClassifiedTypes {
+ /** Interface types marked with @Interface */
+ interfaces: Model[];
+ /** Models used as output types (return values) */
+ outputModels: Model[];
+ /** Models used as input types (parameters) */
+ inputModels: Model[];
+ /** Enum types */
+ enums: Enum[];
+ /** Custom scalar types */
+ scalars: Scalar[];
+ /** Scalar variants for encoded scalars (e.g., bytes + base64 → Bytes) */
+ scalarVariants: ScalarVariant[];
+ /** Union types */
+ unions: Union[];
+ /** Query operations */
+ queries: Operation[];
+ /** Mutation operations */
+ mutations: Operation[];
+ /** Subscription operations */
+ subscriptions: Operation[];
+}
+
+/**
+ * Model variant lookups for quick checking whether output and/or input variants exist.
+ * Used to determine when to append "Input" suffix during type resolution.
+ */
+export interface ModelVariants {
+ /** Output model variants indexed by name */
+ outputModels: Map;
+ /** Input model variants indexed by name */
+ inputModels: Map;
+}
+
+/**
+ * Scalar variant information for encoded scalars.
+ * When a scalar has @encode, we emit it as a different GraphQL scalar (e.g., bytes + base64 → Bytes)
+ */
+export interface ScalarVariant {
+ /** The original TypeSpec scalar type, or IntrinsicType for `unknown` */
+ sourceScalar: Scalar | IntrinsicType;
+ /** The encoding used (e.g., "base64", "rfc3339") */
+ encoding: string;
+ /** The GraphQL scalar name to emit (e.g., "Bytes", "UTCDateTime") */
+ graphqlName: string;
+ /** Optional specification URL for @specifiedBy directive */
+ specificationUrl?: string;
+}
+
+/**
+ * Context value containing GraphQL-specific schema information.
+ *
+ * For access to the TypeSpec program and typekit, use `useTsp()` from
+ * `@typespec/emitter-framework` instead.
+ */
+export interface GraphQLSchemaContextValue {
+ /** Classified types for schema generation */
+ classifiedTypes: ClassifiedTypes;
+ /** Model variant lookups for input/output type resolution */
+ modelVariants: ModelVariants;
+ /** Scalar specification URLs for @specifiedBy directives */
+ scalarSpecifications: Map;
+}
+
+/**
+ * Context provider for GraphQL schema generation
+ */
+export const GraphQLSchemaContext: ComponentContext =
+ createNamedContext("GraphQLSchema");
+
+/**
+ * Hook to access GraphQL schema context
+ * @returns The GraphQL schema context value
+ * @throws Error if used outside of GraphQLSchemaContext.Provider
+ */
+export function useGraphQLSchema(): GraphQLSchemaContextValue {
+ const context = useContext(GraphQLSchemaContext);
+
+ if (!context) {
+ throw new Error(
+ "useGraphQLSchema must be used within GraphQLSchemaContext.Provider."
+ );
+ }
+
+ return context;
+}
diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts
new file mode 100644
index 00000000000..b353281709c
--- /dev/null
+++ b/packages/graphql/src/context/index.ts
@@ -0,0 +1,8 @@
+export {
+ GraphQLSchemaContext,
+ useGraphQLSchema,
+ type GraphQLSchemaContextValue,
+ type ClassifiedTypes,
+ type ModelVariants,
+ type ScalarVariant,
+} from "./graphql-schema-context.js";
diff --git a/packages/graphql/src/lib/scalar-mappings.ts b/packages/graphql/src/lib/scalar-mappings.ts
index 20cdf846536..5956fce34f4 100644
--- a/packages/graphql/src/lib/scalar-mappings.ts
+++ b/packages/graphql/src/lib/scalar-mappings.ts
@@ -170,6 +170,21 @@ const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [
"string", "boolean", "int32", "float32", "float64",
];
+/**
+ * Map a TypeSpec std scalar to its GraphQL built-in scalar name, if any.
+ *
+ * Returns undefined for non-builtin scalars. Uses checker.isStdType
+ * (name + namespace) which works on both original and mutated scalars.
+ */
+export function getGraphQLBuiltinName(program: Program, scalar: Scalar): string | undefined {
+ if (program.checker.isStdType(scalar, "string")) return "String";
+ if (program.checker.isStdType(scalar, "boolean")) return "Boolean";
+ if (program.checker.isStdType(scalar, "int32")) return "Int";
+ if (program.checker.isStdType(scalar, "float32")) return "Float";
+ if (program.checker.isStdType(scalar, "float64")) return "Float";
+ return undefined;
+}
+
/**
* Get the GraphQL scalar mapping for a scalar via its standard library ancestor.
*
diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json
index ad68b784463..d2f18d1ce33 100644
--- a/packages/graphql/tsconfig.json
+++ b/packages/graphql/tsconfig.json
@@ -1,10 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
- "useDefineForClassFields": true,
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "target": "es2022",
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "jsx": "preserve",
+ "jsxImportSource": "@alloy-js/core",
+ "emitDeclarationOnly": true,
"rootDir": ".",
- "outDir": "dist",
- "verbatimModuleSyntax": true
+ "outDir": "dist"
},
- "include": ["src", "test"]
+ "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
}
diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts
index 63cad767f57..b45007f3475 100644
--- a/packages/graphql/vitest.config.ts
+++ b/packages/graphql/vitest.config.ts
@@ -1,4 +1,14 @@
+import alloyPlugin from "@alloy-js/rollup-plugin";
import { defineConfig, mergeConfig } from "vitest/config";
import { defaultTypeSpecVitestConfig } from "../../vitest.config.js";
-export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({}));
+export default mergeConfig(
+ defaultTypeSpecVitestConfig,
+ defineConfig({
+ esbuild: {
+ jsx: "preserve",
+ sourcemap: "both",
+ },
+ plugins: [alloyPlugin()],
+ }),
+);