✡️

TypeScriptで型安全なGraphQLクエリビルダーを作る

2023/08/11に公開

GraphQL のクエリを型安全に書くための TypeScript ライブラリを試作しています。

TypeScript の標準の型推論機能を頼りにクエリの補完やレスポンスの型推論を行います。

GraphQL のクライアントアプリケーションを安全・快適に開発するためのアプローチにはいろいろなものがありますが、その多くは専用のビルドツールやエディタのプラグインを必要とします。

例: Relay (Meta 社製の GraphQL フレームワーク) の場合

Meta 社の GraphQL フレームワークである Relay では JavaScript のソースコード中にタグ付きテンプレートリテラルとして GraphQL クエリを記述します。Relay が動くためには、JavaScript のソースコードから GraphQL クエリを抽出し flow の型情報を生成するコンパイラと、それに付随する Babel プラグインが必要です。その上、タグ付きテンプレートリテラルに書かれた GraphQL クエリは JavaScript としては単なる巨大文字列であり、普通のテキストエディタではシンタックスハイライトや補完が効きません。それらを求めるのであればさらなる環境構築が必要です。

筆者は GraphQL が好きです。しかし、GraphQL の開発環境作りに必要な依存関係の多さには一抹の不安を覚えるところがあります (もっとも、これは WEB フロントエンド開発全般にあてはまるかもしれません)。

ところで TypeScript には mapped type などの変態的な機能があり、型推論の過程で既存の型を元にして新しい型を作り出すことができます。もし TypeScript の型推論を頼りにして GraphQL クエリから型を取り出すことができるのであれば、GraphQL 固有のビルドツールを入れる必要がなく、依存関係をシンプルに保てそうです。奇しくも GraphQL のクエリの文法は JavaScript (TypeScript) のオブジェクトリテラルによく似ています。そこで、クエリをオブジェクトリテラルとして記述し、TypeScript の型パズルを駆使してクエリ結果の型を得るというアプローチで実験を始めました。

GraphQL のクエリは、いわばバリューなしのオブジェクトリテラルのような見た目です。

query {
  foo {
    bar
    baz
  }
}

JavaScript のオブジェクトリテラルにはバリューが必要なので、バリューの部分には true をあてがうことにしました。

const query = {
  foo: {
    bar: true,
    baz: true,
  }
};

手始めに、このような簡単な「クエリ」を結果の型に変換する Resolve という型を作りました[1]

type Result = Resolve<Schema, typeof query>;
// {
//   foo: {
//     bar: string;
//     baz: number;
//   }
// }

この Resolve 型を改良して、フィールド名のエイリアス、変数、そして GrpahQL の独特なフラグメントスプレッド構文やユニオン、インターセクションの型推論などをサポートしていきました。

一通りの機能を実装し終えてアプリケーションにも組み込み、うまくワークするという感触が得られたたのでここに紹介することにしました。

https://github.com/ykiu/gql-in-ts
https://www.npmjs.com/package/gql-in-ts

使い方

npmjs.com にパブリッシュしてあるので、npm コマンドでインストール可能です。

npm install gql-in-ts

初めに GraphQL のスキーマから TypeScript のコードを生成します。

npx gql-in-ts schema.graphql schema.ts

コード生成はスキーマだけを入力とするため、スキーマが変わった時にだけ実行すればOKです。

ライブラリの中身は特定の UI フレームワークに依存しない作りになっていますが、ここでは React を前提として雰囲気レベルの使用例を示します。題材はブログアプリとしましょう。例示用のスキーマを示します。

type Query {
  posts: [Post!]!
}

"""
ブログ記事
"""
type Post {
  id: Int!
  author: User!
  title: String!
  content: String!
}

"""
ユーザー
"""
type User {
  id: Int!
  username: String!
  nickname: String
  avatar(width: Int, height: Int): String
}

このスキーマの Post をレンダリングするコンポーネントは、次のように定義できます。

import { compileGraphQL, graphql, Resolved, GraphQLString } from './schema';

// 注目ポイント1
export const postFragment = graphql("Post")({
  title: true,
  content: true,
  author: {
    username: true,
    avatar: [{ width: 128, height: 128 }, true],
  },
});

interface PostProps {
  post: Resolved<typeof postFragment>;  // 注目ポイント2
}

export default const Post = (props: PostProps) => (
  <section>
    <img src={props.post.author.avatar} alt={props.post.author.username} />
    <h3>{props.post.title}</h3>
    <p>{props.post.content}</p>
  </section>
);

最初の注目ポイントは、const postFragment = graphql("Post")({...}); の部分です。ここでこのコンポーネントが必要とするデータの構造を GraphQL のような見た目のオブジェクトリテラルとして記述しています。TypeScriptの型推論をうまく動かす都合上、graphql 関数は graphql(型名)(クエリ) という形で2回に分けて呼び出すシグニチャとなっています。また、クエリの中の avatar の値は単なる true ではなく avatar: [{ width: 128, height: 128 }, true] となっています。これはフィールドに引数をつけて渡す際の書き方であり、本物の GraphQL であれば avatar(width: 128 height: 128) となるところです。

graphql 関数は TypeScript に型を認識させるためだけの関数であり、やることは与えられたクエリを何もせずに返すだけです。ここで言えば、クエリは { title: true, content: true, ... } というオブジェクトリテラルなので、それがそのまま postFragment に入っています。これを GraphQL のサーバーサイドに送るには本物の GraphQL 文字列に変換する必要がありますが、その方法は少し後で扱います。

二つ目の注目ポイントは、インターフェイス定義の Resolved<typeof postFragment> の部分です。Resolved によって、GraphQL サーバーが返すデータの型が次のように推論されます。

{
    title: string;
    content: string;
    author: {
        id: number;
        username: string;
        avatar: string | null;
    };
}

こうして推論された型を、ここでは Post コンポーネントの prop の型定義に利用しています。このようにすることで GraphQL のクエリにコンポーネントのインターフェイス定義を兼ねさせることができます。このスタイルには賛否両論があるかもしれませんが、同じような型を何度も定義する手間が省けるので個人的には好みです。

GraphQL クエリを prop 定義に利用していることからも分かる通り、この Post コンポーネントは親から受け取るデータをレンダリングするだけでした。ここからは実際にサーバーサイドに GraphQL のクエリを送り、クエリ結果を受け取る方法を紹介します。

データを取得するのに使うクエリは次のように書けます。

const compiled = compileGraphQL('query')({
  posts: {
    id: true,
    '...': postFragment,
  },
});

このクエリの意味は、各 Post について id と先ほど定義した postFragment に含まれるフィールドを取得するというものです。'...' というキーを使うとフラグメントのフィールドがそこに展開されます (生の GraphQL の フラグメントスプレッドに相当)。そのため、上記クエリは以下と等価になります。

const compiled = compileGraphQL('query')({
  posts: {
    id: true,
    title: true,
    content: true,
    author: {
      username: true,
      avatar: [{ width: 128, height: 128 }, true],
    },
  },
});

このように書く代わりに '...' を使うことで、Post コンポーネントで定義した postFragment を再利用して簡潔にクエリを表現できます。

ここでは、先ほどの graphql 関数ではなく compileGraphQL 関数を使いました。compileGraphQL 関数は、GraphQL っぽい見た目のオブジェクトを文字列に変換します。これは JSON.stringify() のようなものですが、JSON 文字列ではなく GraphQL のクエリ文字列を返します。この例であれば戻り値は以下の文字列になります。

query {
  posts {
    id
    title
    content
    author {
      id
      username
      avatar
    }
  }
}

これは実際にはただの文字列ですが、TypeScript の型定義上は「クエリ結果の型が埋め込まれた文字列」になっており、compileGraphQL 関数が返す文字列からはクエリ結果の型を取り出すことができます (その方法はすぐ後で紹介します)。

本ライブラリは型推論とクエリの組み立てにしか関心がなく、ネットワークリクエストを送るところはユーザー任せです。文字列になった GraphQL クエリをサーバーに送る関数は、fetch を利用するのであれば次のように書けます。これ自体には目新しいものはなく、エラー処理など細かいことを除けば誰が書いても同じようなコードになるでしょう。

const makeGraphQLRequest = async (compiled: string) => {
  const response = await fetch('http://example.com/graphql', {
    method: 'POST',
    body: JSON.stringify({ query: compiled }),
    headers: {
      'content-type': 'application/json',
      // If your endpoint requires authorization, comment out the code below.
      // authorization: '...'
    },
  });
  const responseData = (await response.json()).data;
  return responseData;
};

この段階ではこの makeGraphQLRequest の戻り値は any です。ここに、本ライブラリによって得られる型情報を利用して型付けしていきます。compileGraphQL 関数の戻り値は文字列はランタイムにはただの文字列であると先ほど書きましたが、TypeScript の型定義上は GraphQLString という string のサブタイプです。GraphQLString は型引数を持ち、compileGraphQL 関数は GraphQLString<結果の型> という具合にクエリ結果の型を埋め込んだ GraphQLString を返します。そこで、次のようにすることで GraphQLString が持っているクエリ結果の型を抽出して利用することができます。

const makeGraphQLRequest = async <TResolved>( // 注目ポイント1
  compiled: GraphQLString<TResolved>,         // 注目ポイント2
): Promise<TResolved> => {                    // 注目ポイント3
  const response = await fetch('http://example.com/graphql', {
    method: 'POST',
    body: JSON.stringify({ query: compiled }),
    headers: {
      'content-type': 'application/json',
      // If your endpoint requires authorization, comment out the code below.
      // authorization: '...'
    },
  });
  const responseData = (await response.json()).data;
  return responseData;
};

まず、makeGraphQLRequest 関数に型引数 TResolved を追加し、この関数をジェネリックにしました (注目ポイント1)。次に、compiled 引数の型を最初の string から GraphQLString<TResolved> に定義し直しました (注目ポイント2)。これで、TResolved には GraphQLString が持っていたクエリ結果の型が割り当てられます。そして、戻り値の型を Promise<TResolved> としました。これで、クエリ結果の型のPromiseが返ってくるということを表現できます (注目ポイント3)。

React であれば次のようなフックにできます (雑なフックですが、説明用ということで...)。

const useGraphQL = <TResolved>(compiled: GraphQLString<TResolved>): TResolved | null => {
  const [data, setData] = useState<TResolved | null>(null);
  useEffect(() => {
    makeGraphQLRequest(compiled).then(setData);
  }, [compiled]);
  return data;
};

このフックを使うことで、Post コンポーネントをレンダリングする PostList コンポーネントを次のように書くことができます。

const PostList = () => {
  const data = useGraphQL(compiled);
  if (!data) return <div>loading...</div>
  return (
    <div>
      {data.posts.map(post => (
        <Post key={post.id} post={post} />
      ))}
    </div>
  )
};

data の型は次のように推論され、補完や型チェックもバッチリです。

{
    posts: {
        id: number;
        title: string;
        content: string;
        author: {
            id: number;
            username: string;
            avatar: string | null;
        };
    }[];
} | null

類似ライブラリとの比較

オブジェクトリテラルで GraphQL のクエリを表現し、TypeScript の型推論でレスポンスに型を付けるアプローチは実は新しいものではなく、本ライブラリ以前にも GraphQL Zeusgenql に先例があります。本ライブラリを作るにあたってはそれらから着想を得た部分が非常に多くありました。その一方で、既存のものにはない工夫もいくつか行ないました。

テンプレートリテラルの活用 (エイリアス、変数定義)

本ライブラリでは、クエリを違和感なく読み書きできるようにするために、所々でテンプレートリテラル型を活用しました。例えば、次のように "X as Y" というパターンのキーを使うことでエイリアスを定義することができます。

const postFragment = graphql('Post')({
  id: true,
  'content as longContent': [{ maxLength: 4000 }, true],
  'content as shortContent': [{ maxLength: 40 }, true],
});

この結果にはもちろん .longContent や .shortContent が含まれ、エイリアスでフィールドにアクセスすることができます。

また、本ライブラリではコンパイルされたクエリに変数を含めることができます。ここでもテンプレートリテラル型を利用して、GraphQL の文法 (Int! など) で変数を定義するとそこに入る値の型 (number など) が推論されるというインターフェイスにしました。

const userFragment = graphql('User', { avatarSize: 'Int!' })(($) => ({
  avatar: [{ width: $.avatarSize, height: $.avatarSize }, true],
}));

軽量性

バンドル軽量化も意識したポイントの一つです。GraphQL のスキーマは巨大な一枚岩であり、ここに code splitting や tree shaking をかけるのは難しいです。そこで、本ライブラリではスキーマに関する情報を TypeScript の型レベルでのみ保持することにしました。スキーマの情報はTypeScript コンパイラを通した際に完全に消去され、スキーマのサイズがバンドルのサイズに影響を与えることがありません。

まとめ

TypeScript を活用して GraphQL クエリを型安全に扱う手法を提案しました。他にも、本稿では詳しく紹介しませんでしたが、GraphQL の特徴の一つとも言えるユニオンやインターフェイスのサポートにも力を入れています。興味がある方は README を参照してください。

脚注
  1. 本当は最初はResolveではなく別の名前でしたが、それに相当する型を今のソースコードではResolveと呼んでいます ↩︎

Discussion