🥰

AppSyncでのTypeScriptの型生成にはtyped-document-nodesを使おう

2021/09/03に公開

概要

  • AppSyncをTypeScriptで利用するときに、graphql-code-generator を使って型を作成していました。
  • queryをするときに、引数の型と戻り値の型の両方を渡す必要があり、コードが長くなっていました。例はシンプルなのでまだそんなに見にくくはないですが。
sample.ts
const queryRates = async () => {
  const result = await query<RatesQueryVariables, RatesQuery>(rates, {
    currency: "USD"
  });

  const currency = result.rates[0].rate;
};
  • typed-document-nodesのプラグインを利用することで、graphqlの情報を渡すだけで型を定義する必要なく簡単になります。
typed-document-node.ts
const queryRates = async () => {
  const result = await query(RatesDocument, {
    currency: "USD"
  });

  const currency = result.rates[0].rate;
};

環境

  • AWS AppSync
  • TypeScript

使い方

npm install -D @graphql-codegen/typed-document-node
codegen.yml
schema: SCHEMA_FILE_OR_ENDPOINT_HERE
documents: "./src/**/*.graphql"
generates:
  ./src/graphql-operations.ts:
    plugins:
      - typescript
      - typescript-operations
      - typed-document-node
  • graphql-codegenを実行するとxxxDocumentというtypeが生成されます。
  • xxxDocumentをgql-tagで作成するqueryの代わりに利用します。
graphql-operations.ts
// Represents the variables type of the operation - generated by `typescript` + `typescript-operations` plugins
export type RatesQueryVariables = Exact<{
  currency: Scalars['String'];
}>;

// Represents the result type of the operation - generated by `typescript` + `typescript-operations` plugins
export type RatesQuery = (
  { __typename?: 'Query' }
  & { rates?: Maybe<Array<Maybe<(
    { __typename?: 'ExchangeRate' }
    & Pick<ExchangeRate, 'currency' | 'rate'>
  )>>> }
);

// Generated by this plugin - creates a pre-compiled `DocumentNode` and passes result type and variables type as generics
export const RatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"rates"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"currency"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rates"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"currency"},"value":{"kind":"Variable","name":{"kind":"Name","value":"currency"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"rate"}}]}}]}}]} as unknown as DocumentNode<RatesQuery, RatesQueryVariables>;
  • queryの実行は以下のように定義しています。単純にAppSyncClientを生成しているだけです。
import { TypedDocumentNode } from "@apollo/client";
import { AWSAppSyncClient } from "aws-appsync";

const client = new AWSAppSyncClient({
  // 省略
});

export const query = async <V, R>(
  query: TypedDocumentNode<R, V>,
  variables: V
): Promise<R> => {
  try {
    const res = await (await client.hydrated()).query({
      query: query,
      fetchPolicy: "network-only",
      variables
    });
    if (res.errors) {
      console.log(res.errors);
      throw res.errors;
    }
    return res.data as R;
  } catch (e) {
    console.log(e);
    throw e;
  }
};
  • 以下のように利用できるようになります
index.ts
import { RatesDocument } from "./typed-document-nodes";
import { query } from "./gql";

const queryTypedDocumentNodes = async () => {
  const result = await query(RatesDocument, {
    currency: "USD"
  });

  const currency = result.rates[0].rate;
};

typed-document-nodesで生成されているのは何か

sample.ts
import gql from 'graphql-tag';

const query = gql`
  {
    user(id: 5) {
      firstName
      lastName
    }
  }
`
  • これは以下のjsonを生成するようになっています。
{
  "kind": "Document",
  "definitions": [
    {
      "kind": "OperationDefinition",
      "operation": "query",
      "name": null,
      "variableDefinitions": null,
      "directives": [],
      "selectionSet": {
        "kind": "SelectionSet",
        "selections": [
          {
            "kind": "Field",
            "alias": null,
            "name": {
              "kind": "Name",
              "value": "user",
              ...
            }
          }
        ]
      }
    }
  ]
}
  • typed-document-nodeでは上記のASTをcodegen時に作成しています。

サンプル

メリット

コードがすっきりする

  • 概要の例でも記載しましたが、型を指定する部分が必要なくなるので、コードがすっきりします。
  • 個人的には、どっちがVariableでどっちがQueryなのか毎回考えながら書いていたので結構楽になった印象です。

fragmentの管理が楽になる

  • gql-tagを利用した場合、fragmentを利用したtsファイルを作成する場合に都度importが必要になります。
  • そのため、どのfragmentがどのソースに書いてあったかをちゃんと把握する必要があります。
  • typed-document-nodesを利用した場合、すべてのqueryやfragmentは一つのファイルに出力されます。そのため、どこにあるのか気にすることなく利用できます。

デメリット

すべてのクエリがグローバルになるため、名前空間に工夫が必要になる

  • gql-tagを利用した場合は必要なファイルのみをimportすればよかったので、最悪query名が重複しても問題ありませんでした。
  • typed-document-nodesを利用するとすべてのqueryは同じファイルに出力するため、queryの命名規則を決めないと重複してしまう恐れがあります。
  • また、意図しない部分から呼ばれてしまって変更に弱くなるようなこともあるかもしれません。

参考リンク

メモ

  • AppSyncClientだからuseQueryが使えないって思っていたら、AppSyncClientはApolloClientをextendsしているので、そのまま使えるっぽいですね。今度試してみます。
aws-appsync/lib/client.d.ts
declare class AWSAppSyncClient<TCacheShape extends NormalizedCacheObject> extends ApolloClient<TCacheShape> {
    private _store;
    private hydratedPromise;
    hydrated(): Promise<AWSAppSyncClient<TCacheShape>>;
    private _disableOffline;
    constructor({ url, region, auth, conflictResolver, complexObjectsCredentials, cacheOptions, disableOffline, offlineConfig: { storage, keyPrefix, callback, }, }: AWSAppSyncClientOptions, options?: Partial<ApolloClientOptions<TCacheShape>>);
    isOfflineEnabled(): boolean;
    mutate<T, TVariables = OperationVariables>(options: MutationOptions<T, TVariables>): Promise<import("apollo-link").FetchResult<T, Record<string, any>, Record<string, any>>>;
    sync<T, TVariables = OperationVariables>(options: SubscribeWithSyncOptions<T, TVariables>): Subscription;
}

Discussion