@iappx/gql-builder
v1.0.0
Published
A dependency-free TypeScript GraphQL document builder: compose queries, mutations and subscriptions as an object tree and print them with variables extracted automatically.
Maintainers
Readme
About The Project
@iappx/gql-builder builds GraphQL operations at runtime, without a schema, without codegen and without a
GraphQL parser in the bundle. You compose an object tree — operation → fields → arguments → values — and the
printer turns it into a document you can hand to any transport:
{ query: string, variables: Record<string, unknown>, operationName?: string }Why it exists:
- Runtime-shaped queries. Selection sets that depend on data known only at runtime (entity metadata,
user-selected columns, dynamic filters) cannot be written as static
gqltags. - Variables, not string interpolation. Every dynamic value can be lifted into a real GraphQL variable, so the document text stays constant while values change — no escaping bugs, no injection through user input, and a stable document for persisted-query / APQ caching.
- No dependencies. Nothing is imported, so it runs in the browser, in Node and in workers alike.
It is transport-agnostic on purpose: it never sends anything, it only builds documents.
Installation
npm install @iappx/gql-builderRequires Node.js 18 or newer when running server-side. The package has no dependencies and uses no Node APIs, so it also runs unchanged in browsers and workers.
Quick Start
import { Gql, GqlOperation, GqlType } from '@iappx/gql-builder'
const operation = GqlOperation.single('query', 'user', Gql.fields(['id', 'name']))
operation.root.addArg('id', Gql.variable(GqlType.int().required(), 42, 'id'))
const document = operation.build()
// document.query → 'query user($id: Int!) { user(id: $id) { id name } }'
// document.variables → { id: 42 }
// document.operationName → 'user'GqlOperation.single(type, rootFieldName, fields?) covers the common case of a single root field; the root
field is available as operation.root. For several root fields, create the operation and add fields yourself:
const operation = Gql.query('dashboard')
operation.field('user').addFields(Gql.fields(['id']))
operation.field('project').addFields(Gql.fields(['id']))Mutations and subscriptions work the same way:
const mutation = GqlOperation.single('mutation', 'createUser', Gql.fields(['id']))
mutation.root.addArg('input', Gql.variable(GqlType.named('UserCreateInput').required(), { name: 'bob' }, 'input'))
const subscription = GqlOperation.single('subscription', 'userChanged', Gql.fields(['id', 'name']))Variables
A variable carries its GraphQL type, its value and an optional preferred name:
Gql.variable(GqlType.int().required(), 42, 'id') // → $id, declared as ($id: Int!), value 42
Gql.variable(GqlType.int().required(), 42) // → auto-named $v1, $v2, …The type is a GqlType, never a string of GraphQL syntax — the builder assembles the brackets and the !:
GqlType.int() // Int
GqlType.int().required() // Int!
GqlType.string().listOf() // [String]
GqlType.string().required().listOf() // [String!]
GqlType.string().listOf().required() // [String]!
GqlType.named('UserCreateInput').required() // UserCreateInput!Built-in scalars have their own factories (int, float, string, boolean, id); everything the schema
defines itself — input objects, enums, custom scalars — comes from GqlType.named('…'), whose argument is
validated as a GraphQL name. Types are immutable: listOf() and required() return new instances.
Variables are discovered by walking the operation on build(): arguments, directive arguments and values
nested inside lists and objects are all scanned. Names are made unique automatically ($id, $id_2, …), and
the same GqlVariable instance reused in several places is declared once:
import { GqlVariable } from '@iappx/gql-builder'
const id = new GqlVariable(GqlType.int().required(), 7, 'id')
const operation = Gql.query('user')
const root = operation.field('user')
root.addArg('id', Gql.useVariable(id))
root.field('parent').addArg('id', Gql.useVariable(id)).addFields(Gql.fields(['id']))
// query user($id: Int!) { user(id: $id) { parent(id: $id) { id } } }Because values live outside the document, two calls that differ only in values produce byte-identical query text — which is what makes automatic persisted queries effective.
Values
Values can be built explicitly or converted from plain JavaScript:
Gql.string('text') // "text" — properly escaped, including U+2028 / U+2029
Gql.int(10) // 10 — rejects non-integers
Gql.float(1.5) // 1.5 — rejects NaN / Infinity
Gql.boolean(true) // true
Gql.nullValue() // null
Gql.enumValue('DESC') // DESC — printed unquoted
Gql.list([Gql.int(1)]) // [1]
Gql.object({ a: Gql.int(1) }) // { a: 1 }
Gql.from(someValue) // infers the value type
Gql.fromObject({ name: 'bob', tags: ['a', 'b'], createdAt: new Date() })Gql.from maps number to Int or Float by Number.isInteger, Date to an ISO string, null and
undefined to null, arrays to lists (element by element) and objects to input objects. Values that are
already GqlValue instances pass through untouched, so you can mix them into plain data.
Field, argument, directive, alias, enum and variable names are validated against the GraphQL name grammar — an invalid name throws instead of producing a broken document.
Selections
Selection sets can be built from names, from a declarative tree, or field by field:
Gql.fields(['id', 'name'])
Gql.selection([
{ name: 'id' },
{ name: 'name', alias: 'title' },
{ name: 'posts', args: { limit: Gql.int(10) }, fields: [{ name: 'id' }] },
])
const field = Gql.field('user')
field.field('profile').addFields(Gql.fields(['avatar']))TSelectionNode ({ name, alias?, args?, directives?, fields? }) is a plain data shape, which makes it the
integration point for code that derives selections from its own metadata — an ORM, an entity registry, a
column picker — without that code depending on the builder classes.
Directives
const directive = Gql.directive('orderBy', [
Gql.arg('direction', Gql.enumValue('DESC')),
Gql.arg('index', Gql.int(0)),
])
Gql.field('createdAt').addDirective(directive)
// createdAt @orderBy(direction: DESC, index: 0)Directive arguments accept variables just like field arguments:
Gql.directive('filters', [
Gql.arg('items', Gql.variable(GqlType.named('FilterInput').listOf(), filterItems, 'filters')),
])Printing
operation.build() // { query, variables, operationName? }
operation.print() // compact, single line
operation.print({ pretty: true }) // indented
operation.print({ pretty: true, indent: ' ' })The compact form is what you send; the pretty form is for logs and debugging. Printing is deterministic: the same tree always yields the same text, including variable names.
API
| Entry point | Purpose |
|---|---|
| Gql | Static facade over everything below — values, fields, directives, operations |
| GqlOperation | Operation root: query / mutation / subscription, single, field, root, build, print |
| GqlField | Field node: field, addField(s), addArg(s), addDirective(s), alias |
| GqlArgument, GqlDirective | Argument and directive nodes |
| GqlValue and subclasses | GqlStringValue, GqlIntValue, GqlFloatValue, GqlBooleanValue, GqlNullValue, GqlEnumValue, GqlListValue, GqlObjectValue, GqlVariableValue |
| GqlValueFactory | fromJs, fromObject |
| GqlVariable, GqlVariableRegistry | Variable declaration, naming and value collection |
| GqlType | Variable types: int, float, string, boolean, id, named, listOf, required |
| GqlSelectionFactory, TSelectionNode | Declarative selection trees |
| GqlPrinter, TGqlPrintOptions | Document printing |
| TGqlDocument | { query, variables, operationName? } |
Not supported (by design, for now): fragments, inline fragments and schema validation. The builder trusts the tree you give it and only checks what it can check without a schema.
License
Distributed under the MIT License. See LICENSE for more information.
