🧩

TypeScriptで「項目名をカンマ区切りで再帰的に指定する」場合の型を定義する

に公開

この記事では、主にREST APIを呼び出す際に、項目の絞り込みやソートのためのクエリ文字列をTypeScriptの型で制御できるようにする方法を説明します。

使用例

例えばHeadless CMSであるContentfulでは、情報を一覧取得する際のソート項目を指定する order パラメータを、以下のような文字列の形式で指定します。

https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters/order

実装イメージ

await client.getEntries({
  ...
  // fields.dateの昇順かつ、sys.createdAtの降順でソートする場合
  order: 'fields.date,-sys.createdAt', // 👈 この記事では、ここを型定義します
});

Contentfulのライブラリで自動生成される型定義だとorderパラメータは string となっており、そのままだと任意の文字列を指定できてしまいます。

今回説明する型を使用すると、上記のような関数を定義する際に order の値に間違った文字列が設定されないよう制限することができます。

成果物

最終的な定義は以下の通りです。

https://www.typescriptlang.org/play?#code/PTAEEYDpUcNNHVtQ6EqCuGQYwyEuGQ1wyCSGQsYqAY9QELdBrBkAVfQCBVBNBkGiGQaPUAoAFwE8AHAU1ACkB7ASwDsA0myYBnADx1QoYU1BsAHgzb8AJqNCiGAJwEBzADRTQABQCG2lQ1nylK9Zp37QAH1ABXNWwBmAtqroAPlAAXlMLKxtFZTUNLV1+PVAAflAAAwASAG9zS35rEQBfSGzZQrTQAC4ZEQBuOkZWDgBRJW0zAGMCsQAlNg73bVFeADc2ABsmSWkAFVsYh24AIwArfoYjaVzIkXn7OKdE1w8vX35-UJPVHz8A4LCs4wBtGwFQAGsRbm9QGYBdAC0yWqUTssUcCT0xmkqQActx+LD3ONxmYluM2OIZi8RH9gtF9qBlmsutDpClQK0dJ1uqI+gMhqMJlN4Yjkaj0ZjsbI8QYuHwhCIJLI+dt8rJAoEydJqjwBLJhSJRRFxSIpeSqqBzmNtPVCk9Pkxvr8-vVGuxKQoWGY1Fj7r89uD4s5UnNqtq2LqGooWNxtAxQMwLVT2l0FVjHYtVut7a1rbaQzSFfTBsMxpM7YEzSBQAAmaCAKoZAGsMgA6GQDlDIB6hkAEwyAOwZALUMgB+GLBpSBpAAUgEmGQDDDFWAJSALmVAOBKgCiGGvwBCACwZAOoMgDMGfDEQBryoB0-WoNFAbYA4ipPbwOkRqL3zRwAOq8BgACwA8tprtoACK8SxdXgIiME8HEmOXTJZABEv+OX8AV-QpskTMMhTtcozSDDgADUzHGXhVDMZRr1vFNGXTKZjDmd8HGdRJNlAAAxXgJgcfCNE-LpiPQz0FUjA5IUuU8L3o+9H3WF9+HEMiKNEKV7TwsEHA4hVjFdYxqhEhYNB-cShUKAxsgEbxPVAABBbxlG0ABhbgAFtDLMcpJIdKj0lU-h1O0UjlOybTdIM4zTLSMlUh-EiHKyBCkJQtCb09TC02ZcQnM9FyTL5fjxnUQIzI1d02B1aTQEUsRszAABmaB8ywQB-eUAYwYZ0Ab7lAHhDGti3LasaEAaQZAEiGP1b0ARYY0EAQ4YG0AfoYa0ALO1AEr-LBAHxXQAEIynQBABkAItTAAdTGtAFR9aaiEASwTAApXI9wnaQyNAeYxrU28BqkIvR6mkPazEM3NDsOY7dosc7suqR4NTOwzsoAfQOiF9BO8kXvey6vsSH7Tru163oe0AslAP7wY+q6WMKH7EboZHYI286OLfUTmP0e0-OQ1C2A4kKmQzGZlU2wSzQ6BEtFAAB3M8ryCuywixuTAb0QI22az1qlyDGWbtXtQmCXmvToHMaBrFBAE6GKs6EZ9iWbbX8XvAX9e3qJXmdvVWAXVzXtaZjjVb+yAYY1rXFZNlWgPNy2jZt5W9ft0Hcphj33dhq3jZdz0zdB8ADBe3MnZ1021aDgwDdBsPrYju31Zjh3vd953dYDt3NtzEP3YttOU6D8Pbdd2PNq9ivwYLqv3uD0O8-2kv-e0QPNoAFiN0Ac09bQ-QzyPU6r+vQc7rXu7AXv+6AA

この記事では上記の型定義について、以下の部分に分けて解説していきます。

  1. param1,-param2,param3 のように、文字列をカンマ区切りで任意の数結合できる型の定義方法
  2. {a: string, b: {b1: string, b2: string}} のようなオブジェクト型から a | b.b1 | b.b2 のような形でキーのUnion型を抽出する方法
  3. 1と2を組み合わせ、オブジェクト型を元にソートパラメータの型を定義する方法

はじめに

今回の方法は少しややこしい型定義が必要となるため、場合によっては型で対処せず、普通に関数で処理した方が楽な可能性があります。各自の状況に応じて適切な方法を使用してください。

前提

1. 文字列リテラルを再帰的に結合する型

まずはじめに、param1,-param2,param3 のように、文字列をカンマ区切りで任意の数結合する型を定義していきます。

定義

type FieldName = "param1" | "param2" | "param3";

// 2つのUnion型の全ての組み合わせを取得
// `"param1" | "param2" | "param3" | "-param1" | "-param2" | "-param3"`
type FieldNameWithOrderDirection = `${"" | "-"}${FieldName}`;

// TがFieldNameWithOrderDirectionをカンマ区切りで結合した文字列であれば、その型を返す
export type ValidateOrderRecursively<T extends string> =
  T extends FieldNameWithOrderDirection
    ? T
    : T extends `${FieldNameWithOrderDirection},${infer R}`
    ? T extends `${infer F},${R}`
      ? `${F},${ValidateOrderRecursively<R>}`
      : never
    : FieldNameWithOrderDirection;

// Tが条件を満たしいない場合のみエラーになる型
type ValidateOrder<T extends string> = T extends ValidateOrderRecursively<T>
  ? T
  : ValidateOrderRecursively<T>;

// 使い方: 以下のように関数の引数に適用すると、不正な型が渡された場合にエラーになります
const withOrder = <T extends string>(order: ValidateOrderRecursively<T>) =>
  order;

TypeScript Playground

説明

主な定義は以下の部分です。

type FieldName = "param1" | "param2" | "param3";

// 2つのUnion型の全ての組み合わせを取得
// `"param1" | "param2" | "param3" | "-param1" | "-param2" | "-param3"`
type FieldNameWithOrderDirection = `${"" | "-"}${FieldName}`;

// TがFieldNameWithOrderDirectionをカンマ区切りで結合した文字列であれば、その型を返す
export type ValidateOrderRecursively<T extends string> =
  T extends FieldNameWithOrderDirection
    // TがFieldNameWithOrderDirectionのいずれかと一致する場合は、
    // T自体の型を適用することでエラーが起きないようにする
    ? T
    // 1つ目のカンマより手前がFieldNameWithOrderDirectionに一致する場合
    // カンマ以降をAfterCommaとして取り出す
    : T extends `${FieldNameWithOrderDirection},${infer AfterComma}`
    // FieldNameWithOrderDirectionに一致した部分をinferで取り出す
    ? T extends `${infer F},${AfterComma}`
      // AfterCommaを再帰的に処理してカンマで結合
      ? `${F},${ValidateOrderRecursively<AfterComma>}`
      : never // infer Fは必ず成功するため、ここは絶対に通らない
    // エラーになる場合はFieldNameWithOrderDirectionを返し、
    // エラーメッセージ中に正しい値の候補が表示されるようにする
    : FieldNameWithOrderDirection;

FieldNameWithOrderDirection では ${UnionType1}${UnionType2} の形式で、2つのUnion型の全ての組み合わせを新しいUnion型として取得しています。

その上で、ValidateOrderRecursively では、型引数のTが FieldNameWithOrderDirectionをカンマ区切りで結合した文字列であるか を再帰的に検証し、検証に成功した場合はその文字列自体の型を、失敗した場合は候補となる値の型を返します。

参考: https://stackoverflow.com/a/67184772

// 正しい値の場合は `"param1,param2"` のように値の型がそのまま返る
type TestType1 = ValidateOrderRecursively<"param1,param2">; 

// 間違っている場合は `"param1,param2" | "param1,param3" | "param1,param1" | "param1,-param1" | "param1,-param2" | "param1,-param3"` のように
// 候補となる値の一覧を返す
type TestType2 = ValidateOrderRecursively<"param1,dummy">;

この型を関数の引数に適用することで、間違った値を指定された場合にのみ、適切なエラーが表示されるようになります。

const withOrder = <T extends string>(order: ValidateOrderRecursively<T>) =>
  order;

// 引数の型が `"param1,param3"` と推論され、
// 実際の値と一致するためエラーにならない
withOrder("param1,param3");

// 引数の型が `"param1,param1" | "param1,param2" | ...` と推論され、
// 実際の値と一致しないためエラーになる
withOrder("param1,dummy");

参考: VSCodeで withOrder 関数の型を確認してみる

上記の 間違った値を指定された場合にのみ、適切なエラーが表示される 部分の動作を確認するため、VSCodeでどのように型推論されているのかを確認してみます。

正しい値の場合

エラーの場合

余談

試してみたこと(本題じゃないので省略)

気になって色々試してみていた所、以下のように定義すると末尾にカンマを入力したタイミングでのみ入力補完を効かせられるようになりました。

export type ValidateOrderRecursively<T extends string> =
  T extends FieldNameWithOrderDirection
    ? T
    : T extends `${FieldNameWithOrderDirection},${infer R}`
    ? T extends `${infer F},${R}`
      ? `${F},${ValidateOrderRecursively<R>}`
      : never
    : T extends "" // ここに分岐を加える
    ? FieldNameWithOrderDirection
    : never;

ただ、それ以降に別の文字列を入力してしまうとnever型として解決されてしまい、本来の目的から外れてしまうため諦めることにしました。(もし原因が分かる方が居たら教えて頂きたいです)

2. オブジェクトのキーを.(ドット)区切りの文字列として再帰的に取得する型

次に、任意のオブジェクトからキーの部分をUnion型として取得する型を定義します。

  • 入力例: {param1: string, param2: {param2_1: string}}
  • 出力例: "param1" | "param2.param2_1"

定義

// ParentKeyがstringの場合は`.`で結合、undefinedならKeyをそのまま返す
type JoinKeys<
  Key extends string,
  ParentKey extends string | undefined
> = ParentKey extends string ? `${ParentKey}.${Key}` : Key;

// Tのkeyを再帰的に走査して、`.`で結合した文字列をUnion型にする
type ExtractKeysRecursively<
  T extends object,
  ParentKey extends string | undefined = undefined
> = {
  [Key in keyof T]-?: Key extends string
    ? NonNullable<T[Key]> extends Record<string, any>
      ? ExtractKeysRecursively<NonNullable<T[Key]>, JoinKeys<Key, ParentKey>>
      : JoinKeys<Key, ParentKey>
    : never;
}[keyof T];

// 再帰的に処理された値も文字列リテラルに展開する
type Expand<T> = T extends string ? T : never;

export type ExtractKeys<T extends object> = Expand<ExtractKeysRecursively<T>>;

// 使い方: 以下で `"param1" | "param2.param2_1"` という型を取得できます
type ParamFieldKeys = ExtractKeys<{
  param1: string;
  param2: {
    param2_1: string;
  }
}>;

TypeScript Playground

説明

主な定義は以下の部分です。

type JoinKeys<
  Key extends string,
  ParentKey extends string | undefined
> = ParentKey extends string ? `${ParentKey}.${Key}` : Key;

type ExtractKeysRecursively<
  T extends object,
  ParentKey extends string | undefined = undefined
> = {
  // objectのkeyは `string | number | symbol` なのでstringに限定
  // また、省略可能なキーがある場合に最終的な型にundefinedが含まれないよう `-?` を指定
  [Key in keyof T]-?: Key extends string
    // undefinedやnullの可能性がある場合も再帰的に処理できるようNonNullableを適用
    ? NonNullable<T[Key]> extends object
      // 値がobjectの場合は再帰的に処理。それ以外の場合は `親Key.子Key` を返す
      ? ExtractKeysRecursively<NonNullable<T[Key]>, JoinKeys<Key, ParentKey>>
      : JoinKeys<Key, ParentKey>
    : never; // ここは基本的に通らない
}[keyof T];

mapped typesでオブジェクトのキーを再帰的に連結しています。
親Keyがstringの場合は子Keyと連結、undefinedの場合は子Keyを返す という処理が複数箇所に必要で、直接書くと分岐が複雑になるためJoinKeysとして外出ししています。

なお、下記の Expand は適用しなくとも型定義としては正しく動作しますが、そのままだとVSCodeなどで型を参照した際、再帰的に処理している部分が展開されずに見えていまいます。

type Expand<T> = T extends string ? T : never;

export type ExtractKeys<T extends object> = Expand<ExtractKeysRecursively<T>>;

これだと分かりにくいため、conditional typesを通すことで展開された値で全ての値が展開された状態で表示されるようになります。

3. 1と2を組み合わせて最終的な型を定義する

あとは任意のオブジェクトに対し、2.でkeyの一覧を取得し、結果を1.に渡すことで冒頭のようなソート項目を指定するための型を定義できます。

定義

// 1. 文字列リテラルを再帰的に結合する型
type JoinKeys<
  Key extends string,
  ParentKey extends string | undefined
> = ParentKey extends string ? `${ParentKey}.${Key}` : Key;

type ExtractKeysRecursively<
  T extends object,
  ParentKey extends string | undefined = undefined
> = {
  [Key in keyof T]-?: Key extends string
    ? NonNullable<T[Key]> extends object
      ? ExtractKeysRecursively<NonNullable<T[Key]>, JoinKeys<Key, ParentKey>>
      : JoinKeys<Key, ParentKey>
    : never;
}[keyof T];

type Expand<T> = T extends string ? T : never;

export type ExtractKeys<T extends object> = Expand<ExtractKeysRecursively<T>>;

// 2. オブジェクトのキーを`.`(ドット)区切りの文字列として再帰的に取得する型 (Genericにする)
type WithOrderDirection<T extends object> = `${"" | "-"}${ExtractKeys<T>}`;

type ValidateOrderRecursively<
  T extends string,
  Fields extends object,
  OrderParam extends string = WithOrderDirection<Fields>
> = T extends OrderParam
  ? T
  : T extends `${OrderParam},${infer AfterComma}`
  ? T extends `${infer F},${AfterComma}`
    ? `${F},${ValidateOrderRecursively<AfterComma, Fields>}`
    : never
  : OrderParam;

// 3. 2.を使って任意のオブジェクト型からorderパラメータの定義を生成し、関数の引数に適用
type Params = {
  param1: string;
  param2: string;
  param3: {
    param3_1: string;
    param3_2: string;
    param3_3: { param3_3_1: string };
  };
};
type ParamOrder<T extends string> = ValidateOrderRecursively<T, Params>;

const withOrder = <T extends string>(order: ParamOrder<T>) => order;

// 型のテスト
withOrder("param1");
withOrder("-param1");
withOrder("param3.param3_1");
withOrder("-param3.param3_1");
withOrder("-param3.param3_3.param3_3_1");
withOrder("param1,param2");
withOrder("param1,-param2");
withOrder("param1,-param3.param3_1");
withOrder("-param2,param3.param3_1,-param1");
withOrder("-param3.param3_3.param3_3_1,param2,param1");
withOrder("param4"); // error
withOrder("param3.param3_1,param4"); // error

TypeScript Playground

説明

主な定義は以下の部分です。

type WithOrderDirection<T extends object> = `${"" | "-"}${ExtractKeys<T>}`;

type ValidateOrderRecursively<
  T extends string,
  Fields extends object,
  // 第二引数のオブジェクト型を元にソート指定のための文字列を生成する
  OrderParam extends string = WithOrderDirection<Fields>
> = T extends OrderParam
  ? T
  : T extends `${OrderParam},${infer AfterComma}`
  ? T extends `${infer F},${AfterComma}`
    // Fieldsを受け渡すよう変更
    ? `${F},${ValidateOrderRecursively<AfterComma, Fields>}`
    : never
  : OrderParam;

1.で定義した ValidateOrderRecursively を、任意のオブジェクト型を渡せるよう型引数を追加しています。

これにより、以下のように "-"で昇順/降順を指定したパラメータを","区切りで任意の数結合した文字列 を引数として取る関数を定義することができました。

withOrder("-param3.param3_3.param3_3_1,param2,param1");

おわり

長くなりましたが、この記事での説明は以上です。

冒頭にも記載した通り、無理に型定義で対処する必要は無いかもしれませんが、型だけでもここまで定義できるんだなーと初めて知ったので参考までに記事にしてみました。

皆さんも、良い型パズルライフを👋

References

Discussion