A lightweight and type-safe GraphQL query builder.
sample.mp4
Straightforward — gql-in-ts
allows you to inline GraphQL queries directly in TypeScript without requiring a special build process. It ensures query correctness using TypeScript’s type system.
Ergonomic — Unlike most GraphQL clients, gql-in-ts uses the TypeScript compiler to infer types directly from GraphQL queries, removing the need for code generation.
Portable — Being agnostic of the runtime or view framework, gql-in-ts
will Just Work™ in any ES5+ environment.
Supports TypeScript versions 4.4 to 5.1.
npm i gql-in-ts
First, generate a TypeScript module from your GraphQL schema by running the gql-in-ts
command. In the example below, schema.graphql
is translated into schema.ts
:
npx gql-in-ts schema.graphql schema.ts
The generated module exports a function named graphql
, which you'll use to define inline GraphQL queries:
import {
graphql // A wrapper function for defining inline GraphQL queries.
} from './schema.ts'; // The generated module
Additionally, the module gql-in-ts
exports helper types for deriving TypeScript types from GraphQL queries:
import {
Output, // A helper type for getting the TypeScript type from a GraphQL operation.
Input, // A helper type for getting the types of the variables (or parameters) that a GraphQL operation takes.
} from 'gql-in-ts';
The graphql
function can be used to define a GraphQL operation:
import { graphql } from './schema';
const query = graphql('Query')({
user: {
username: true,
nickname: true,
},
posts: [
{ author: 'me' },
{
title: true,
content: true,
},
],
});
The graphql
function provides auto-completion for field names and arguments.
To divide a GraphQL operation into fragments, simply extract the parts of your interest and wrap the individual parts with the graphql
function:
import { graphql } from './schema';
const userFragment = graphql('User')({
username: true,
nickname: true,
});
const postFragment = graphql('Post')({
title: true,
content: true,
});
const query = graphql('Query')({
user: userFragment,
posts: [{ author: 'me' }, postFragment],
});
The value returned from the graphql
function is compiled into real GraphQL when JSON.stringify
ed:
JSON.stringify({ query });
// '{"query":"query {\\n user {\\n username\\n nickname\\n }\\n posts(author: \\"me\\") {\\n title\\n content\\n }\\n}"}'
The Output
type can be used to define the response data type of a query:
import { Output } from 'gql-in-ts';
type QueryResult = Output<typeof query>;
QueryResult
is inferred as:
type QueryResult = {
user: {
username: string;
nickname: string | null;
};
posts: {
title: string;
content: string;
}[];
};
The Input
type can be used to define input variable types of a query. I'll dwell on this later in the Variables in queries section.
gql-in-ts
is network-agnostic. You can define a custom function to fetch a GraphQL endpoint and properly type the response, like this:
import { Output } from 'gql-in-ts';
const fetchGraphQL = async <T>(query: T) => {
const response = await fetch('http://example.com/graphql', {
method: 'POST',
body: JSON.stringify({ query }),
// query gets compiled into real GraphQL when JSON.stringify()ed.
headers: {
'content-type': 'application/json',
// If your endpoint requires authorization, uncomment the code below.
// authorization: '...'
},
});
const responseData = (await response.json()).data;
return responseData as Output<T>;
};
// Can be used like the following:
fetchGraphQL(query).then((data) => {
const titles = data.posts.map((post) => post.title);
// ...
});
See also: GraphQL: serving over HTTP.
This section describes the syntax of inline GraphQL queries that the graphql
function accepts. The following schema is used for the purpose of explanation.
schema {
query: Query
mutation: Mutation
}
type Query {
user: User!
posts(author: String): [Post!]!
feed: [FeedItem!]!
}
type User {
id: Int!
username: String!
nickname: String
avatar(size: Int): String
}
interface FeedItem {
id: Int!
author: User!
}
enum PostStatus {
DRAFT
PUBLIC
ARCHIVED
}
type Post implements FeedItem {
id: Int!
author: User!
title: String!
content(maxLength: Int): String!
status: PostStatus!
}
To select a primitive field, use fieldname: true
.
To select a non-primitive field, use fieldname: {...subselection}
.
In the example below,
import { graphql } from './schema';
const query = graphql('Query')({
user: {
username: true,
nickname: true,
},
});
The fields username
and nickname
are primitive fields on the User
type, so they are selected as fieldname: true
. The field user
is a non-primitive field on the Query
type, so it is selected as fieldname: {...subselection}
.
If a primitive field requires inputs, use fieldname: [{inputname1: inputvalue1, inputname2: inputvalue2, ...}, true]
.
If a non-primitive field requires inputs, use fieldname: [{inputname1: inputvalue1, inputname2: inputvalue2, ...}, { ...subselection }]
.
In the example below,
import { graphql } from './schema';
const query = graphql('Query')({
user: {
username: true,
nickname: true,
avatar: [{ size: 128 }, true],
},
posts: [
{ author: 'me' },
{
title: true,
content: true,
},
],
});
The avatar
field on the User
type is getting { size: 128 }
as an input. The posts
field on the Query
type is getting { author: 'me' }
as an input.
If an object key is suffixed with as [alias]
, the field is aliased as [alias]
. In the example below,
import { graphql } from './schema';
import { Output } from 'gql-in-ts';
const query = graphql('Query')({
user: {
username: true,
nickname: true,
'avatar as avatarSmall': [{ size: 128 }, true],
'avatar as avatarLarge': [{ size: 512 }, true],
},
});
type QueryOutput = Output<typeof query>;
QueryOutput
is evaluated as follows:
type QueryOutput = {
user: {
username: string;
nickname: string | null;
avatarSmall: string | null;
avatarLarge: string | null;
};
};
Use ...
to merge a GraphQL fragment. In the example below,
import { Output } from 'gql-in-ts';
import { graphql } from './schema';
const postHeaderFragment = graphql('Post')({
title: true,
author: {
id: true,
username: true,
avatar: [{ size: 128 }, true],
},
});
const postFragment = graphql('Post')({
id: true,
'...': postHeaderFragment,
});
type PostFragmentOutput = Output<typeof postFragment>;
PostFragmentOutput
is evaluated as follows:
type PostFragmentOutput = {
// id is selected in postFragment:
id: number;
// the rest of the fields are selected in postHeaderFragment:
title: string;
author: {
id: number;
username: string;
avatar: string | null;
};
};
To merge multiple fragments, use ... as [arbitrary alias]
instead of ...
. In the example below,
import { Output } from 'gql-in-ts';
import { graphql } from './schema';
const postContentFragment = graphql('Post')({
content: true,
});
const postFragment = graphql('Post')({
id: true,
'... as a': postHeaderFragment,
'... as b': postContentFragment,
});
type PostFragmentOutput = Output<typeof postFragment>;
PostFragmentOutput
is evaluated as follows:
type PostFragmentOutput = {
id: number;
title: string;
author: {
id: number;
username: string;
avatar: string | null;
};
content: string;
};
Caution: when multiple fragments with conflicting arguments are spread into the same location, a runtime error will be thrown. For example, the following is an error.
import { graphql } from './schema';
const query = graphql('Query')({
'... as a': {
posts: { content: [{ maxLength: 100 }, true] },
},
'... as b': {
posts: { content: [{ maxLength: 200 }, true] },
},
});
JSON.stringify({ query });
// Error: Cannot merge fragments. Saw conflicting arguments...
To select fields on a union or an interface, a key named ... on [name of the union or the interface]
should be used. In the example below,
import { Output } from 'gql-in-ts';
import { graphql } from './schema';
const feedFragment = graphql('FeedItem')({
__typename: true,
id: true,
author: { username: true },
'... on Comment': {
content: true,
post: { title: true },
},
'... on Post': {
title: true,
content: true,
},
});
type FeedFragmentOutput = Output<typeof feedFragment>;
FeedFragmentOutput
is evaluated as follows:
type FeedFragmentOutput =
| {
title: string;
content: string;
__typename: 'Post';
id: number;
author: {
username: string;
};
}
| {
content: string;
post: {
title: string;
};
__typename: 'Comment';
id: number;
author: {
username: string;
};
};
__typename
should be used to switch based on the actual type, as shown below:
let feedItem: FeedFragmentOutput;
if (feedItem.__typename === 'Comment') {
// TypeScript figures out feedItem is a Comment in this block, and ...
} else if (feedItem.__typename === 'Post') {
// feedItem is a Post in this block.
}
Inputs to fields can be parameterized. In the example below,
import { graphql } from './schema';
import { Input } from 'gql-in-ts';
const query = graphql('Query', { postsAuthor: 'String!' })(($) => ({
posts: [
{ author: $.postsAuthor },
{
title: true,
content: true,
},
],
}));
The input author
of the posts
field is parameterized as postsAuthor
. Parameters are also called variables. The query
query takes a variable named postsAuthor
.
Note { postsAuthor: 'String!' }
in the definition of the query
query. Variables should be defined in the same syntax as in real GraphQL.
The TypeScript type of the variables can be obtained through the Input
type. In the example below,
type QueryInput = Input<typeof query>;
{ postsAuthor: string }
is assignable to QueryInput
.
When a query with variables is executed, the actual values for the variables have to be supplied. The fetchGraphQL
function presented earlier can be improved to take variables into account:
import { graphql } from './schema';
import { Output, Input } from 'gql-in-ts';
const fetchGraphQL = async <T>(query: T, variables: Input<T>) => {
const response = await fetch('http://example.com/graphql', {
method: 'POST',
body: JSON.stringify({ query, variables }),
headers: {
'content-type': 'application/json',
// If your endpoint requires authorization, uncomment the code below.
// authorization: '...'
},
});
const responseData = (await response.json()).data;
return responseData as Output<T>;
};
When using a parameterized fragment in another fragment or query, invoke the fragment as a function. In the example below,
import { graphql } from './schema';
const userFragment = graphql('User', { avatarSize: 'Int!' })(($) => ({
avatar: [{ size: $.avatarSize }, true],
}));
const query = graphql('Query', { postsAuthor: 'String!' })(($) => ({
posts: [
{ author: $.postsAuthor },
{
title: true,
content: true,
author: userFragment({ avatarSize: 128 }),
},
],
}));
userFragment
is getting the variable value { avatarSize: 128 }
.
A variable can also be specified using another variable instead of a literal. In the example below,
import { graphql } from './schema';
const query = graphql('Query', { postsAuthor: 'String!', postsAuthorAvatarSize: 'Int!' })(($) => ({
posts: [
{ author: $.postsAuthor },
{
title: true,
content: true,
author: userFragment({ avatarSize: $.postsAuthorAvatarSize }),
},
],
}));
the query
query has gotten another variable named postsAuthorAvatarSize
. It then passes that variable as an argument to userFragment
.
Currently gql-in-ts
does not warn about non-existent fields. As it is hard to prevent objects from having extra properties in TypeScript, you won't get a type error even if you include a non-existent field in your query. Because GraphQL execution engines reject unknown fields, a type mismatch could cause runtime errors despite passing TypeScript checks.
This project is particularly indebted to GraphQL Zeus and genql for their idea of using TypeScript type transformations to precisely type GraphQL response data. If you are interested in this project, you may want to take a look at them as well.