Document Builder
Generate typed GraphQL document strings at compile-time without a client instance. Perfect for passing typed documents to other GraphQL clients (graphql-request, urql, Apollo, etc.) or building tools.
How it works
After running the generator, import query
, mutation
, or subscription
builders from your generated code. Call methods to generate TypedDocument.String
objects with the GraphQL string and full TypeScript types.
import { $ } from 'graffle/extensions/document-builder'
import { Graffle } from './graffle/$.js'
const doc = Graffle.query.trainerByName({
$: { name: $ }, // Variables automatically extracted
name: true,
class: true,
})
// doc.document → GraphQL string
// doc → typed as TypedDocument.String<ResultType, VariablesType>
Example:
Building Full Documents
The Graffle.document()
function creates complete GraphQL documents containing multiple named operations (queries and/or mutations). This is useful when you want to define several operations in one document and execute them selectively.
import { $ } from 'graffle/extensions/document-builder'
import { Graffle } from './graffle/$.js'
const doc = Graffle.document({
query: {
getTrainer: {
trainerByName: {
$: { name: $('trainerName') },
id: true,
name: true,
class: true,
},
},
getPokemons: {
pokemons: {
name: true,
type: true,
},
},
},
mutation: {
addTrainer: {
addPokemon: {
$: { name: $('pokemonName'), $type: $('pokemonType') },
name: true,
type: true,
},
},
},
})
Sending Documents with client.send()
Use the client.send()
method to execute documents directly without chaining from .gql()
:
import { $ } from 'graffle/extensions/document-builder'
import { Graffle } from './graffle/$.js'
const client = Graffle.create()
// Single operation - no operation name needed
const singleOpDoc = Graffle.document({
query: {
getTrainer: {
trainerByName: { $: { name: $('name') }, id: true, name: true },
},
},
})
const trainer = await client.send(singleOpDoc, { name: 'Ash' })
// Multiple operations - operation name required
const multiOpDoc = Graffle.document({
query: {
getTrainer: { trainerByName: { $: { name: $('name') }, id: true } },
getPokemons: { pokemons: { name: true } },
},
})
const trainerResult = await client.send(multiOpDoc, 'getTrainer', {
name: 'Ash',
})
const pokemonsResult = await client.send(multiOpDoc, 'getPokemons')
Comparison with .gql()
Both approaches are supported:
// Chained API
const result = await client.gql(doc).send({ id: '123' })
// One-shot API
const result = await client.send(doc, { id: '123' })
Use .send()
when you have a pre-built document from static builders or codegen. Use .gql().send()
when building the document inline.
Example:
Configuration
The document builder can be configured using global defaults:
import { staticBuilderDefaults } from 'graffle/extensions/document-builder'
// Change default behavior for all queries
staticBuilderDefaults.hoistArguments = false
Hoisting Arguments
By default, the builder extracts all arguments as GraphQL variables (hoistArguments: true
). This provides:
- Better query caching (same query structure, different variable values)
- Automatic custom scalar encoding
- Alignment with GraphQL best practices
Example with default behavior:
const doc = query.trainerByName({
$: { name: 'Ash' },
name: true,
class: true,
})
// Generates:
// query($name: String!) {
// trainerByName(name: $name) { name class }
// }
// Variables: { name: "Ash" }
Disabling automatic extraction:
staticBuilderDefaults.hoistArguments = false
const doc = query.trainerByName({
$: { name: 'Ash' },
name: true,
})
// Generates:
// query { trainerByName(name: "Ash") { name } }
Note: Explicit $
markers are ALWAYS extracted as variables, regardless of this setting:
staticBuilderDefaults.hoistArguments = false
const doc = query.pokemonByName({
$: {
name: $('pokemonName'), // Always extracted
},
name: true,
trainer: {
name: true,
},
})
// Generates:
// query($pokemonName: String!) {
// pokemonByName(name: $pokemonName) { name trainer { name } }
// }
Conflict Resolution
When both explicit $
markers and auto-hoisted arguments want the same variable name, automatic renaming occurs:
const doc = query.trainerByName({
$: {
name: $('trainerName'), // Gets: $trainerName
},
name: true,
class: true,
})
Feature Reference
This section provides detailed examples of how each GraphQL feature is expressed using Graffle's selection set syntax.
Basic Query
query.trainers({ name: true })
Arguments
Arguments are passed using the special $
property in your selection set.
query.pokemons({
$: { filter: { name: { in: ['Pikachu', 'Charizard'] } } },
name: true,
trainer: { name: true },
})
Nested Arguments:
Arguments work at any nesting level. Each field can have its own $
argument property.
query.trainers({
$: { filter: { class: 'Elite' } },
name: true,
pokemons: {
$: { filter: { type: 'FIRE' } },
name: true,
type: true,
},
})
Example:
Variables
query.trainerByName({ $: { name: $ }, name: true })
Variable Type Inference
Graffle automatically infers GraphQL types for variables using two strategies:
1. Schema-Driven (with generated client):
When using a generated client, Graffle uses Schema-Driven Data Map (SDDM) metadata to infer precise GraphQL types including nullability, list types, and custom scalars:
import { Graffle } from './graffle/$.js'
Graffle.query.trainerByName({
$: { name: 'Ash' }, // Inferred as: $name: String!
name: true,
})
// Generated: query($name: String!) { trainerByName(name: $name) { name } }
2. Value-Based (without SDDM):
When SDDM metadata is unavailable (e.g., stripped for bundle size), the same generated client falls back to inferring GraphQL types from JavaScript runtime values:
import { Graffle } from './graffle/$.js'
// Without SDDM, types are inferred from JS values:
Graffle.query.trainerByName({
$: { name: 'Ash' }, // Inferred as: String (not ID!)
name: true,
})
// Generated: query($name: String) { trainerByName(name: $name) { name } }
// Note: String vs ID! - less precise than schema-driven inference
Type Inference Rules:
JavaScript Type | GraphQL Type |
---|---|
string | String |
number (integer) | Int |
number (decimal) | Float |
boolean | Boolean |
Array<T> | [InferredType<T>] |
Note: Schema-driven inference provides more accurate types (e.g., ID!
vs String
, custom scalars) and is recommended unless you're bundle-size sensitive. Value-based inference is a fallback for development or when schema access is limited.
Variable Naming
Variables can be named explicitly or use automatic naming:
// Explicit naming
query.trainerByName({ $: { name: $('trainerName') }, name: true })
// Generated: query($trainerName: String!) { trainerByName(name: $trainerName) { name } }
// Automatic naming (uses argument name)
query.trainerByName({ $: { name: $ }, name: true })
// Generated: query($name: String!) { trainerByName(name: $name) { name } }
Optional and Required Variables
Use modifiers to control nullability:
// Required (default)
query.trainerByName({ $: { name: $ }, name: true })
// Generated: query($name: String!) { ... }
// Optional
query.trainerByName({ $: { name: $.optional }, name: true })
// Generated: query($name: String) { ... }
Variable Defaults
Provide default values using the third parameter:
query.trainers({ $: { limit: $(10) }, name: true })
// Generated: query($limit: Int = 10) { trainers(limit: $limit) { name } }
Mutations
mutation.addPokemon({
$: { name: 'Pikachu', $type: 'ELECTRIC' },
id: true,
name: true,
type: true,
})
Aliases
Aliases allow you to request the same field multiple times with different arguments. Use the $batch
method with the field name as the key and an array of [aliasName, selectionSet]
tuples.
query.$batch({
pokemons: [
['elderPokemons', {
$: { filter: { birthday: { lte: '1924-01-01' } } },
name: true,
}],
['babyPokemons', {
$: { filter: { birthday: { gte: '2023-01-01' } } },
name: true,
}],
],
})
// Result type: { elderPokemons: ..., babyPokemons: ... }
Example:
Directives
GraphQL directives like @skip
and @include
are written using special $
prefixed properties.
query.$batch({
trainers: {
name: true,
id: {
$skip: true,
},
pokemon: {
id: {
$include: false,
},
name: true,
},
},
})
You can also apply directives to entire field groups using the special ___
key:
query.$batch({
___: {
$skip: true,
pokemons: {
name: true,
},
},
})
Example:
Note on @defer and @stream:
The experimental @defer
and @stream
directives are not yet supported in Graffle. These directives enable incremental delivery of GraphQL responses. Support is planned as a future extension. Track progress in #1134.
Enums
Enum values are passed as strings and automatically validated by TypeScript based on your schema.
query.pokemons({
$: { filter: { type: 'FIRE' } },
// ^^^^^^
// TypeScript validates this is a valid enum value
name: true,
type: true,
})
TypeScript will provide autocomplete for valid enum values and show errors for invalid ones.
Inline Fragments
Inline fragments are used to select fields on specific types in unions and interfaces. Use the ___on_TypeName
syntax.
Unions
query.battles({
__typename: true,
___on_BattleRoyale: {
date: true,
combatants: {
trainer: { name: true },
},
},
___on_BattleTrainer: {
date: true,
combatant1: {
trainer: { name: true },
},
},
})
// Result is a discriminated union type based on __typename:
for (const battle of battles) {
switch (battle.__typename) {
case 'BattleRoyale':
// TypeScript knows: battle.combatants is available
break
case 'BattleTrainer':
// TypeScript knows: battle.combatant1 is available
break
}
}
Example:
Interfaces
Interface fragments work the same way as unions, using ___on_TypeName
for each implementing type.
query.beings({
__typename: true,
id: true,
name: true,
___on_Patron: {
money: true,
},
___on_Trainer: {
class: true,
},
___on_Pokemon: {
type: true,
},
})
Example:
Field Groups
Field groups allow you to apply directives to multiple fields at once using the special ___
key.
query.$batch({
___: {
$skip: true,
pokemons: {
name: true,
},
},
})