🎃

Template Literal Typesを使ってmicroCMSのクエリを型安全に実行するアイデア

8 min read

概要

Template Literal Typesを使い、以下のようにオブジェクトのプロパティをカンマ区切りで列挙する文字列を型安全に生成できる型を実装しました。

microCMSという Headless CMS でのクエリを型安全に実装するために試験的に実装したため、型の名前がMicroCMSRequestFieldsとなっています。

// { title: "hoge"; main: "hoge" }というTypeに対して、"title,main"という文字列リテラルを型安全に指定できる
type ValidFields = MicroCMSRequestFields<'title,main', { title: 'hoge', main: 'hoge' }>
expectType<ValidFields>("title,main")

// author.nameという記法でネストしたプロパティにアクセスできる
type ValidFieldsDot = MicroCMSRequestFields<'title,main,author.name', { title: 'hoge', main: 'hoge', author: { name: 'name', test: 'test' } }>
expectType<ValidFieldsDot>("title,main,author.name")

// 存在しないプロパティを含んでいると、never型を返す
type InvalidFields = MicroCMSRequestFields<'title,main', { title: 'hoge', _main: 'hoge' }>
expectType<() => InvalidFields>(() => {
  throw new Error();
})

結論から言いますと、実用化できるといえるレベルには達することができなかったのですが、その過程で色々と勉強になることが有ったので記事にしておきます。

microCMSについての説明は末尾に掲載したので、気になる方はそちらを読んでください。

この記事を読むと分かること

  • Template Literal Types を使った簡易パーサーの実装方法
  • 型のテストの書き方

実装した内容

こちらのリポジトリにて公開しました。

全体としては以下の通りです。

type _Object = { [x: string]: any }

type Values<T extends _Object> = T[keyof T]

type AccessKeyUsingDot<Str extends string, T extends _Object> = `${Str}.${string & keyof T}`

type PickObjectProps<T extends _Object> = Values<{
    [k in string & keyof T]: AccessKeyUsingDot<k, T[k]>
}>

type AccessKeys<T extends _Object> = (string & keyof T) | PickObjectProps<T>

export type MicroCMSRequestFields<Str extends string, Response extends _Object> = 
    Str extends `${AccessKeys<Response>}` ? Str :
    Str extends `${AccessKeys<Response>},${AccessKeys<Response>}` ? Str :
    Str extends `${infer Str2},${AccessKeys<Response>}` ? Str2 extends MicroCMSRequestFields<Str2, Response> ? Str : never : never

やったことの大まかな概要

{ title: 'hoge', main: 'hoge', author: { name: 'name', test: 'test' } }といった形式のオブジェクトに対して、'title,main,author.name'といった文字列を型安全に書けるようにしました。

microCMSの検索APIにて、fields=title,main_image,updatedAt,author.nameのように書くことがあるため、fields=以降のカンマ区切りの部分を型安全に書けないか、というのが狙いです。

つまり、オブジェクトの型と、カンマ区切りの文字列を渡すと、それがValidだったら文字列をそのまま返して、InValidだったらneverを返すように型定義を書くことが目的です。

以上より、やるべきことは以下の2点となります。

  • カンマ区切りの文字列かどうかをパースできるTemplate Literal Typesを定義する
  • それぞれ区切られている文字列が、指定したオブジェクトの型に対してプロパティ名のTypoなどが無いように満たせているかチェックするUnion Typesを書く

カンマ区切りのパーサーの書き方

カンマ区切りの文字列は、再帰的に型定義を書くことで定義することができます。

以下の型定義を1行ずつ見ていくと読み解くことができます。

export type MicroCMSRequestFields<Str extends string, Response extends _Object> = 
    // もし"title"などのカンマなしの文字列だったらValid
    Str extends `${AccessKeys<Response>}` ? Str :
    // 合間に1つカンマを挟んでいる文字列だったらValid
    Str extends `${AccessKeys<Response>},${AccessKeys<Response>}` ? Str :
    // 3つ以上に区分される場合は、最後のカンマより前の部分をInferし、再帰的に”合間に1つカンマを挟んでいる文字列”を満たしているかチェックし、満たしたらValid
    Str extends `${infer Str2},${AccessKeys<Response>}` ? Str2 extends MicroCMSRequestFields<Str2, Response> ? Str : never : never
    // 全てのチェックに対して否だったらnever

MicroCMSRequestFieldsの型の中にMicroCMSRequestFieldsを書き、再帰的に書くことがポイントです。

シンプルなユースケースなので、もう少し簡単に書けるかもしれません。

特定の文字列がオブジェクトのキーに含まれているかチェックするUnion Types

続いて、'title'などの文字列が{ title: 'hoge', main: 'hoge', author: { name: 'name', test: 'test' } }といったオブジェクトのキーに含まれているかを証明するUnion Typesを考えます。

これはシンプルで、keyof Tを使えばオブジェクトのキーのUnion Typesが取れるので、それをTemplate Literal Typesに当て込めば対応可能です。

type AccessKeys<T extends _Object> = (string & keyof T)
// ↓
`${AccessKeys<Response>}`

ドット区切りでネストしたプロパティにアクセスする部分の型

今回作成した型では、author.nameといったドット記法でネストしたプロパティにアクセスする部分も書くことができます。

// author.nameという記法でネストしたプロパティにアクセスできる
type ValidFieldsDot = MicroCMSRequestFields<'title,main,author.name', { title: 'hoge', main: 'hoge', author: { name: 'name', test: 'test' } }>
expectType<ValidFieldsDot>("title,main,author.name");

もちろん存在しないプロパティを指定するとneverとなります。

type ValidFieldsDotInvalid = MicroCMSRequestFields<'title,author.test,main,author.never', { title: 'hoge', main: 'hoge', author: { name: 'name', test: 'test' } }>

expectType<() => ValidFieldsDotInvalid>(() => {throw new Error})

こちらの実装は、以下のように型定義してみました。

type Values<T extends _Object> = T[keyof T]

type AccessKeyUsingDot<Str extends string, T extends _Object> = `${Str}.${string & keyof T}`

type PickObjectProps<T extends _Object> = Values<{
    [k in string & keyof T]: AccessKeyUsingDot<k, T[k]>
}>

AccessKeyUsingDot<k, T[k]>のように、型引数を2つ持った型を作成したのがポイントで、これをPickObjectPropsの型定義のほうで、Mapped Typesを使ってT[k]にアクセス可能にすることでネストしたプロパティに対するドット記法を型安全に実現できます。

ただし、この書き方ですと3段階以上のネストしたプロパティに対するドット記法が型安全にできませんでした。こちらは残課題です。

// TODO: Object has nested props over 3 can't be typed
type ValidFieldsDot3ToDo = MicroCMSRequestFields<'title,author.test,author.address.pref,main,author.name', { title: 'hoge', main: 'hoge', author: { name: 'name', address: { pref: 'tokyo' }, test: 'test' } }>

Mapped Typesを使っただけだと、Union Typesにはならないのでtype Values<T extends _Object> = T[keyof T]を使うことでUnion Typesへと変換しています。

Union Typesに変換することで、最終的にtype AccessKeys<T extends _Object> = (string & keyof T) | PickObjectProps<T>でkeyOf Tと合わさって大きなUnion Typesが完成します。

'title'も'author'も'aurhor.name'も含む巨大なUnion Typesを作ることが重要で、最後にそれをTemplate Literal Typesに突っ込んでextendsでConditional Typesとして扱えば、パーサーを書くことができました。

型のテストの書き方

先程からチラチラ書いているexpectTypeは、tsdというライブラリのメソッドです。

以下のように型だけでテストコードが書けるため、今回のようなケースでは気軽に型だけのテストができて便利です。

https://github.com/TeXmeijin/type-safe-microcms-query/blob/286df0e45ef83aa0b1c51ed969382ea21a4e9f75/test-d/index.test-d.ts
  • test-dというディレクトリを切る
  • hoge.test-d.tsというファイルを作成し、expectType等を書き込む
  • npx tsdコマンドを実行する

never型をテストするにあたっては、Errorをthrowすることで表現したのですが、もっときれいなやり方はあるんでしょうか。

expectType<() => InvalidFields>(() => {throw new Error})
expectType<() => InvalidFields2>(() => {throw new Error})
expectType<() => InvalidFields3>(() => {throw new Error})

残課題

再帰の制限について

TypeScriptで再帰的な型を書いた場合は、ある程度以上の複雑な入力を与えると再帰回数の制限にぶつかるようです。
こちらについては未検証です。

https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/

But apart from being computationally intensive, these types can hit an internal recursion depth limit on sufficiently-complex inputs. When that recursion limit is hit, that results in a compile-time error.

3段階以上のネストしたプロパティ

前述のとおり、対応できていません。

リファクタの余地

たった数十行に満たない型ですが、何箇所かリファクタの余地がありそうな表記があります。

  • type _Object = { [x: string]: any }は必要か?object/Object/Record<string, any>あたりの使い分けが分かっていません
  • (string & keyof T)の書き方がちょっと不自然。前半のstring &は不要か?

どなたかコメントしてくださると嬉しいです!


【補足】microCMS について

microCMSという、Headless CMS のサービスがあります。簡単に言うと管理画面付きの API を作成できる SaaS です。

API を管理画面から作成できる世界観のため、GET の API に対しては独自の形式でクエリパラメータをつけることでデータの絞り込みなどができるようになっています。GraphQL に感覚としては近いかもしれません。

microCMS の検索クエリを読みますと、fields=title,main_image,updatedAt,author.nameのようにクエリパラメータを指定することでカンマで区切られたフィールドのデータのみ取得できます。

予め管理画面からtitle,main_image,updatedAt,authorといったキーを持ったデータを作成すると、fields=title,main_image,updatedAt,author.nameといったクエリパラメータでデータを取得できるようになるということです。

課題感

この仕様における課題感としては以下のとおりです

  • fields の他にも limit、filters といったクエリの種類があり、覚えるのが大変
  • 特に fields や filters のクエリに指定する文字列には、レスポンスに設定済みのキーが含まれるわけだが、ただの文字列として扱っているため Typo する危険性がある

前者は、単にクエリを型定義してあげれば良さそうですね。

type MicroCMSQuery = {
  fields: string;
  limit: number;
  filters: string;
  // and so on
};

後者に使える技術として、Template Literal Types があります。

参考文献

Discussion

ログインするとコメントできます