How to handle GraphQL instead of OAS
Author (or clone) a generator that processes GraphQL operations instead of OAS operations.
When to use this
Your input is a GraphQL schema (SDL), not OpenAPI. The engine supports both, but the entry-point factory and the parsed model differ.
Prerequisites
- The project's
client.json#sourcepoints at a GraphQL SDL (.graphqlfile or content-typeapplication/graphql). - Familiarity with the GraphQL asymmetry.
Steps
Use toGqlOperationEntry (not toOasOperationEntry)
The factory is the GraphQL counterpart. Use the class-based
projection pattern — the same shape as OAS operation generators:
extend toTsGqlOperationProjectionBase, then dispatch the class
from the entry's transform.
// src/base.ts
import { capitalize } from '@skmtc/core'
import { toTsGqlOperationProjectionBase } from '@skmtc/lang-typescript'
import type { TsIdentifierType } from '@skmtc/lang-typescript'
import { join } from '@std/path'
import { toEnrichmentSchema, type EnrichmentSchema } from './enrichments.ts'
import denoJson from '../deno.json' with { type: 'json' }
export const MyGqlBase = toTsGqlOperationProjectionBase<EnrichmentSchema>({
id: denoJson.name,
toEnrichmentSchema,
toIdentifierName: ({ operation }) => `use${capitalize(operation.fieldName)}`,
toIdentifierType: (): TsIdentifierType => ({ type: 'variable' }),
toExportPath: ({ operation }) =>
join('@', 'graphql', `use${capitalize(operation.fieldName)}.generated.ts`)
})
// src/MyGqlHook.ts
import { TsProjection } from '@skmtc/gen-typescript'
import { synthesizeArgsObject } from '@skmtc/core'
import { MyGqlBase } from './base.ts'
import type { EnrichmentSchema } from './enrichments.ts'
import type { GqlOperationProjectionConstructorArgs } from '@skmtc/core'
export class MyGqlHook extends MyGqlBase {
argsTypeName: string | undefined
resultTypeName: string
constructor(args: GqlOperationProjectionConstructorArgs<EnrichmentSchema>) {
super(args)
const argsObject = synthesizeArgsObject(args.operation)
if (argsObject !== undefined) {
const argsType = this.insertNormalizedModel(TsProjection, {
schema: argsObject,
fallbackName: `${args.settings.identifier.name}Args`
})
this.argsTypeName = argsType.identifier.name
}
const resultType = this.insertNormalizedModel(TsProjection, {
schema: args.operation.returnType,
fallbackName: `${args.settings.identifier.name}Result`
})
this.resultTypeName = resultType.identifier.name
this.register({
imports: { '@tanstack/react-query': ['useQuery'] }
})
}
override toString(): string {
return `(${this.argsTypeName ? `args: ${this.argsTypeName}` : ''}) => useQuery<${this.resultTypeName}>({ ... })`
}
}
// src/mod.ts
import { toGqlOperationEntry, synthesizeArgsObject } from '@skmtc/core'
import { MyGqlHook } from './MyGqlHook.ts'
import { toEnrichmentSchema } from './enrichments.ts'
import denoJson from '../deno.json' with { type: 'json' }
export const myGqlEntry = toGqlOperationEntry({
id: denoJson.name,
toEnrichmentSchema,
// GQL `isSupported` typically checks the args shape — synthesizeArgsObject
// returns undefined for fields with no arguments, which most generators
// don't want to process.
isSupported: ({ operation }) => synthesizeArgsObject(operation) !== undefined,
transform: ({ context, operation }) => {
context.insertOperation({ projection: MyGqlHook, operation })
}
})
export default myGqlEntryThe GraphQL entry is mostly symmetric with OAS — same
({ context, operation, variant }) => void transform, same
pre-resolved enrichments, same Driver / cache model. Two
GraphQL-specific differences:
-
Routing keys are
[rootKind][fieldName], not[path][method]. The cache key and the enrichment lookup path key on these. (The projection base'stoEnrichmentsresolves['enrichments', id, rootKind, fieldName, variant], mirroring the OAS[…, path, method, variant]— you don't walk enrichments yourself.) -
Mutation args come via
synthesizeArgsObject(operation). GQL has norequestBody.synthesizeArgsObjectturns the field's typed argument list into anOasObjectso the sameinsertNormalizedModel(TsProjection, …)path that OAS uses for request bodies works for GraphQL arguments.
Why the class-based pattern, not a functional transform?
Earlier stock generators (@skmtc/gen-graphql-operation,
@skmtc/gen-graphql-typed-document-node) used a functional pattern
— a free emitOperation helper called from transform, no
Projection class. Both were deleted on 2026-05-13. The reasons were
mechanical:
- The artifacts they produced (
<Op>Args,<Op>Result,<Op>Document) had zero.tsconsumers across the workspace — no peer generator ever needed to find them viainsertOperation. - Most of each
transform's work wascontext.insertNormalizedModel(TsProjection, …)delegation; the package's distinctive logic was a naming convention plus an export path — which is exactly what a Projection class encapsulates. - The functional shape bypassed
affirmDefinitionintegrity checks (it calledcontext.register({ definitions: [new TsDefinition(...)] })directly with hand-builtgeneratorKeys).
Class-based Projections get all four framework guarantees from
insertOperation (Definition registration, cross-File import
registration, insertion order, refactor resilience). A free helper
in transform skips them.
If the only thing your generator does in transform is delegate to
TsProjection / ZodProjection for typing the operation, the
package may not be earning its boundary. Inline the call in
whichever consuming generator needs the type, or audit zero
consumers before publishing.
Read the GraphQL operation model
The full GqlOperation shape (from
core/gql/operation/GqlOperation.ts):
class GqlOperation {
readonly oasType: 'gqlOperation'
readonly rootKind: 'query' | 'mutation' | 'subscription'
readonly fieldName: string // e.g., 'getUser'
readonly arguments: GqlArgument[] // typed argument list
readonly returnType: OasSchema | OasRef<'schema'> // OAS-shaped return type
readonly returnTypeString: string // human-readable original
readonly description: string | undefined
readonly deprecated: boolean
readonly deprecationReason: string | undefined
// Computed: `<rootKind>_<fieldName>` e.g. `query_getUser`
get identifier(): string
}GraphQL types are normalized to the same OasSchema family used
for OAS schemas — that's how the same TypeScript-producing code
works for both ecosystems.
Compose with peer GraphQL generators
New GraphQL generator authors typically reach
for TsProjection from @skmtc/gen-typescript for typing
operations' arguments and return values. The parsed types are
interoperable across OAS and GraphQL because of the shared
OasSchema representation, so the same
insertNormalizedModel(TsProjection, …) call works for both
protocols.
Verification
skmtc generate <project>The CLI's worker parses the GraphQL SDL inside the worker
process (host-side parsing isn't possible — see the GraphQL
asymmetry). Your
generator runs against each GqlOperation.
Inspect the output for a representative root field. The generated code should reference the field name, args, and result type as expected.
Troubleshooting
- "GraphQL parse error" — The SDL has syntax issues. The parse runs inside the worker; the error message includes a line/column. Validate with a standalone GraphQL parser if needed.
isRef()always false on return types — GraphQL return types are sometimes converted to inlineOasObjects rather thanOasRefs, depending on whether the type is reusable across operations. Handle both cases via the standard schema-variant dispatch.- Schemas referenced but not produced — Composing with a
peer GraphQL generator that's not installed. Run
skmtc list.
Related
- The GraphQL pipeline concept —
the conceptual model: shared
OasSchemavocabulary, type- mapping rules, scalar handling, operation generator patterns - The GraphQL asymmetry — why GraphQL parses inside the worker
- API: Projection bases —
including
toTsGqlOperationProjectionBase - API: GraphQL document model —
GqlDocument,GqlRegistry,GqlOperation,GqlArgument
How to compose with another generator
Reference another generator's Projection from your generator's output — by name, not by source text.
How to swap a peer dependency
Switch a cloned generator from one peer generator to another (e.g., from gen-tanstack-query-supabase-zod to gen-tanstack-query-fetch-zod).