Skip to content

gql-x/Composer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@gql-x/composer

A DSL for composing GraphQL query strings with nicer DX.

@gql-x/composer is a general-purpose GraphQL query composer: it produces plain, spec-compliant GraphQL text suitable for any GraphQL endpoint, any client transport, any tooling.

Think of it as a spiritual successor to the older gql-query-builder package; it solves the same problem (building GraphQL queries from host-language values rather than templated strings), but with better ergonomics around variable hoisting, dynamic composition, and field-level expressivity. None of the code or API is ported or shared; the kinship is in problem space and motivation, not in implementation.

For design rationale and tradeoffs, see DESIGN.md. For details on the extension points composer exposes for building higher-level layers on top of it, see EXTENSIBILITY.md.

Design Overview

Two primary goals motivate the design:

  1. Reduce variable bookkeeping. Annotate a variable's type at its use site; the builder hoists the declaration into the operation's parameter list and deduplicates automatically.

  2. First-class dynamic composition. Query fragments (arguments, selection-sets, etc) are plain JS values that can be conditionally included, named, passed around, and combined using ordinary host-language logic. No string templating, no parameter-list maintenance.

A third theme runs throughout: meaning is conveyed by explicit names (selectionSet, varArgs, litArgs) rather than syntactic position. This trades raw-GraphQL positional convention for label-driven composition that can be reordered to foreground whatever matters most about a given query.

Getting Started

Composer is a factory-producing module: each call to createComposer() returns its own independent instance with its own closure-private state (token symbols, internal WeakMaps, caches).

import { createComposer } from "@gql-x/composer";

var {
    $f, $t, $v, $m,
    varArgs, litArgs, varDefs,
    selectionSet, root, operationName,
    raw, query, mutation, subscription,
    isGQLName,
} = createComposer();

NOTE: All the destructured API methods shown above are bound to that instance of the Composer, and cannot safely be mixed with helpers from other Composer instances. Generally, though, you'll only create one Composer instance in your application.

// minimal: just the builder + the bits used in the simplest query
var { query, root, selectionSet } = createComposer();

query(
    root("ping"),
    selectionSet("ok")
)
// {
//    text: "query { ping { ok } }",
//    kind: "query",
//    opName: null,
//    resName: "ping"
// }

At a Glance

Consider a typical GraphQL query like the one below; fetching a user by ID with a date-bounded sub-selection of recent posts:

query GetUser(
    $userID: ID,
    $sinceDate: DateTime
) {
    user(id: $userID) {
        firstName
        lastName
        recentPosts: posts(since: $sinceDate) {
            title
            publishedAt
        }
    }
}

A few things stand out as friction:

  • Variable duplication. $userID is declared once but its definition ($userID: ID) lives far away from its use site. Same for $sinceDate.

  • Sigil bookkeeping. Every variable carries a $ everywhere it appears. Easy to typo, easy to forget.

  • Nesting tax. Field aliases, field-level arguments, and sub-selections each have their own syntactic shape to remember.

Here's the Composer equivalent:

query(
    operationName("GetUser"),
    root("user"),
    varArgs($v("id","userID","ID")),
    selectionSet(
        "firstName",
        "lastName",
        $m(
            $f`recentPosts``posts ${
                varArgs($v("since","sinceDate","DateTime"))
            }`,
            [ "title", "publishedAt" ]
        )
    )
)

Variables are declared inline where used, then hoisted into the parameter list (and de-duplicated/de-conflicted) by the builder (raw(), query(), etc), so you never have to type both a separate parameter declaration and the use-site reference.

Fluent Helpers vs. Object Literal Forms

Composer provides two families of helpers, and both produce plain JS object structures that the query-builder consumes:

  1. Option-key helpers like varArgs(..), litArgs(..), varDefs(..), selectionSet(..), root(..). Each produces a single-property object keyed by its option name. They're passed as variadic arguments to a builder (raw(..), query(..), etc).

    In other words, varArgs(..) produces { varArgs: .. }.

  2. Chunk-producing helpers like $v(..), $m(..), $f`...`. They produce structural object chunks for the inside of those options.

    In other words, $m(..) produces { field: value }.

Both families are pure object-shape sugar. Every helper has an equivalent object literal form, and the query-builder accepts either form interchangeably.

// helper form (recommended)
raw(
    root("user"),
    varArgs($v("id","ID")),
    selectionSet("firstName","lastName")
)

// object literal form (also accepted)
raw({
    root: { field: "user" },
    varArgs: { id: "ID" },
    selectionSet: [ "firstName", "lastName" ]
})

// mixed (also accepted)
raw(
    root("user"),
    varArgs({ id: "ID" }),
    { selectionSet: [ "firstName", "lastName" ] }
)

The object literal forms are the base. The helpers exist as sugar on top, to reduce repetition and visual syn-tax. If a helper doesn't fit a particular shape cleanly, drop down to an object literal for that bit.

Query Builder Options

Query Builder's raw(..) accepts variadic option-key helpers (or a single options object), and returns a query-builder result object (see below).

NOTE: In general, an option-key helper like { whatever: .. } has the preferred whatever(..) named function form, as illustrated in the examples below.

The following options are recognized:

  • kind (string, default: "query"): allows "query", "mutation" or "subscription".

    { kind: "query" }

    NOTE: It's generally preferred to use the scoped query-builder methods query(..), mutation(..), and subscription(..), which each preset the underlying kind accordingly:

    // raw({ kind: "query", .. })
    query({ .. })
    
    // raw({ kind: "mutation", .. })
    mutation({ .. })
    
    // raw({ kind: "subscription", .. })
    subscription({ .. })
  • operationName (string, default: null): the operation name in the query text (e.g., GetUser). Pass null or "" to omit, in which case the builder falls back to Query / Mutation / Subscription (based on kind) if variable defs are present, or omits the operation header entirely if not. Most easily produced via the operationName(..) option-key helper.

    operationName("getUser")
    // { operationName: "getUser" }
  • root (object): specifies the root field shape. Most easily produced via the root(..) option-key helper.

    root(field) bare field:

    root("user")
    // { root: { field: "user" } }

    Produces a root like user(..) { .. }.

    root(field, alias) aliased root:

    root("currentUser","user")
    // { root: { field: "currentUser", alias: "user" } }

    Produces a root like currentUser: user(..) { .. }.

  • varArgs (option): operation-level (and field-level) arguments whose values are variable type-defs. The builder hoists the type-defs into the operation parameter list automatically.

    For example:

    varArgs(
        $v("id","userID","ID"),
        $v("limit","Int")
    )

    Produces operation parameters: $userID: ID, $limit: Int, and operation arguments: id: $userID, limit: $limit.

  • litArgs (option): operation-level (and field-level) arguments with literal values. Leaf values can be built-in JS types (42, "hello", true), bare-tokens via $t (e.g., $t.DESC), or manual variable references via $t (e.g., $t.$orderBy).

    For example:

    litArgs(
        $m("order", $m("lastName", $t.DESC)),
        $m("limit", 50)
    )

    Produces: order: { lastName: DESC }, limit: 50.

  • varDefs (option): manual variable type-defs. Adds explicit parameter declarations to the operation without tying them to any specific argument position; useful when a variable is referenced manually via $t.$varName in literal-based arguments.

    For example:

    varDefs($v("orderBy","String"))

    Adds $orderBy: String to the operation's variable type definitions. The variable can then be referenced in litArgs (operation-level or field-level) via $t.$orderBy.

  • selectionSet (option): the fields to include in the selection-set.

    For example:

    selectionSet(
        "firstName",
        "lastName",
        $f`ownerEmail``email`
    )

    Produces: firstName lastName ownerEmail: email.

    Each argument is a selection entry: a bare string for a scalar field, an $f helper for an aliased or argument-bearing field reference, or an object-keyed entry for sub-selections (see "Field-Level Selections" below).

    To omit the selection-set block entirely: selectionSet(null), selectionSet($f.noSelection), or selectionSet.none().

Chunk-Producing Helpers

$v: Variable Leaf Specs

$v builds variable leaf-specs for varArgs and varDefs.

$v(chunk1, chunk2, ..) composes/merges chunks; it takes the place of an object literal on the right side of varArgs: / varDefs:, merging the individual leaf chunks passed in.

varArgs: $v(
    $v("id","ID"),
    $v("limit","Int")
)

NOTE: Since $v(..) composes object chunks, the chunks it accepts can also be object-spread directly into a regular object literal as a more flexible alternative.

The 2-arg form $v(name,type) defaults the variable name to the argument name:

$v("id","ID")
// chunk: { id: "ID" }
// type def: $id: ID, arg: id: $id

The 3-arg form $v(name,varName,type) sets the variable name explicitly:

$v("id","userID","ID")
// chunk: { id: { userID: "ID" } }
// type def: $userID: ID, arg: id: $userID

NOTE: Anywhere a type string appears -- as long as it doesn't include non-identifier characters like [ or ! -- a $t bare-name token is also accepted. For example: $t.String, $t.DateTime, $t.ID. This can help visually distinguish the type from the surrounding field/variable name strings:

$v("id",$t.ID)

$t: Bare-Name Tokens

$t is a proxy that produces bare-name tokens for use in literal-based argument positions.

$t.DESC      // renders as: DESC
$t.UTC_NOW   // renders as: UTC_NOW
$t.String    // renders as: String  (usable as a type string)

A leading $ on the property name marks it as a manual variable reference (for use alongside varDefs):

$t.$orderBy  // renders as: $orderBy

Bare tokens can appear anywhere a literal value is expected: inside litArgs, as type strings in $v / variable specs, etc.

$m: Map Literals

$m builds object structures, useful in places where the literal data shape would otherwise require object-literal syntax.

The 2-arg form $m(name,value) produces a single-property object:

$m("order",$t.DESC)
// chunk: { order: $t.DESC }
// arg: order: DESC

$m("foo",42)
// chunk: { foo: 42 }
// arg: foo: 42

Nesting requires explicit $m calls per level:

$m("order",
    $m("title",$t.DESC)
)
// chunk: { order: { title: $t.DESC } }
// arg: order: { title: DESC }

Multiple chunk-objects as trailing args merge as siblings under the named property:

$m("order",
    $m("title",$t.DESC),
    $m("year",$t.ASC)
)
// chunk: { order: { title: $t.DESC, year: $t.ASC } }
// args: order: { title: DESC, year: ASC }

$m also accepts an $f token (or its symbol) as the property-name, mirroring the [$f...] computed-property syntax, for selection-set entries:

selectionSet(
    $m(
        $f`recentPosts``posts ${ /* .. */ }`,
        [ "title", "publishedAt" ]
    )
)

is equivalent to:

selectionSet: {
    [ $f`recentPosts``posts ${ /* .. */ }` ]:
        [ "title", "publishedAt" ]
}

$f: Field-Level References

$f produces field-level reference tokens for use in selectionSet(..). It supports field aliases, field-level arguments, and pairs with $m for sub-selections.

$f supports two equivalent calling styles. The tagged-template form ($f`alias``field`) is JS-specific and is more terse/closer to GraphQL's own alias syntax. The function-call form ($f(alias,field)) is conventional JS and is the basis for ports to other languages (Go, Rust, etc.).

Both forms produce identical tokens and work interchangeably in all positions: selectionSet(..), computed property keys ({ [$f(...)]: subSel }), and $m(..).

Signatures:

// tag form
$f`fieldName`
$f`alias``fieldName`
$f`fieldName ${combinator}`
$f`alias``fieldName ${combinator}`

// function-call form
$f("fieldName")
$f("alias", "fieldName")
$f("fieldName", combinator)
$f("alias", "fieldName", combinator)

Side by side:

// alias only
$f`ownerEmail``email`
$f("ownerEmail", "email")

// field with args, no alias
$f`posts ${varArgs($v("since","DateTime"))}`
$f("posts", varArgs($v("since","DateTime")))

// alias + field + args
$f`myPosts``posts ${varArgs($v("since","DateTime"))}`
$f("myPosts", "posts", varArgs($v("since","DateTime")))

The choice between forms is purely stylistic. The tag form is more compact and reads left-to-right as alias: field, matching GraphQL's own rendering. The function-call form is immediately readable to anyone familiar with conventional JS and maps directly to how other language ports express the same concept.

Field-Level Selections

To alias a field name in a selection-set:

selectionSet(
    // ..
    $f`userFirstName``firstName`,
    // ..
)

Produces a field-level reference like userFirstName: firstName, which aliases the firstName field name to userFirstName in the result set.

To use field-level arguments (and aliases, if desired) on an object field with sub-selection, pair the $f helper with $m to produce a computed-property selection-set entry. The $f interpolation accepts an array of chunks (merged together), so the option-key helpers and other chunk producers compose naturally inside it:

selectionSet(
    // ..
    $m(
        $f`myPosts``posts ${[
            varArgs($v("since","DateTime")),
            litArgs($m("limit",50))
        ]}`,
        [ "title", "publishedAt" ]
    )
    // ..
)

NOTE: The [ ] surrounding the interpolation expression is there to allow the two argument-bearing values. If there's only one value being interpolated, you can pass it directly without the [ ] around it.

The $f interpolation also accepts a single object literal directly, equivalent to the array-of-chunks form above:

selectionSet(
    // ..
    $m(
        $f`myPosts``posts ${{
            varArgs: { since: "DateTime" },
            litArgs: { limit: 50 }
        }}`,
        [ "title", "publishedAt" ]
    )
)

Either form above produces this field-level reference with sub-selection:

myPosts: posts(since: $since, limit: 50) {
    title
    publishedAt
}

Query Result Object

The query-builders (raw(), query(), etc) all return a result object with the following properties:

  • text: the ready-to-execute query text

  • opName: the operation name embedded in the query text (e.g., GetUser), to pass along to whatever GraphQL endpoint will execute the query

  • resName: the result set name (e.g., the root field's alias if one was set, otherwise its bare field name)

  • kind: the kind of query string ("query", "mutation", or "subscription")

For example, this call:

query(
    operationName("GetUser"),
    root("user"),
    varArgs($v("id","ID")),
    selectionSet("firstName","lastName")
)

Produces:

{
    text: "query GetUser($id:ID) { user(id:$id) { firstName lastName } }",
    opName: "GetUser",
    resName: "user",
    kind: "query"
}

Other Exports

  • isGQLName(str): predicate; returns true if str is a valid GraphQL name (per the spec's identifier grammar). Useful for validating dynamically-supplied field or alias names before passing them to the builder.

Extending Composer

Composer is built to be extended. Higher-level layers -- backend-flavored DSLs, opinionated query helpers, transport-coupled clients -- can register against composer's internals to add new syntax, new combinators, and new rendering behavior, without composer itself needing to know about any of it.

This package also ships an abstract DB-shaped layer (@gql-x/composer/db) that sits between bare composer and a fully-realized backend-specific package. It adds auto-prefixing of schema names (handy for backends without native namespacing), a pluggable transport spread, and a decorate hook for layering on backend-specific helpers. It functions more like an interface or abstract base class than a direct tool; you wouldn't normally instantiate it directly.

The extension points, the DB layer's full surface, and the render protocol that makes deep customization possible are all documented in EXTENSIBILITY.md.

Tests

A test suite is included in this repository, as well as the npm package distribution. The default test behavior runs the test suite using the files in src/.

To run the test suite:

npm test

License

License

All code and documentation are (c) 2026 Kyle Simpson and released under the MIT License. A copy of the MIT License is also included.

About

An extensible DSL for composing GraphQL strings with nicer DX

Resources

License

Stars

Watchers

Forks

Contributors