graphql-isoclient
v0.3.1
Published
A graphql client generator
Maintainers
Readme
graphql-isoclient
A GraphQL client generator that emits one self-contained, fully typed TypeScript file.
The generated file imports nothing. It has no client instance, no configuration file, and no runtime dependency. It exposes plain functions that build a request, send it, and return the typed result.
Install
npm install --save-dev graphql-isoclientRequires Node.js 24 or newer to run the generator. The generated client itself runs anywhere fetch is available.
Generate a client
From the command line:
npx graphql-isoclient generate \
--schema-endpoint=https://api.example.com/graphql \
--output=src/graphql/client.tsFrom a saved introspection payload:
npx graphql-isoclient generate \
--introspection-json-file=schema.json \
--output=src/graphql/client.tsFrom code:
import { generateClient } from "graphql-isoclient";
await generateClient({
schemaEndpoint: "https://api.example.com/graphql",
output: "src/graphql/client.ts",
});Use the generated client
import { mutation, query } from "./graphql/client.ts";
const userResult = await query(
{
User: { __arguments: { id: "user-1" }, id: true, username: true },
},
{ headers: { authorization: `Bearer ${token}` } },
);
const username = userResult.User?.username;
const tweetResult = await mutation({
createTweet: { __arguments: { body: "hello" }, id: true, body: true },
});
const tweetBody = tweetResult.createTweet?.body;query and mutation take the request map as the first argument and the request options as the second. The request map describes the fields to read and the arguments to send. subscription has the same shape and returns an async iterable.
What the generated file contains
The generated file holds only what the schema needs. A schema with a query root type and no mutation root type gets a query function and no mutation function. A schema with no subscription root type gets no subscription function, no subscription transport, and no subscription types.
| Schema root types | Generated operations |
| ------------------- | ----------------------- |
| Query | query |
| Query, Mutation | query, mutation |
| Query, Subscription | query, subscription |
Query and mutation share one runtime, because both send the same request and differ only in the operation keyword. Subscriptions have their own runtime, because they stream through an injected transport instead of sending a request.
Client configuration
The generated file opens with an editable configuration block. Change the URL and the headers there, no regeneration needed.
const clientConfig: ClientConfig = {
endpoint: "https://api.example.com/graphql",
headers: {
authorization: "Bearer token",
},
};The endpoint is the default for every operation and the headers are sent with every request. Per call options override them.
Request maps
A request map describes the fields to read. It is a plain object, and it nests to any depth.
{
id: true, // leaf field
Author: { username: true }, // nested object
__scalar: true, // every scalar field of the type
__arguments: { id: "user-1" }, // arguments for the field that holds this map
}Arguments live in __arguments on the map of the field they belong to, so every field carries its own arguments at every depth. The argument names and their types are checked against the schema, at every level.
const result = await query({
Tweet: {
__arguments: { id: "tweet-1" },
body: true,
Author: { __arguments: { id: "user-1" }, username: true },
},
});A field with required arguments must have __arguments, and the compiler says so when it is missing.
Request options
The second argument accepts the request options.
| Option | Type | Description |
| ---------- | ------------------------ | --------------------------------------------- |
| headers | Record<string, string> | Headers sent with the request |
| endpoint | string | Overrides the endpoint baked in at generation |
| signal | AbortSignal | Aborts the request |
const controller = new AbortController();
const result = await query({ User: { __arguments: { id: "user-1" }, username: true } }, { signal: controller.signal });Subscriptions
A subscription returns an async iterable. The transport is injected, so the client stays free of a websocket dependency. The subscription function and its types exist only when the schema has a subscription root type.
import { subscription } from "./graphql/client.ts";
const stream = subscription({ notificationAdded: { id: true, type: true } }, { transport: mySubscriptionTransport });
for await (const event of stream) {
console.log(event);
}A transport implements a single method.
type SubscriptionTransport = {
subscribe(operationRequest: OperationRequest): AsyncIterableIterator<OperationResponse>;
};Errors
An operation resolves with the data of the GraphQL operation, and throws when the operation reports errors.
const data = await query({ User: { __arguments: { id: "user-1" }, username: true } });
console.log(data.User?.username);A GraphQL error is thrown as an OperationError, which carries the errors the server reported and the partial data it sent along with them.
try {
await query({ User: { __arguments: { id: "user-1" }, username: true } });
} catch (operationError) {
if (operationError instanceof Error) {
console.log(operationError.message);
for (const reportedError of operationError.errors) {
console.log(reportedError.message);
}
}
}A plain Error is thrown when the request itself fails, which means the endpoint was unreachable, the response body was not JSON, or the server answered with a status other than 200 without a GraphQL error body.
Generation options
| Option | Type | Description |
| ------------------------- | ------------------------ | ------------------------------------------------ |
| schemaEndpoint | string | Endpoint the schema is read from |
| introspectionJsonFile | string | File the schema is read from |
| output | string | Path of the generated file |
| endpoint | string | Default endpoint baked into the generated client |
| headers | Record<string, string> | Headers used for the introspection request |
| includeDeprecatedFields | boolean | Includes deprecated fields, false by default |
Either schemaEndpoint or introspectionJsonFile is required.
The command line exposes the same options as flags.
| Flag | Description |
| ------------------------------- | ------------------------------------------------ |
| --schema-endpoint=<url> | Read the schema from an introspection endpoint |
| --introspection-json-file=<p> | Read the schema from a saved payload |
| --output=<path> | Path of the generated file |
| --endpoint=<url> | Default endpoint baked into the client |
| --header=<name:value> | Header for the introspection request, repeatable |
| --include-deprecated-fields= | true or false |
The client config block in the generated file is meant to be edited by hand after generation, so there is no flag for the headers it holds.
Known limitations
- The same field cannot be selected twice in one operation, because the request map is a plain object.
- Fields typed as an interface or a union cannot be selected, because the runtime reads plain object types.
- Custom scalars are typed as
unknown. - Deprecated leaf fields are hidden unless
includeDeprecatedFieldsis enabled. - Batching is not supported yet. It will be a separate function that takes a list of request maps.
Development
npm run build # type check and bundle into dist
npm test # run the test suite
npm run lint # run oxlint
npm run format # run oxfmtRepository operations live in repository.sh at the repository root. The CI workflows and local work source that file and call its functions. run_integrity_checks covers the structure, changelog, lint and format checks, run_tests runs the suite, check_package_install packs the tarball, installs it into a clean project and runs the published binary, and publish_package publishes the version. git_push commits and pushes with a validated development: or maintenance: message.
Publishing uses npm trusted publishing, so the repository needs the npm trusted publisher configured for graphql-isoclient and the workflow needs the id-token permission. The version in package.json and the latest CHANGELOG.md entry must match, which check_changelog_version verifies.
The publish job runs on every push to main. The version decides whether a publish happens: the integrity job exports the version from package.json, the publish job passes it to publish_new_version, and that function compares it against the version already on the registry. A version that is already published is skipped without an error, a version that is newer is published, and a version that does not match the manifest is an error.
License
Apache-2.0. The full text is in LICENSE.
