👌

TypeScriptの型再帰呼び出しを使って、graphql-codegenで自動生成された型から部分的な型を取り出す

2024/09/10に公開

要するに...

こういう型から

type User_Anime_ListQuery = {
  __typename?: "Query";
  MediaListCollection?: {
    __typename?: "MediaListCollection";
    lists?: Array<{
      __typename?: "MediaListGroup";
      entries?: Array<{
        __typename?: "MediaList";
        media?: {
          __typename?: "Media";
          id: number;
          title?: {
            __typename?: "MediaTitle";
            native?: string | null;
            romaji?: string | null;
            english?: string | null;
          } | null;
          coverImage?: {
            __typename?: "MediaCoverImage";
            extraLarge?: string | null;
            large?: string | null;
          } | null;
        } | null;
      } | null> | null;
    } | null> | null;
  } | null;
};

こういう型を部分的に取り出したい

type Media = {
  __typename?: "Media";
  id: number;
  title?: {
    __typename?: "MediaTitle";
    native?: string | null;
    romaji?: string | null;
    english?: string | null;
  } | null;
  coverImage?: {
    __typename?: "MediaCoverImage";
    extraLarge?: string | null;
    large?: string | null;
  } | null;
};

結論

自作のユーティリティ型を使うことで解決

type extractTypeName<T, __typename> = T extends
  | {
      [key in string]?: infer U;
    }
  | Array<infer U>
  ? U extends { __typename?: __typename }
    ? U
    : extractTypeName<U, __typename>
  : never;

type Media = extractTypeName<User_Anime_ListQuery, "Media">;

解説

経緯

AniListという自分のアニメリストを作ることができるサービスがあり、
GraphQLのAPIが公開されています。
Introduction | AniList APIv2 Docs

Codegen with GraphQL, Typescript, and Apollo - GraphQL Tutor...
@graphql-codegen/cliを使うことでGraphQLクエリの戻り値の型を自動生成出来ます。

例えば、以下のような、ユーザーが持っているアニメリストを取得する
GraphQLのクエリから型を自動生成すると、

const USER_ANIME_LIST = gql(`
  query USER_ANIME_LIST($userName: String!) {
    MediaListCollection(userName: $userName, type: ANIME) {
      lists {
        entries {
          media {
            id
            title {
              native
              romaji
              english
            }
            coverImage {
              extraLarge
              large
            }
          }
        }
      }
    }
  }
`);

以下のような型が得られます。

export type User_Anime_ListQuery = {
  __typename?: "Query";
  MediaListCollection?: {
    __typename?: "MediaListCollection";
    lists?: Array<{
      __typename?: "MediaListGroup";
      entries?: Array<{
        __typename?: "MediaList";
        media?: {
          __typename?: "Media";
          id: number;
          title?: {
            __typename?: "MediaTitle";
            native?: string | null;
            romaji?: string | null;
            english?: string | null;
          } | null;
          coverImage?: {
            __typename?: "MediaCoverImage";
            extraLarge?: string | null;
            large?: string | null;
          } | null;
        } | null;
      } | null> | null;
    } | null> | null;
  } | null;
};

これだけでも便利なのですが、例えば取得した情報の中には配列が含まれており、
要素一つ一つについて処理を行う関数を作りたい場合、上記User_Anime_ListQueryから
__typename?: "Media";を持つオブジェクトの型を部分的に取り出したくなるということがあります。

愚直に取り出したい型をコピペして利用しても良いですが、例えばGraphQLのクエリを変更した場合は自動生成される型も変更されるため、コピペが多ければ多いほど保守が大変になります。

また、User_Anime_ListQuery["MediaListCollection"]["lists"] のようにすることで部分的な型を取り出すことも今回に関しては出来ません。オブジェクトがundefinedやnullを取りうる可能性があるためです。

そこで下記のユーティリティ型を定義します。

type extractTypeName<T, __typename> = T extends
  | {
      [key in string]?: infer U;
    }
  | Array<infer U>
  ? U extends { __typename?: __typename }
    ? U
    : extractTypeName<U, __typename>
  : never;

原理

実はTypeScript 3.7以降では再帰呼び出しを使うことが出来ます。
Announcing TypeScript 3.7 - TypeScript

例えば、__typename: "Media" を持つオブジェクトの型を取り出すとします。
inferを使って部分的な型を取り出して再帰的にネストされた型を探索し、
__typename: "Media" を持つ部分的な型を見つけたらそれを最後に返す、というような実装がextractTypeNameの考え方になります。

具体的に分解しながら見ていくと、
まずはinferを利用してプロパティ、もしくは配列の型の取り出しを行っています。

type extractTypeName1<T> = T extends
  | {
      [key in string]?: infer U;
    }
  | Array<infer U>
  ? U
  : never

User_Anime_ListQueryはよく見ると深くネストされた構造の中の一部分はオブジェクトもしくは配列で出来ていることが分かります。
そこで、inferを用いて配列の値、もしくはオブジェクトの値部分を取り出しています。

例えば、下記のような型に対して現段階のextractTypeName1を利用すると

type User_Anime_ListQuery = {
  __typename?: "Query";
  MediaListCollection?: {
	__typename?: "MediaListCollection";
	lists?: Array...
		...省略
  } | null;
};

type test1 = extractTypeName1<User_Anime_ListQuery>
// 下記のようなunion型が取り出せる
type part1 = "Query" | {
    __typename?: "MediaListCollection";
    lists?: Array...
		...省略
} | null

一段階ネストが下がった形のunion型が取得できます。

ところで、下記のようなunion型に対してextractTypeName1を適用するとどうなるでしょうか?

type hoge = extractTypeName1<"hoge" | { a: string } | null>
type hoge = string // これと同義になる

上記のようにstringのみが取り出されます。
オブジェクトや配列のサブタイプではない"hoge"nullneverを返すため、unionから取り除かれます。

そこでtest1型に対して、再度extractTypeName1を適用すると、

type test2 = extractTypeName1<test1>
// 下記のような型が取り出せる
type part2 =  "MediaListCollection" | ({
    __typename?: "MediaListGroup";
    entries?: Array<{
		...省略
    } | null> | null;
} | null)[] | null

段々とネストが下がっていく様子がわかると思います。
"Query"nullなどの型はT extends | { [key in string]?: infer U; } | Array<infer U>の条件判定でneverとなりunionから取り除かれるため、このように都合よくオブジェクトか配列型のみを探索することが可能になります。

次は、Generic typeの再帰呼び出しと終了条件を実装します。

type extractTypeName<T, __typename> = T extends
  | {
      [key in string]?: infer U;
    }
  | Array<infer U>
  ? U extends { __typename?: __typename }
    ? U
    : extractTypeName<U, __typename>
  : never;

新たに__typename引数が増えています。
目的のプロパティが見つからない場合は再度 extractTypeNameを呼び出し、Uを引数に指定することで再帰探索しています。
先程inferで取り出した型U{ __typename?: "Media" } などのプロパティを持つ型であれば再帰探索を終了して良いので、そのままUを返しています。

GitHubで編集を提案

Discussion