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..1e98b1b58b5
--- /dev/null
+++ b/packages/graphql/src/components/fields/field.tsx
@@ -0,0 +1,62 @@
+import { type ModelProperty, 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 = $.type.getDoc(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..66d18e16102
--- /dev/null
+++ b/packages/graphql/src/components/fields/operation-field.tsx
@@ -0,0 +1,80 @@
+import { type Operation, 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 = $.type.getDoc(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..ccfacb2456d
--- /dev/null
+++ b/packages/graphql/src/components/fields/type-expression.tsx
@@ -0,0 +1,166 @@
+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 { 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 nullable = props.isNullable || isNullable(props.type);
+
+ // Fields are non-null unless nullable or optional.
+ // In GraphQL, optional fields are represented as nullable (per spec:
+ // "nullability directly determines whether a field is required").
+ // This also allows circular references in input types (e.g., Author.friend?: Author).
+ const isNonNull = !nullable && !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)) {
+ // The mutation engine handles input/output naming - input models are
+ // already suffixed with "Input" when they need to be distinguished.
+ 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..c281141c83e
--- /dev/null
+++ b/packages/graphql/src/context/graphql-schema-context.tsx
@@ -0,0 +1,43 @@
+import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core";
+import type { Namespace } from "@typespec/compiler";
+
+/**
+ * A self-contained type world produced by the mutation pipeline.
+ * The namespace contains all mutated types for the schema.
+ */
+export interface TypeGraph {
+ readonly globalNamespace: Namespace;
+}
+
+/**
+ * Context value containing the mutated type graph for schema generation.
+ *
+ * For access to the TypeSpec program and typekit, use `useTsp()` from
+ * `@typespec/emitter-framework` instead.
+ */
+export interface GraphQLSchemaContextValue {
+ typeGraph: TypeGraph;
+}
+
+/**
+ * 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..b734df36476
--- /dev/null
+++ b/packages/graphql/src/context/index.ts
@@ -0,0 +1,6 @@
+export {
+ GraphQLSchemaContext,
+ useGraphQLSchema,
+ type GraphQLSchemaContextValue,
+ type TypeGraph,
+} 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/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts
index 50fc9aba3ed..2cd0f5b22e0 100644
--- a/packages/graphql/src/lib/type-utils.ts
+++ b/packages/graphql/src/lib/type-utils.ts
@@ -1,21 +1,14 @@
import {
- type ArrayModelType,
- type Enum,
getDoc,
getTypeName,
type IndeterminateEntity,
- isNeverType,
isNullType,
isTemplateInstance,
- type Model,
type Program,
- type RecordModelType,
- type Scalar,
type Type,
type Union,
type UnionVariant,
type Value,
- walkPropertiesInherited,
} from "@typespec/compiler";
import {
type AliasStatementNode,
@@ -75,14 +68,6 @@ export function stripNullVariants(union: Union): {
};
}
-/** Generate a GraphQL type name for a templated model (e.g., `ListOfString`). */
-export function getTemplatedModelName(model: Model): string {
- const name = getTypeName(model, {});
- const baseName = toTypeName(name.replace(/<[^>]*>/g, ""));
- const templateString = getTemplateString(model);
- return templateString ? `${baseName}Of${templateString}` : baseName;
-}
-
function splitWithAcronyms(
splitFn: (name: string) => string[],
skipStart: boolean,
@@ -233,64 +218,6 @@ function getUnionNameForOperation(program: Program, union: Union): string {
return toTypeName(getTypeName(operation));
}
-/** Convert a namespaced name to a single name by replacing dots with underscores. */
-export function getSingleNameWithNamespace(name: string): string {
- return name.trim().replace(/\./g, "_");
-}
-
-/**
- * Check if a model is an array type.
- */
-export function isArray(model: Model): model is ArrayModelType {
- return Boolean(model.indexer && model.indexer.key.name === "integer");
-}
-
-/**
- * Check if a model is a record/map type.
- */
-export function isRecordType(type: Model): type is RecordModelType {
- return Boolean(type.indexer && type.indexer.key.name === "string");
-}
-
-/** Check if a model is an array of scalars or enums. */
-export function isScalarOrEnumArray(type: Model): type is ArrayModelType {
- return (
- isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum")
- );
-}
-
-/** Check if a model is an array of unions. */
-export function isUnionArray(type: Model): type is ArrayModelType {
- return isArray(type) && type.indexer?.value.kind === "Union";
-}
-
-/** Extract the element type from an array model, or return the model itself. */
-export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union;
-export function unwrapModel(model: Exclude): Model;
-export function unwrapModel(model: Model): Model | Scalar | Enum | Union {
- if (!isArray(model)) {
- return model;
- }
-
- if (model.indexer?.value.kind) {
- if (["Model", "Scalar", "Enum", "Union"].includes(model.indexer.value.kind)) {
- return model.indexer.value as Model | Scalar | Enum | Union;
- }
- throw new Error(`Unexpected array type: ${model.indexer.value.kind}`);
- }
- return model;
-}
-
-/** Unwrap array types to get the inner element type. */
-export function unwrapType(type: Model): Model | Scalar | Enum | Union;
-export function unwrapType(type: Type): Type;
-export function unwrapType(type: Type): Type {
- if (type.kind === "Model") {
- return unwrapModel(type);
- }
- return type;
-}
-
/** Get the GraphQL description for a type from its doc comments. */
export function getGraphQLDoc(program: Program, type: Type): string | undefined {
// GraphQL uses CommonMark for descriptions
@@ -318,15 +245,3 @@ function getTemplateStringInternal(
return args.length > 0 ? args.map(toTypeName).join(options.conjunction) : "";
}
-/** 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 !(
- // Array of scalars/enums — represented as a list type, not an object type
- isScalarOrEnumArray(model) ||
- // Array of unions — represented as a list type, not an object type
- isUnionArray(model) ||
- isNeverType(model) ||
- // Pure record with no properties — emitted as a custom scalar, not an object type
- (isRecordType(model) && [...walkPropertiesInherited(model)].length === 0)
- );
-}
diff --git a/packages/graphql/test/lib/type-utils.test.ts b/packages/graphql/test/lib/type-utils.test.ts
index aa5cb5ee45c..0fe12748de5 100644
--- a/packages/graphql/test/lib/type-utils.test.ts
+++ b/packages/graphql/test/lib/type-utils.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
- getSingleNameWithNamespace,
sanitizeNameForGraphQL,
toEnumMemberName,
toFieldName,
@@ -113,18 +112,4 @@ describe("type-utils", () => {
});
});
- describe("getSingleNameWithNamespace", () => {
- it("replaces dots with underscores", () => {
- expect(getSingleNameWithNamespace("My.Namespace.Type")).toBe("My_Namespace_Type");
- });
-
- it("trims whitespace", () => {
- expect(getSingleNameWithNamespace(" My.Type ")).toBe("My_Type");
- });
-
- it("handles names without namespace", () => {
- expect(getSingleNameWithNamespace("MyType")).toBe("MyType");
- });
- });
-
});
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()],
+ }),
+);