Skip to content

Commit

Permalink
Merge pull request #17 from ykiu/resolved-utility
Browse files Browse the repository at this point in the history
Introduce the Resolved utility type
  • Loading branch information
ykiu authored Jul 17, 2023
2 parents 99f6005 + b3b13ed commit e3020a8
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 187 deletions.
101 changes: 57 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@

[![npm](https://img.shields.io/npm/v/gql-in-ts)](https://www.npmjs.com/package/gql-in-ts/) ![](https://github.com/ykiu/gql-in-ts/actions/workflows/ci.yaml/badge.svg)

A type-safe way to write GraphQL. Express your query as a plain object. Keep your code safe with the power of TypeScript.
A type-safe way to write GraphQL. Express your queries as plain objects and rely on the TypeScript compiler to keep your type definitions in sync with the queries.

<img src="https://user-images.githubusercontent.com/32252655/188314291-69ecbd37-2f11-4445-b493-e57186b3eb90.gif" style="aspect-ratio: 1460 / 474" alt="A screen recording demonstrating what it looks like to write a query with gql-in-ts." />
<img src="https://user-images.githubusercontent.com/32252655/253862101-906a209f-5bfa-4e72-af25-fa0967ffc79c.gif" style="aspect-ratio: 850 / 359" alt="A screen recording demonstrating the usage of gql-in-ts." />

## Features

**Straightforward** — Tired of maintaining a complex development environment with loads of plugins/extensions? `gql-in-ts` is a tiny library that comes with carefully-designed TypeScript type definitions. It requires no changes to your existing build process, yet it guarantees the correctness of your GraphQL queries with the help of the TypeScript compiler.
**Straightforward** — Tired of maintaining a complex development environment with loads of plugins/extensions? `gql-in-ts` is a tiny library that comes with carefully designed TypeScript type definitions. It requires no changes to your existing build process, yet it guarantees the correctness of your GraphQL queries with the help of the TypeScript compiler.

**Ergonomic**Most existing GraphQL client solutions work by generating TypeScript code from GraphQL queries. `gql-in-ts`, in contrast, relies on TypeScript type inference to keep queries and types in sync, eliminating the need for code generation.
**Ergonomic**Unlike most existing GraphQL client solutions that generate TypeScript code from GraphQL queries, `gql-in-ts` relies on TypeScript type inference to keep queries and types in sync, eliminating the need for code generation.

**Portable** — Being agnostic of the runtime or view framework, `gql-in-ts` will Just Work™ in any ES5+ environment.

## Getting started

`gql-in-ts` supports TypeScript 4.4 thru 5.1.
Currently, `gql-in-ts` is tested against TypeScript versions 4.4 through 5.1.

Install the library:

Expand All @@ -32,8 +32,6 @@ Generate TypeScript code from your schema:
npx gql-in-ts schema.graphql schema.ts
```

Now you are all set!

## Core concepts

### The `graphql` function
Expand All @@ -58,9 +56,9 @@ const query = graphql('Query')({
});
```

The `graphql` function returns your query without doing any processing on it. However, its type signagures do enforce type checking, allowing TypeScript-compatible editors to provide instant feedback and auto-completion.
The `graphql` function returns the query object as is, without modifying it. However, its type signatures enforce type checking, allowing TypeScript-compatible editors to provide instant feedback and auto-completion.

You can split up a large query into smaller pieces, much like you do with GraphQL fragments:
A large query can be split into smaller pieces, similar to breaking down a function or a class into smaller functions or classes:

```ts
import { graphql } from './schema';
Expand All @@ -79,14 +77,14 @@ const query = graphql('Query')({
});
```

### The `Result` type
### The `Resolved` utility type

Use the `Result` type for typing the response for the query:
The `Resolved` utility type can be used to define the response type of a query:

```ts
import { Result } from './schema';
import { Resolved } from './schema';

type QueryResult = Result<typeof query>;
type QueryResult = Resolved<typeof query>;
// QueryResult would be inferred as:
// {
// user: {
Expand All @@ -102,7 +100,7 @@ type QueryResult = Result<typeof query>;

### The `compileGraphQL` function

As mentioned earlier, the `graphql` function returns the given query unmodified. As your query is a plain JavaScript object, you'll need to convert it to a real GraphQL query before sending it to the server. Do so by using `compileGraphQL`:
As mentioned earlier, the `graphql` function returns the given query unmodified. Because the query is a plain JavaScript object at this point, it needs to be converted to a GraphQL string before being sent to the server. This can be achieved by using `compileGraphQL`:

```ts
import { compileGraphQL } from './schema';
Expand All @@ -122,14 +120,18 @@ expect(compiled).toEqual(
);
```

Think of `compileGraphQL` as a JSON.stringify() for GraphQL.
`compileGraphQL` is like `JSON.stringify()` for GraphQL. However, the return type of `compileGraphQL` is a string subtype named `GraphQLString`. `GraphQLString` is an ordinary string at runtime, but at the TypeScript level, it contains metadata for type inference. The `Resolved` utility can be used again to obtain the the type of the response:

```ts
type MyResult = Resolved<typeof compiled>;
```

While `compileGraphQL` returns an ordinary string at runtime, its return type in TypeScript is a string subtype named `GraphQLString`. `GraphQLString`, in addition to all the string properties, has one useful property: the `Result` for the compiled query. You can extract the `Result` by using [the infer keyword in a conditional type](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#inferring-within-conditional-types):
Alternatively, the resolved type can be extracted from the first type parameter of GraphQLString:

```ts
import { GraphQLString } from './schema';

type MyResult = typeof compiled extends GraphQLString<infer TResult, never> ? TResult : never;
type MyResult = typeof compiled extends GraphQLString<infer TResolved> ? TResolved : never;
```

You can also pass your query directly to `compileGraphQL` instead of via `graphql`.
Expand All @@ -145,14 +147,14 @@ const compiled = compileGraphQL('query')({

### Making a request

`gql-in-ts` is agnostic of the transport layer: it's your responsibility to send requests to your backend server. That said, most GraphQL endpoints are [served over HTTP](https://graphql.org/learn/serving-over-http/), so here I include an example demonstrating how to send a typed GraphQL query using `fetch`:
`gql-in-ts` is agnostic of the transport layer, so it is your responsibility to send requests to your backend server. However, since most GraphQL endpoints are [served over HTTP](https://graphql.org/learn/serving-over-http/), here is an example demonstrating how to send a typed GraphQL query using `fetch`:

```ts
import { GraphQLString } from './schema';

const makeGraphQLRequest = async <TResult>(
compiled: GraphQLString<TResult, never>,
): Promise<TResult> => {
const makeGraphQLRequest = async <TResolved>(
compiled: GraphQLString<TResolved>,
): Promise<TResolved> => {
const response = await fetch('http://example.com/graphql', {
method: 'POST',
body: JSON.stringify({ query: compiled }),
Expand All @@ -171,7 +173,7 @@ const makeGraphQLRequest = async <TResult>(

### Using aliases

You can alias a field by appending ` as [alias]` to the field name:
Fields can be aliased by appending ` as [alias]` to their names:

```ts
import { graphql } from './schema';
Expand All @@ -183,11 +185,11 @@ const postFragment = graphql('Post')({
});
```

You can access the fields on the resulting response by their respective aliases. Aliasing is possible thanks to [template literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) of TypeScript.
The fields on the resulting response can be accessed by their respective aliases.

### Unions and interfaces

You can specify the type condition for a fragment by using keys with the pattern `... on [type name]`. Say `FeedItem` is an interface for things that appear in feeds, and `Post` and `Comment` implement `FeedItem`. `id` and `author` are defined in `FeedItem`, and additional fields are defined in the respective implementations:
Fields on unions and interfaces can be queried by using keys with the pattern `... on [type name]`. Say `FeedItem` is an interface for things that appear in feeds, and `Post` and `Comment` implement `FeedItem`. `id` and `author` are defined in `FeedItem`, and additional fields are defined in the respective implementations:

```ts
import { graphql } from './schema';
Expand All @@ -207,12 +209,12 @@ const feedFragment = graphql('FeedItem')({
});
```

Use `__typename` to switch by the type of the feedItem to benefit from TypeScript's type narrowing feature:
On the response, `__typename` can be used to narrow down the type:

```ts
import { Result } from './schema';
import { Resolved } from './schema';

const processFeedItem = (feedItem: Result<typeof feedFragment>) => {
const processFeedItem = (feedItem: Resolved<typeof feedFragment>) => {
if (feedItem.__typename === 'Comment') {
// The type of feedItem is inferred as Comment in this block.
} else if (feedItem.__typename === 'Post') {
Expand All @@ -223,12 +225,12 @@ const processFeedItem = (feedItem: Result<typeof feedFragment>) => {

### Merging fragments

You can "merge" fragments. This is a powerful feature that allows to colocate fragments and the code that depends on them, maximizing maintainability of both the code and the query.
Fragments can be merged to form a larger fragment or a query. This comes in handy when you have small UI components that comprise a complex UI, where each component depends on different subsets of data from the GraphQL API. In such cases, it makes sense to use GraphQL fragments not just for fetching data, but also for describing the shape of inputs to the components.

Suppose you want to render a post. You've split the rendering function into two parts where the first one is for the header of a post and the second one for the main text. The former is only interested in the post's `title` and `author`:

```ts
import { graphql, Result } from './schema';
import { graphql, Resolved } from './schema';

const postHeaderFragment = graphql('Post')({
title: true,
Expand All @@ -239,37 +241,37 @@ const postHeaderFragment = graphql('Post')({
},
});

const renderPostHeader = (post: Result<typeof postHeaderFragment>) => {
const renderPostHeader = (post: Resolved<typeof postHeaderFragment>) => {
// ...
};
```

...and the latter is only interested in the post's `content`:

```ts
import { graphql, Result } from './schema';
import { graphql, Resolved } from './schema';

const postContentFragment = graphql('Post')({
content: true,
});

const renderPostContent = (post: Result<typeof postContentFragment>) => {
const renderPostContent = (post: Resolved<typeof postContentFragment>) => {
// ...
};
```

Now on to the parent that renders both of them. Say the parent needs `id` as its own requirement. It also needs the data the children need so that it can pass that data to `renderPostHeader()` and `renderPostContent()`. You can write a fragment for the parent by merging the fragments of the children. Do so by using a special key spelled `...`:

```ts
import { graphql, Result } from './schema';
import { graphql, Resolved } from './schema';

const postFragment = graphql('Post')({
id: true,
'... as a': postHeaderFragment.
'... as b': postContentFragment.
});

const renderPost = (post: Result<typeof postFragment>) => {
const renderPost = (post: Resolved<typeof postFragment>) => {
const postHeader = renderPostHeader(post);
const postContent = renderPostHeader(post);
// ...
Expand All @@ -278,8 +280,6 @@ const renderPost = (post: Result<typeof postFragment>) => {

Note that two `...`s are given [aliases](#using-aliases) to avoid key collision. `...` is similar to the object spread syntax of JavaScript. However, by using `...` as a key, you are telling `gql-in-ts` to _recursively_ merge fragments, while the object spread syntax merges objects only _shallowly_.

This way, you can place GraphQL queries side-by-side with functions or classes that need data for the queries. The pattern is sometimes called colocation and is a good practice to keep your code DRY and maintainable.

_Caution: when you try to merge fragments with conflicting arguments, compileGraphQL will throw a runtime error. For example, the following is an error._

```ts
Expand All @@ -298,7 +298,7 @@ compileGraphQL('query')({

### Using variables

Variables allow to compile a query once and to reuse it over and over again with different parameters. Compiling is not that expensive so you could write arguments inline, as in [the first example](#the-graphql-function), but for performance freaks, variables provide a way to tune their code.
Variables allow queries to be compiled once and to be reused with different parameters. Compiling is not expensive so you could write arguments inline, as in [the first example](#the-graphql-function), but a performance freak can tune their code by using variables.

To define a fragment with variables, declare the names and the types of the variables, and pass a callback to `graphql`. You can reference the variables from within the callback:

Expand Down Expand Up @@ -347,23 +347,36 @@ expect(compiled).toEqual(

The syntax of variable definitions follows that of real GraphQL (e.g. types are optional by default, and types with "!" are required). Variable definitions are type-checked using [template literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html).

You can extract the types of the variables that a compiled query takes by using the second type parameter of `GraphQLString`:
The types of the variables that a compiled query takes can be extracted from the second type parameter of `GraphQLString`. Therefore, the `makeGraphQLRequest` function from the earlier section can be rewritten to take variables into account:

```ts
import { GraphQLString } from './schema';

type MyVariables = typeof compiled extends GraphQLString<infer TResult, TVariables>
? TVariables
: never;
const makeGraphQLRequest = async <TResolved, TVariables>(
compiled: GraphQLString<TResolved, TVariables>,
variables: TVariables,
): Promise<TResolved> => {
const response = await fetch('http://example.com/graphql', {
method: 'POST',
body: JSON.stringify({ query: compiled, variables }),
headers: {
'content-type': 'application/json',
// If your endpoint requires authorization, comment out the code below.
// authorization: '...'
},
});
const responseData = (await response.json()).data;
return responseData;
};
```

## Limitations

At the moment `gql-in-ts` has the following limitations:

- Not capable of eliminating extraneous 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. Since GraphQL execution engines error when they meet an unknown field, this introduces an unsafeness where the code passes type check but errors at runtime.
- It cannot eliminate extraneous 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. Since GraphQL execution engines throw an error when encountering an unknown field, this introduces a scenario where the code passes type checks but errors at runtime.

## Related works

Several other solutions employ an approach similar to this library. This one especially owes a lot to [GraphQL Zeus](https://github.com/graphql-editor/graphql-zeus) and [genql](https://github.com/remorses/genql) for the 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.
Several other solutions employ an approach similar to this library. This project is particularly indebted to [GraphQL Zeus](https://github.com/graphql-editor/graphql-zeus) and [genql](https://github.com/remorses/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.
2 changes: 1 addition & 1 deletion src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ const compileDocument = (schema: GraphQLSchema, params: CompileParams): string[]
const footer = [
`export const graphql = makeGraphql<OutputCompositeTypeMap, InputTypeMap>();`,
`export const compileGraphQL = makeCompileGraphQL<InputTypeMap, Schema>();`,
`export type { Result, Selection, GraphQLString } from '${params.importPath}';`,
`export type { Resolved, Selection, GraphQLString } from '${params.importPath}';`,
`export const defineVariables = makeDefineVariables<InputTypeMap>();`,
'',
];
Expand Down
Loading

0 comments on commit e3020a8

Please sign in to comment.