If you have ever built a GraphQL query dynamically in JavaScript, you know the quiet misery of it. You start with a clean template literal, and then the requirements arrive: this field only when the user is an admin, that argument only when a filter is set, and a variable that needs to be declared in the operation header but is actually used four levels deep in the selection set. Before long you are concatenating strings, juggling commas, and manually keeping a list of $variables in sync with the places that reference them. The query is logically simple, but the bookkeeping is anything but.
@gql-x/composer is a library by Kyle Simpson (the author of You Don't Know JS) that takes a different angle on this problem. Instead of templating strings, you compose queries out of plain JavaScript values. Every piece of a query, the root field, the selection set, the arguments, the directives, is just a value you can name, store, pass around, conditionally include, and combine using ordinary host-language logic. The headline trick is that you declare variables right where you use them, and the builder automatically hoists their type declarations up to the operation header and deduplicates them. It produces spec-compliant GraphQL text that works with any endpoint or client.
A quick note on maturity before we dive in: this is an early-stage project. At the time of writing it ships under a continuous pre-release scheme (versions look like 0.0.0-pre-202606021257, published almost daily during active development), and it is only a few weeks old. The trust signals are good, though: zero runtime dependencies, an MIT license, and bundled TypeScript definitions. Treat it as promising early-adopter territory rather than something battle-hardened, and expect the API to keep evolving.
Why Compose Instead of Template
The core idea is that GraphQL's own tools for dynamism are thin. Inline fragments and the @skip/@include directives only get you so far, and they live inside the query string rather than in your code. When you genuinely need to build a query on the fly, you usually fall back to string manipulation, which has no structure and no safety net.
Composer turns the query into data. Because each clause is a real JavaScript value, you can do this:
const fields = ["firstName", "lastName"];
if (includeEmail) fields.push("email");
and pass fields straight into a selection set. No string gymnastics, no dangling commas, no worrying about whether you remembered to declare a variable up top. That last point is the one that tends to win people over, so let us see it in action.
Getting Set Up
Installation is a single package with no transitive dependencies to worry about.
npm install @gql-x/composer
yarn add @gql-x/composer
The TypeScript types ship with the package, so there is nothing extra to install for autocomplete and type checking.
Creating a Composer Instance
Everything starts with createComposer(), which returns a bundle of helpers. Each call produces an independent instance, and you should not mix helpers from different instances. In practice, most applications need exactly one.
import { createComposer } from "@gql-x/composer";
const {
$d, $f, $t, $v, $m,
varArgs, litArgs, varDefs, directives,
selectionSet, root, operationName,
raw, query, mutation, subscription,
isGQLName,
} = createComposer();
That is a lot of helpers, but they fall into two camps: the structural combinators with readable names (query, root, selectionSet, varArgs), and a small family of terse proxy helpers ($f, $v, $m, $t, $d) that build the leaf-level pieces. We will meet them gradually.
Your First Query
The smallest meaningful query is genuinely tiny:
const { query, root, selectionSet } = createComposer();
query(
root("ping"),
selectionSet("ok")
);
This returns an object rather than a bare string:
{
text: "query { ping { ok } }",
kind: "query",
opName: null,
resName: "ping"
}
The text field is the ready-to-send query. The other fields are conveniences for the transport layer: opName is the operation name to pass to your endpoint, resName is the name of the result set (the field's alias if it has one, otherwise the bare field name), and kind tells you whether it was a query, mutation, or subscription. Notice too how the combinator names carry the meaning. You read root and selectionSet and immediately know what each argument is for, regardless of the order you wrote them in.
The Aha Moment: Variables That Hoist Themselves
Here is the example that captures the whole point of the library. Suppose you want this query, where $userID is declared in the header but used on the root field, and $sinceTS is declared in the header but used on a nested, aliased field:
query GetUser($userID: ID, $sinceTS: Int) {
user(id: $userID) {
firstName
lastName
recentPosts: posts(since: $sinceTS) {
title
publishedAt
}
}
}
With Composer you never write that header. You declare each variable at the exact spot it is used, and the builder lifts the type definitions to the top for you:
query(
operationName("GetUser"),
root("user"),
varArgs($v("id", "userID", "ID")),
selectionSet(
"firstName",
"lastName",
$m(
$f`recentPosts``posts ${
varArgs($v("since", "sinceTS", "Int"))
}`,
["title", "publishedAt"]
)
)
);
Look at $v("id", "userID", "ID"). The three-argument form reads as "the argument named id is backed by a variable named userID of type ID", which produces both the id: $userID usage and the $userID: ID header declaration. The two-argument form, like $v("since", ...) shown elsewhere, lets the variable name default to the argument name. The nested field uses $f, the field helper, with a tagged-template syntax: the first segment is the alias (recentPosts), the second is the real field plus its arguments (posts with its varArgs), and $m pairs that field with its own selection set. The $sinceTS declaration, buried two levels deep, still floats up to the header automatically. That is the bookkeeping you no longer have to do by hand.
Two Styles, One Result
Every combinator has an object-literal equivalent, and you are free to mix them. The function form and the plain-object form produce identical output, so you can lean on whichever reads better in a given spot.
// Combinator form
raw(
root("user"),
varArgs($v("id", "ID")),
selectionSet("firstName", "lastName")
);
// Object-literal form
raw({
root: { field: "user" },
varArgs: { id: "ID" },
selectionSet: ["firstName", "lastName"],
});
// Mixed, because why not
raw(
root("user"),
varArgs({ id: "ID" }),
{ selectionSet: ["firstName", "lastName"] }
);
The raw builder is the generic one where you can set kind yourself; query, mutation, and subscription are simply presets for the common cases.
Literal Arguments and Bare Tokens
Not every argument is a variable. For literal values, you reach for litArgs, combined with $m for object shapes and $t for bare-name tokens like enum values. Bare tokens matter because GraphQL enums are unquoted identifiers, not strings.
litArgs(
$m("order", $m("lastName", $t.DESC)),
$m("limit", 50)
);
// produces: order: { lastName: DESC }, limit: 50
$t.DESC renders as the unquoted token DESC, exactly what a GraphQL enum position expects, while the plain JavaScript number 50 renders as-is. The $m helper nests cleanly, so building deep input objects stays readable rather than collapsing into a wall of braces.
Conditional Shapes by Type
GraphQL inline fragments, the ... on TypeName { ... } form, are first-class here through $f.on. This is where the "queries are data" philosophy pays off, because each branch is just another value in your selection set.
selectionSet(
"id",
$m($f.on("User"), ["name", "email"]),
$m($f.on("Post"), ["title", "body"])
);
// → id ... on User { name email } ... on Post { title body }
These type-conditional selections nest, accept directives, and can be name-prefixed for backends that namespace their types. The library is also opinionated where it counts: inline fragments reject aliases and field arguments at construction time, and they require a sub-selection at render time, catching mistakes that GraphQL itself would only reject at parse time on the server.
Directives Where You Need Them
Directives can be attached at the operation level, on the root field, or on individual selection fields, and they are produced by the $d proxy. A bare $d.nonreactive becomes @nonreactive, and directives that take arguments accept the same varArgs/litArgs helpers you already know, including participating in variable hoisting.
query(
operationName("GetUser"),
directives(
$d.cached(litArgs($m("ttl", 60))),
$d.nonreactive
),
root("user").directives($d.cached),
selectionSet("firstName", "lastName")
);
// → query GetUser @cached(ttl:60) @nonreactive { user @cached { firstName lastName } }
Notice root("user").directives(...), a chained method for decorating just the root field, distinct from the operation-level directives(...) clause. One interesting design choice: the documentation supports @skip and @include but openly calls them an anti-pattern with this DSL. Since you can conditionally include a field with a plain JavaScript if, there is rarely a reason to push that decision into the query string at all.
A Foundation, Not Just a Tool
Composer is explicitly built to be extended. The intent is that higher-level layers, backend-flavored DSLs, opinionated helpers, or transport-coupled clients, sit on top of the core without modifying it. The package even ships an abstract database-shaped layer at @gql-x/composer/db that provides schema-name auto-prefixing, pluggable transport spread, and a decorate hook for layering in backend-specific helpers. Think of it as an interface to build against rather than a finished client.
There is also a small but handy utility worth knowing: isGQLName. When you accept field or alias names from dynamic input, this predicate tells you whether a string is a valid GraphQL name per the spec.
isGQLName("firstName"); // true
isGQLName("first-name"); // false
It is a tiny guardrail, but exactly the kind of thing you want when query parts come from outside your code.
Should You Reach for It
It helps to be clear about where Composer fits. It is not competing with graphql-tag, which parses a static string literal into an AST but does nothing for dynamic composition. It is not trying to replace GraphQL Code Generator and TypedDocumentNode, which give you end-to-end type safety from static .graphql documents through a build step. Those tools are wonderful when your queries are known ahead of time. The closest conceptual neighbor is something like gql-query-builder, which also assembles queries from JavaScript, but Composer goes further with its named combinators, inline-variable hoisting, type-conditional selections, and fine-grained directive support.
The sweet spot is the genuinely dynamic case: queries assembled at runtime, with fields and arguments that depend on conditions you only know when the code runs, against any endpoint, with no schema and no codegen step required. Composer makes that case feel structured and safe instead of stringy and fragile, and it does so as a zero-dependency, MIT-licensed package with types in the box.
The honest caveat remains the version number. This is 0.0.0-pre-* software with a handful of stars and a near-daily release cadence, so the API may shift under you. But the ideas are sharp, the DX is genuinely nicer than hand-rolling strings, and if you have been fighting dynamic GraphQL composition, it is well worth keeping an eye on. Sometimes the best way to stop doing tedious bookkeeping is to let the builder do it for you.