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..ed5c9d0e5ac
--- /dev/null
+++ b/packages/graphql/src/components/fields/field.tsx
@@ -0,0 +1,76 @@
+import { type ModelProperty, getDoc, getDeprecationDetails } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { GraphQLTypeResolutionContext } from "../../context/index.js";
+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;
+}
+
+/**
+ * Renders a GraphQL field (property on a type or input type)
+ *
+ * Automatically handles:
+ * - Description from doc comments
+ * - Type resolution with input/output awareness
+ * - Nullability based on optional flag and nullable unions
+ * - Array types using Field.List
+ * - Directives like @deprecated
+ *
+ * Uses gql.Field for output fields and gql.InputField for input fields
+ */
+export function Field(props: FieldProps) {
+ const { program } = useTsp();
+ const mode = props.isInput ? "input" : "output";
+
+ 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..490d32376a7
--- /dev/null
+++ b/packages/graphql/src/components/fields/operation-field.tsx
@@ -0,0 +1,91 @@
+import { type Operation, getDoc, getDeprecationDetails } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { GraphQLTypeResolutionContext } from "../../context/index.js";
+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 decorator where operations become
+ * fields on a type with parameters as arguments.
+ *
+ * Nullability for parameters is tracked by the mutation engine on each
+ * mutated ModelProperty (see nullable.ts). This component queries the
+ * state maps and passes the results to GraphQLTypeExpression.
+ */
+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..81c1233ba1d
--- /dev/null
+++ b/packages/graphql/src/components/fields/type-expression.tsx
@@ -0,0 +1,208 @@
+import {
+ type Type,
+ type Scalar,
+ type ModelProperty,
+ getEncode,
+ isUnknownType,
+} from "@typespec/compiler";
+import { type Children, useContext } from "@alloy-js/core";
+import { useTsp } from "@typespec/emitter-framework";
+import { GraphQLTypeResolutionContext, 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 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;
+ key?: string;
+ children: (typeInfo: GraphQLTypeInfo) => Children;
+}
+
+/**
+ * Resolves a TypeSpec type to GraphQL type information.
+ *
+ * Follows the emitter framework's TypeExpression component pattern: a single
+ * component that encapsulates type resolution logic, using context hooks for
+ * program/typekit access and Typekit predicates for type narrowing.
+ *
+ * Uses render props (children function) because GraphQL SDL rendering components
+ * (`gql.Field`, `gql.InputField`) consume structured data, not raw type strings.
+ *
+ * Nullability comes from two sources:
+ * 1. Property-level: `isNullable` prop, set by the Field component when the
+ * mutation engine marked the property as nullable (for inline T | null unions).
+ * 2. Type-level: `isNullable(program, type)` state map check, used for named
+ * multi-variant unions (Cat | Dog | null) where the engine creates a new
+ * unique union object.
+ */
+export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) {
+ const { $, program } = useTsp();
+ const { modelVariants } = useGraphQLSchema();
+ const resolutionContext = useContext(GraphQLTypeResolutionContext);
+ const mode = resolutionContext?.mode ?? "output";
+
+ // Nullability from property-level tracking (inline T | null) or
+ // type-level state map (named multi-variant unions with null stripped).
+ // See nullable.ts for the full architectural explanation.
+ const nullable = props.isNullable || isNullable(program, props.type);
+
+ // GraphQL non-null rules:
+ // - Output fields: non-null unless optional (?) or nullable (| null)
+ // - Input fields: always non-null unless nullable (| null); optionality is
+ // expressed via default values, not nullability
+ const isNonNull = nullable ? false : mode === "input" || !props.isOptional;
+
+ // Unwrap inline T | null unions that haven't been processed by the mutation engine.
+ // This handles cases where the type reaches us still as a union (e.g., array elements
+ // like Array, or operation parameters).
+ if ($.union.is(props.type)) {
+ const innerType = unwrapNullableUnion(props.type);
+ if (innerType) {
+ return (
+
+ {(innerInfo) =>
+ props.children({
+ ...innerInfo,
+ isNonNull: false,
+ })
+ }
+
+ );
+ }
+ }
+
+ // Arrays — recurse for element type
+ if ($.array.is(props.type)) {
+ const elementType = $.array.getElementType(props.type);
+ // Check if the element type is nullable from three sources:
+ // 1. Property-level: hasNullableElements prop (mutation engine detected Array
+ // and marked the property before the union was replaced with the inner type)
+ // 2. Type-level: isNullable state map on the element type itself
+ // 3. Inline union: element type is still a T | null union (not yet processed)
+ const elementIsNullable =
+ props.hasNullableElements ||
+ isNullable(program, elementType) ||
+ ($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined);
+
+ return (
+
+ {(elementInfo) =>
+ props.children({
+ typeName: elementInfo.typeName,
+ isList: true,
+ isNonNull,
+ itemNonNull: !elementIsNullable,
+ })
+ }
+
+ );
+ }
+
+ // Resolve base type name
+ const typeName = resolveBaseTypeName();
+
+ return props.children({
+ typeName,
+ isList: false,
+ isNonNull,
+ itemNonNull: false,
+ });
+
+ /**
+ * Resolve the base type name using Typekit predicates for type narrowing.
+ */
+ function resolveBaseTypeName(): string {
+ const type = props.type;
+
+ // Intrinsic unknown
+ if (isUnknownType(type)) {
+ return "Unknown";
+ }
+
+ // Scalars
+ if ($.scalar.is(type)) {
+ const builtinName = getGraphQLBuiltinName(program, type);
+ if (builtinName) return builtinName;
+
+ // Standard library scalars with encoding-specific mappings
+ 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;
+ }
+
+ // Models — check whether "Input" suffix is needed
+ if ($.model.is(type)) {
+ // Name-based lookup: modelVariants maps are keyed by name because both
+ // input and output variants share the same source model identity — the
+ // distinction is purely nominal at the GraphQL output level.
+ const hasOutputVariant = modelVariants.outputModels.has(type.name);
+ const hasInputVariant = modelVariants.inputModels.has(type.name);
+
+ if (mode === "input" && hasOutputVariant && hasInputVariant) {
+ return `${type.name}Input`;
+ }
+ return type.name;
+ }
+
+ // Enums
+ if ($.enum.is(type)) {
+ return type.name;
+ }
+
+ // Unions
+ 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..3b7c98f37f8
--- /dev/null
+++ b/packages/graphql/src/context/index.ts
@@ -0,0 +1,14 @@
+export {
+ GraphQLSchemaContext,
+ useGraphQLSchema,
+ type GraphQLSchemaContextValue,
+ type ClassifiedTypes,
+ type ModelVariants,
+ type ScalarVariant,
+} from "./graphql-schema-context.js";
+
+export {
+ GraphQLTypeResolutionContext,
+ type TypeResolutionMode,
+ type GraphQLTypeResolutionContextValue,
+} from "./type-resolution-context.js";
diff --git a/packages/graphql/src/context/type-resolution-context.tsx b/packages/graphql/src/context/type-resolution-context.tsx
new file mode 100644
index 00000000000..9a7b4819504
--- /dev/null
+++ b/packages/graphql/src/context/type-resolution-context.tsx
@@ -0,0 +1,20 @@
+import { type ComponentContext, createNamedContext } from "@alloy-js/core";
+
+/**
+ * Type resolution mode for context-aware type name resolution
+ */
+export type TypeResolutionMode = "input" | "output";
+
+/**
+ * Context value for type resolution
+ */
+export interface GraphQLTypeResolutionContextValue {
+ /** Whether we're resolving types in input or output context */
+ mode: TypeResolutionMode;
+}
+
+/**
+ * Context provider for type resolution mode
+ */
+export const GraphQLTypeResolutionContext: ComponentContext =
+ createNamedContext("TypeResolution");
diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts
index ac79ad37859..5c94258c5da 100644
--- a/packages/graphql/src/lib.ts
+++ b/packages/graphql/src/lib.ts
@@ -177,7 +177,11 @@ export const libDef = {
oneOf: { description: "State for tracking @oneOf input objects created from input unions." },
nullable: {
description:
- "State for tracking types that were determined to be nullable from null-variant stripping.",
+ "State for tracking types and properties marked nullable after null-variant stripping by the mutation engine.",
+ },
+ nullableElements: {
+ description:
+ "State for tracking properties whose array element type was originally T | null before mutation.",
},
},
} as const;
diff --git a/packages/graphql/src/lib/nullable.ts b/packages/graphql/src/lib/nullable.ts
index 65425108390..2c914b15e54 100644
--- a/packages/graphql/src/lib/nullable.ts
+++ b/packages/graphql/src/lib/nullable.ts
@@ -1,3 +1,49 @@
+/**
+ * Nullable tracking for the GraphQL mutation pipeline.
+ *
+ * ## Why state maps?
+ *
+ * The GraphQL mutation engine strips `null` variants from unions before the
+ * component layer renders SDL. By the time components see a type, the
+ * structural evidence of nullability is gone — a `string | null` property's
+ * type has already been replaced with the bare `string` scalar.
+ *
+ * State maps bridge this gap: the mutation engine records nullability facts
+ * during transformation, and components query them at render time.
+ *
+ * (This differs from the C# emitter, which has no mutation engine and detects
+ * `T | null` unions structurally at render time. That approach doesn't work
+ * here because our mutation engine rewrites the type graph first.)
+ *
+ * ## Two tracking dimensions
+ *
+ * **Field nullability** (`isNullable`): The field/property itself is nullable.
+ *
+ * Marked on different targets depending on the source:
+ * - *Property-level*: For inline `T | null` (e.g., `bio: string | null`),
+ * the union is replaced with the shared scalar singleton. Marking the
+ * singleton would poison every use of that scalar, so we mark the
+ * **ModelProperty** instead. Set by `GraphQLModelPropertyMutation`.
+ * - *Operation-level*: For `op getUser(): User | null`, the return type
+ * union is replaced with the inner type. We mark the **Operation**
+ * itself. Set by `GraphQLOperationMutation`.
+ * - *Type-level*: For named multi-variant unions with null (e.g.,
+ * `union Pet { Cat, Dog, null }`), the engine creates a new unique union
+ * object without the null variant. This new object is safe to mark
+ * directly. Set by `GraphQLUnionMutation`.
+ *
+ * Components call `isNullable(program, property)`,
+ * `isNullable(program, operation)`, or `isNullable(program, type)` —
+ * all use the same state set.
+ *
+ * **Element nullability** (`hasNullableElements`): Array elements are nullable.
+ *
+ * For `tags: (string | null)[]`, the mutation engine replaces the element
+ * union with the inner scalar but the array itself is non-null. We mark the
+ * **ModelProperty** so the component layer can emit `[String]` (nullable
+ * elements) instead of `[String!]`. Set by `GraphQLModelPropertyMutation`.
+ */
+
import type { Program, Type } from "@typespec/compiler";
import { useStateSet } from "@typespec/compiler/utils";
import { GraphQLKeys } from "../lib.js";
@@ -5,17 +51,47 @@ import { GraphQLKeys } from "../lib.js";
const [getNullableState, setNullableState] = useStateSet(GraphQLKeys.nullable);
/**
- * Check if a type has been marked as nullable due to null-variant stripping.
- * For example, `Cat | Dog | null` becomes `union CatDog` marked as nullable.
+ * Check whether a type or property was marked nullable by the mutation engine.
+ *
+ * Works on both type-level targets (named unions with null stripped) and
+ * property-level targets (inline `T | null` on a ModelProperty).
*/
export function isNullable(program: Program, type: Type): boolean {
return getNullableState(program, type);
}
/**
- * Mark a type as nullable. Called by the mutation engine when null variants
- * are stripped from a union during processing.
+ * Mark a type or property as nullable. Called by the mutation engine when
+ * null variants are stripped during processing.
+ *
+ * @see {@link GraphQLModelPropertyMutation} — marks ModelProperty for inline `T | null`
+ * @see {@link GraphQLOperationMutation} — marks Operation for `op foo(): T | null`
+ * @see {@link GraphQLUnionMutation} — marks the new union for named `Cat | Dog | null`
*/
export function setNullable(program: Program, type: Type): void {
setNullableState(program, type);
}
+
+const [getNullableElementsState, setNullableElementsState] = useStateSet(
+ GraphQLKeys.nullableElements,
+);
+
+/**
+ * Check whether a property's array elements were originally `T | null`.
+ *
+ * For `tags: (string | null)[]`, the mutation engine replaces the element
+ * type with the bare scalar, but the component layer still needs to know
+ * that elements should be emitted without `!` (e.g., `[String]` not `[String!]`).
+ */
+export function hasNullableElements(program: Program, type: Type): boolean {
+ return getNullableElementsState(program, type);
+}
+
+/**
+ * Mark a property as having nullable array elements.
+ *
+ * @see {@link GraphQLModelPropertyMutation} — detects `Array` pattern
+ */
+export function setNullableElements(program: Program, type: Type): void {
+ setNullableElementsState(program, type);
+}
diff --git a/packages/graphql/src/lib/scalar-mappings.ts b/packages/graphql/src/lib/scalar-mappings.ts
index 20cdf846536..0f444cb5d80 100644
--- a/packages/graphql/src/lib/scalar-mappings.ts
+++ b/packages/graphql/src/lib/scalar-mappings.ts
@@ -158,17 +158,38 @@ export function isStdScalar(tk: Typekit, scalar: Scalar): boolean {
}
/**
- * TypeSpec std scalar names that map directly to GraphQL built-in scalar types:
- * string → String, boolean → Boolean, int32 → Int, float32/float64 → Float.
+ * TypeSpec std scalars that map directly to GraphQL built-in scalar types.
*
* These must NOT be renamed by the scalar mutation — they're resolved to
* GraphQL builtins at emit time.
*
* @see https://spec.graphql.org/September2025/#sec-Scalars.Built-in-Scalars
*/
-const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [
- "string", "boolean", "int32", "float32", "float64",
-];
+// Note: GraphQL's `ID` built-in is not mapped here — no TypeSpec scalar maps
+// to `ID` by default. It requires an explicit decorator (e.g., @id).
+const GQL_BUILTIN_SCALARS: Partial> = {
+ string: "String",
+ boolean: "Boolean",
+ int32: "Int",
+ float32: "Float",
+ float64: "Float",
+};
+
+const TSP_SCALARS_TO_GQL_BUILTINS = Object.keys(GQL_BUILTIN_SCALARS) as IntrinsicScalarName[];
+
+/**
+ * Get the GraphQL built-in scalar name for a TypeSpec scalar, if it is one.
+ * Uses identity-based check via typekit to avoid false positives from
+ * user-defined scalars that share the same name in a different namespace.
+ * Returns undefined for non-builtin scalars.
+ */
+export function getGraphQLBuiltinName(program: Program, scalar: Scalar): string | undefined {
+ const checker = program.checker;
+ for (const [name, gqlName] of Object.entries(GQL_BUILTIN_SCALARS)) {
+ if (checker.isStdType(scalar, name as IntrinsicScalarName)) return gqlName;
+ }
+ return undefined;
+}
/**
* Get the GraphQL scalar mapping for a scalar via its standard library ancestor.
diff --git a/packages/graphql/src/mutation-engine/mutations/model-property.ts b/packages/graphql/src/mutation-engine/mutations/model-property.ts
index 709d7247972..75f4d390076 100644
--- a/packages/graphql/src/mutation-engine/mutations/model-property.ts
+++ b/packages/graphql/src/mutation-engine/mutations/model-property.ts
@@ -6,7 +6,8 @@ import {
type SimpleMutationOptions,
type SimpleMutations,
} from "@typespec/mutator-framework";
-import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
+import { setNullable, setNullableElements } from "../../lib/nullable.js";
+import { isArray, unwrapNullableUnion, sanitizeNameForGraphQL } from "../../lib/type-utils.js";
/** GraphQL-specific ModelProperty mutation. */
export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation {
@@ -29,8 +30,43 @@ export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation (e.g., `tags: (string | null)[]`)
+ // The array's element type (indexer value) is a T | null union.
+ const isArrayWithNullableElements =
+ originalType.kind === "Model" &&
+ isArray(originalType) &&
+ originalType.indexer.value.kind === "Union" &&
+ unwrapNullableUnion(originalType.indexer.value) !== undefined;
+
+ // Trigger mutation (whenMutated callback sanitizes the name)
this.mutationNode.mutate();
super.mutate();
+
+ // Mark the mutated *property* (not the type) as nullable.
+ // The component layer checks isNullable(program, property) to determine
+ // whether a field should omit the ! (non-null) wrapper.
+ if (isInlineNullable) {
+ setNullable(this.engine.$.program, this.mutatedType);
+ }
+
+ // Mark the property as having nullable array elements.
+ // The component layer checks hasNullableElements(program, property) to
+ // emit [String] instead of [String!].
+ if (isArrayWithNullableElements) {
+ setNullableElements(this.engine.$.program, this.mutatedType);
+ }
}
}
diff --git a/packages/graphql/src/mutation-engine/mutations/operation.ts b/packages/graphql/src/mutation-engine/mutations/operation.ts
index 21d973703f8..37ab954dcb7 100644
--- a/packages/graphql/src/mutation-engine/mutations/operation.ts
+++ b/packages/graphql/src/mutation-engine/mutations/operation.ts
@@ -6,7 +6,8 @@ import {
type SimpleMutationOptions,
type SimpleMutations,
} from "@typespec/mutator-framework";
-import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
+import { setNullable } from "../../lib/nullable.js";
+import { unwrapNullableUnion, sanitizeNameForGraphQL } from "../../lib/type-utils.js";
import { GraphQLMutationOptions, GraphQLTypeContext } from "../options.js";
/** GraphQL-specific Operation mutation. */
@@ -48,10 +49,25 @@ export class GraphQLOperationMutation extends SimpleOperationMutation {
operation.name = sanitizeNameForGraphQL(operation.name);
});
super.mutate();
+
+ // Mark the mutated operation as having a nullable return type.
+ // The OperationField component checks isNullable(program, operation)
+ // to determine whether the return field should omit the ! wrapper.
+ if (hasNullableReturn) {
+ setNullable(this.engine.$.program, this.mutatedType);
+ }
}
}
diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts
index d1833b78c91..47cbfa34033 100644
--- a/packages/graphql/src/mutation-engine/mutations/union.ts
+++ b/packages/graphql/src/mutation-engine/mutations/union.ts
@@ -112,7 +112,16 @@ export class GraphQLUnionMutation extends UnionMutation {
expect(mutation.mutatedType.name).toBe("get_data");
});
+
+ it("marks operation as nullable when return type is T | null", async () => {
+ const { getUser } = await tester.compile(
+ t.code`
+ model ${t.model("User")} { name: string; }
+ op ${t.op("getUser")}(): User | null;
+ `,
+ );
+
+ const engine = createTestEngine(tester.program);
+ const mutation = engine.mutateOperation(getUser);
+
+ // The return type should be unwrapped to the inner type
+ expect(mutation.mutatedType.returnType.kind).toBe("Model");
+ // The operation itself should be marked nullable
+ expect(isNullable(tester.program, mutation.mutatedType)).toBe(true);
+ });
+
+ it("does not mark operation as nullable when return type is non-null", async () => {
+ const { getUser } = await tester.compile(
+ t.code`
+ model ${t.model("User")} { name: string; }
+ op ${t.op("getUser")}(): User;
+ `,
+ );
+
+ const engine = createTestEngine(tester.program);
+ const mutation = engine.mutateOperation(getUser);
+
+ expect(mutation.mutatedType.returnType.kind).toBe("Model");
+ expect(isNullable(tester.program, mutation.mutatedType)).toBe(false);
+ });
});
describe("GraphQL Mutation Engine - Scalars", () => {
@@ -499,7 +531,9 @@ describe("GraphQL Mutation Engine - Unions", () => {
// T | null is replaced with the inner type (string scalar)
expect(mutation.mutatedType.kind).toBe("Scalar");
expect(mutation.wrapperModels).toHaveLength(0);
- expect(isNullable(tester.program, mutation.mutatedType)).toBe(true);
+ // The replacement type is NOT marked nullable — nullability for inline T | null
+ // is tracked on the model property, not the shared scalar singleton.
+ expect(isNullable(tester.program, mutation.mutatedType)).toBe(false);
});
it("replaces nullable model union with inner type", async () => {
@@ -516,7 +550,9 @@ describe("GraphQL Mutation Engine - Unions", () => {
// Dog | null is replaced with the inner type (Dog model)
expect(mutation.mutatedType.kind).toBe("Model");
expect(mutation.wrapperModels).toHaveLength(0);
- expect(isNullable(tester.program, mutation.mutatedType)).toBe(true);
+ // The replacement type is NOT marked nullable — nullability for inline T | null
+ // is tracked on the model property, not the shared type.
+ expect(isNullable(tester.program, mutation.mutatedType)).toBe(false);
});
it("creates wrapper models for scalar variants", async () => {
@@ -596,7 +632,7 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(mutation.mutatedType.name).toBe("ValidUnion");
});
- it("strips T | null on model property to inner type and marks nullable", async () => {
+ it("strips T | null on model property to inner type and marks property nullable", async () => {
const { Foo } = await tester.compile(
t.code`model ${t.model("Foo")} { name: string | null; }`,
);
@@ -609,8 +645,10 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(nameProp).toBeDefined();
expect(nameProp!.type.kind).toBe("Scalar");
- // The inner type should be marked as nullable
- expect(isNullable(tester.program, nameProp!.type)).toBe(true);
+ // Nullability is tracked on the property, not the inner type.
+ // The shared scalar singleton must NOT be marked nullable (would poison all uses).
+ expect(isNullable(tester.program, nameProp!.type)).toBe(false);
+ expect(isNullable(tester.program, nameProp!)).toBe(true);
});
});
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()],
+ }),
+);