🐷

オブジェクトや配列の深いところの型をパスで取得する

2023/01/27に公開

始めに

type-challengesにオブジェクトのキーパスを取得したり、指定したパスにある型を取得する問題があります。これらの問題を少し改変して、より複雑なパターンでも取得できるコードを書いたので、それについてまとめたいと思います。

作るもの

こんな感じのを作ります。type-challengesと特に違うのは配列部分で、配列指定を[]にして、そこからドットをつなげることで配列以下の内容を知ることができます。

サンプルデータ
const simpleObj = {
  num: 0,
  text: 'hoge',
}

const arrObj = {
  arr: ['foo', 'bar'],
  arrObj: [{
    num: 0,
    text: ''
  }],
  arrDoubleTexts: [
    [''],
    ['', ''],
  ]
}

const tupleObj = {
  tupleTexts: ['', ''] as [string, string],
  tupleObj: [
    {
      num: 10,
      text: ''
    },
    {
      num: 0,
    }
  ] as [{ num: number; text: string }, { num: number }]
}
作るもの(パス一覧の取得)
type SimpleObjPaths = ObjectKeyPaths<typeof simpleObj>
//-> 'num' | 'text'

type ArrObjPaths = ObjectKeyPaths<typeof arrObj>
//-> 'arr' | 'arr[]' | 'arrObj' | 'arrObj[]' | 'arrObj[].num' | 'arrObj[].text' | 'arrDoubleTexts' | 'arrDoubleTexts[]' | 'arrDoubleTexts[][]'

type TupleObjPath = ObjectKeyPaths<typeof tupleObj>
//-> 'tupleTexts' | 'tupleTexts[0]' | 'tupleTexts[1]' | 'tupleObj' | 'tupleObj[0]' | 'tupleObj[1]' | 'tupleObj[0].num' | 'tupleObj[0].text' | 'tupleObj[1].num'
作るもの(パスから型を取得)
type num = GetTypeByPath<typeof simpleObj, 'num'>
//-> number

type texts = GetTypeByPath<typeof arrObj, 'arrDoubleTexts[]'>
//-> string[]

type text = GetTypeByPath<typeof tupleObj, 'tupleObj[0].text'>
//-> string

オブジェクトや配列のパス一覧を取得

まずはパス一覧の取得の方からです。基本的な実装方針としては階層を1つずつ降りながらCurrentPathに追記していき、最後まで辿り着いた時のパスを返すようにします。これを一気に全パターンやると混乱するので、オブジェクト、配列、タプルの順番で解決させていきます。

オブジェクトのパス一覧を取得

最初にオブジェクトだけのパターンを考えます。オブジェクトの場合は.で繋いでいきますが、最初だけ.をつけない特殊ケースがあるため、ユーティリティを用意します。

.で結合するユーティリティ
type JoinObjectKey<CurrentPath extends string, AppendKey extends string> =
  CurrentPath extends ''
    ? AppendKey
    : `${CurrentPath}.${AppendKey}`

後で追記しやすいように配列のケースと条件分岐しておいたら、後はオブジェクトを展開してパスを展開し、一階層下りた後もオブジェクトである場合は再帰します。

type ObjectKeyPaths<T extends object, CurrentPath extends string = ''> = T extends any[]
  ? unknown // 後で実装
  // オブジェクトの場合
  : {
    [K in keyof T]:  K extends string
      ?
        | JoinObjectKey<CurrentPath, K>
        | (T[K] extends object
          ? ObjectKeyPaths<T[K], JoinObjectKey<CurrentPath, K>>
          : never
        )
      : never
  }[keyof T]

配列のパス一覧を取得

次に配列です。まずタプルと配列と区別する必要がありますが、Arr['length']したときにnumberと返るのが配列になるのでそれで分かります。タプルの場合は2とか3とか、具体的な数値が返ってきます。

配列とタプルの区別
number extends Arr['length']
  ? // 配列
  : // タプル

後はオブジェクトと同じように現在のパスを展開し、更に配列の中身がオブジェクトだった場合は再帰します。配列もobjectの一種なのでこの判別だけでオブジェクトと配列両方を判定させることができます。

 type ObjectKeyPaths<T extends object, CurrentPath extends string = ''> = T extends any[]
+  ? number extends T['length']
+    // 配列の場合
+    ? T extends (infer U)[]
+      ?
+        | `${CurrentPath}[]`
+        | (U extends object
+          ? ObjectKeyPaths<U, `${CurrentPath}[]`>
+          : never
+        )
+      : never
+    // タプルの場合
+    : unknown // 後で実装
   : // オブジェクトの場合は省略

タプルのパス一覧を取得

最後にタプルです。タプルは1つ1つの項目で型が異なっているため再帰も更に複雑になります。まず1つ1つの項目を取り出すにはArr extends [...infer Rest, infer U]にすることで1個分のUと残りのRestに分けられます。何故後ろの方から取っているかというと、Rest['length']がちょうどUのindex番号と一致するためです。
例えばtype Arr = [number, string]Arr extends [...infer Rest, infer U]をマッチさせるとRest = [number]Rest['length']1になります。

これらを組み合わせることで、以下のようなコードで実現することができます。
ユニオンタイプの1つ目2つ目はオブジェクトや配列と同じように現在のパスを展開し、さらに中身がオブジェクトだった場合は再帰します。タプルの場合はこれに加えて、残りのタプル部分もチェックする必要があるためCurrentPathは変更せずTの部分だけ変えて再帰します。

 type ObjectKeyPaths<T extends object, CurrentPath extends string = ''> = T extends any[]
   ? number extends T['number']
     ? // 配列の場合は省略
+    // タプルの場合
+    : T extends [...infer Rest, infer U]
+      ?
+        | `${CurrentPath}[${Rest['length']}]`
+        | (U extends object
+          ? ObjectKeyPaths<U, `${CurrentPath}[${Rest['length']}]`>
+          : never
+        )
+        | ObjectKeyPaths<Rest, CurrentPath>
+      : never
  : // オブジェクトの場合は省略

全体のコード

以上のコードをPlayGroundにまとめましたので、全体のコードを見たい方はこちらをご参照ください。

https://www.typescriptlang.org/play?target=6#code/MYewdgzgLgBBCWBbADgGwKYHkBGArGAvDAN4BQMMYArogFwwAMANOTFOgB5T0DkAFiADm6HiwC+pUqEiwAhgCd5OfETIUF8+gG0eAMxAhRMHtgU8Aui3WLl2tRUo16zVhXZdePVmMusNAERAqbAwAFU4oCG1XGB0LKwc4pmN41nNSCSlwaDYqNCw8QhJWKDywiKjYniNq8xhZCFjoeXgwQWTm1sFfNzKC3GiHYqGKajoYAEYXEbYIzxixBIp7IbHnJZgJCjqG2OJHcbHsdHkAblmPOCgWts3k-bWD4-lN9MzpHMQT4QATZSL7AA6YEIFAYZQJYGAjQQ1hQ0r5WGZKAAT2Q6BgACkQK1lOhgFAANLoFEAHgAwlRFOgwFAAAqyKB8GARGk-RqdNrJACCyHRYB+xJRLK4bI51y6AD5CKxKdTaQymSL2ALGtUYgB+GC8-mCkkxegAAwAJMQ5fIafTGXwxIDTTq2UKxIbJKj0TA8QShYq+BBSaFlWKYCA8PioMlzZafYHVVcboIitVpUQA6zY7IwCitOkKFqjicY+yYKEdBg2kyLDEAPRVmCAWUTAOhKgDsGQAsGoAIFU1xcLjQAFK1dAWAKoASmzMVz44cAB8YCazVSLQrrWJsy6ZhQZz3B93g6GCZOhlrPUSST6-YPknPI0umSvzIbJQeHPQwOgAG4nJ-Dycv9+foY1jAgD9DIA6wyANcMrYdkM9CpqKsZaFC-YFgASug0DJEhLyDjmIwak+M5XguUbLlopqodApY0oIFbmD4a7rpu25pkWIa4GGT65h6e4niiZ6khes6mteVq3qRxDkVAlHlnwFh0Y+67PpQf7yF++FcWxXqntafoSRGRE3nw8kjL+H4qRQgGAFUMgBrDIAHQyAOUMgD1DIAEwyQaw9ArLEhIwK0MAANYkiAujFuY9AwF5zHivGk54QpM7Yri3FChS+kiXwySEkZMybiWhJ1BFu4aVAHEwEeiVaUyfo5ZYWI4mAx5JcJPrpZKmXriZ-7rt+MztWZmxaP5KKBcFrpohiADKSCIngZ5FPV5W+qSbroENoJTbgj41gAtNKPBjDwMAzjw7hQF4pBLdqNjTdps1lbx2mLaNQ0wngG1VttxgaPth0aNmX0fZduB-Twz24L9B3-UoeDZoCe3g8DAPQ8dQMBEEIToOEXAQMjiiBME5SY2D3046j+ORNmv0je6oR9Mo0ZEHNd0VQ96JDQi4IvaQW07Wz6MVEDPMY2TDDmPzfSCxAWgTCLcM88ootrVowvy+zoNS8r-SK+YMM0Oryia4CSMyzTUNS9riBeEAA

オブジェクトや配列のパスから型を取得する

次は先ほど取得したパス一覧のいずれかを指定した時に得られる型を作っていきます。
パス一覧取得と同じようにオブジェクト、配列、タプルの順番でやっていきます。

オブジェクトのパスから型を取得する

まずはオブジェクトだけのパターンからです。まずはKeyPathCurrentPath&infer Restで前方一致を試します。Restに何も文字が入らずにマッチした場合は完全一致になるので現在のTを返し、そうでなければもっとパスを進める必要があります。そもそもマッチしなかった場合はこれ以上探索しても無意味なのでneverを返します。
前方一致してRestに何かある場合はパスを進める必要があるので、現在のTがオブジェクトだったら一階層降りて再帰します。

type GetTypeByPathImpl<
  T,
  KeyPath extends string,
  CurrentPath extends string = ''
> = KeyPath extends `${CurrentPath}${infer Rest}`
  ? Rest extends ''
    // CurrentPathがKeyと一致したらTを返す
    ? T
    : T extends any[]
      ? unknown // 後で実装
      : keyof T extends never
        ? never
        // オブジェクトの場合
        : {
          [K in keyof T]: K extends string
            ? GetTypeByPathImpl<T[K], KeyPath, JoinObjectKey<CurrentPath, K>>
            : never
        }[keyof T]
  : never

補足

typeの名前を~Implとしていて実装用という意味合いでつけております。本来引数の型はもっと厳しくかけた方がタイプセーフになって良いですが再帰でそれをやると上手く推論できなかったりなどの問題が起きてしまいました。なので呼び出し用と再帰用で分けています。

// 再帰用(内部で使用するイメージ)
type GetTypeByPathImpl<
  T,
  KeyPath extends string,
  CurrentPath extends string = ''
> = ...

// 呼び出し用(こっちを使う)
type GetTypeByPath<
  T extends object,
  KeyPath extends ObjectKeyPaths<T>
> = GetTypeByPathImpl<T, KeyPath>

配列のパスから型を取得する

続いて配列です。配列の再帰は非常にシンプルで、単純に中身をアクセスするだけです。

 type GetTypeByPathImpl<
   T,
   KeyPath extends string,
   CurrentPath extends string = ''
 > = KeyPath extends `${CurrentPath}${infer Rest}`
   ? Rest extends ''
     // CurrentPathがKeyと一致したらTを返す
     ? T
     : T extends any[]
+      ? number extends T['length']
+        // 配列の場合
+        ? GetTypeByPathImpl<T[number], KeyPath, `${CurrentPath}[]`>
+        // タプルの場合
+        : unknown // 後で実装
       : // オブジェクトの場合は省略
   : never

タプルのパスから型を取得する

タプルは1つ1つ中身を見る必要があるので少し複雑ですが、パスを1つ進める再帰とタプルの残りを見る再帰をそれぞれ行なっています。

 type GetTypeByPathImpl<
   T,
   KeyPath extends string,
   CurrentPath extends string = ''
 > = KeyPath extends `${CurrentPath}${infer Rest}`
   ? Rest extends ''
     // CurrentPathがKeyと一致したらTを返す
     ? T
     : T extends any[]
       ? number extends T['length']
         // 配列の場合
         ? GetTypeByPathImpl<T[number], KeyPath, `${CurrentPath}[]`>
+        // タプルの場合
+        : T extends [...infer TupleRest, infer U]
+          ?
+            | GetTypeByPathImpl<U, KeyPath, `${CurrentPath}[${TupleRest['length']}]`>
+            | GetTypeByPathImpl<TupleRest, KeyPath, CurrentPath>
+          : never
       : // オブジェクトの場合は省略
   : never

全体のコード

以上のコードをPlayGroundにまとめましたので、全体のコードを見たい方はこちらをご参照ください。

https://www.typescriptlang.org/play?target=6#code/MYewdgzgLgBBCWBbADgGwKYHkBGArGAvDAN4BQMMYArogFwwAMANOTFOgB5T0DkAFiADm6HiwC+pUqEiwAhgCd5OfETIUF8+gG0eAMxAhRMHtgU8Aui3WLl2tRUo16zVhXZdePVmMusNAERAqbAwAFU4oCG1XGB0LKwc4pmN41nNSCSlwaDYqNCw8QhJWKDywiKjYniNq8xhZCFjoeXgwQWTm1sFfNzKC3GiHYqGKajoYAEYXEbYIzxixBIp7IbHnJZgJCjqG2OJHcbHsdHkAblmPOCgWts3k-bWD4-lN9MzpHMQT4QATZSL7AA6YEIFAYZQJYGAjQQ1hQ0r5WGZKAAT2Q6BgACkQK1lOhgFAANLoFEAHgAwlRFOgwFAAAqyKB8GARGk-RqdNrJACCyHRYB+xJRLK4bI51y6AD5CKxKdTaQymSL2ALGtUYgB+GC8-mCkkxegAAwAJMQ5fIafTGXwxIDTTq2UKxIbJKj0TA8QShYq+BBSaFlWKYCA8PioMlzZafYHVVcboIitVpUQA6zY7IwCitOkKFqjicY+yYKEdBg2kyLDEAPRVmCAWUTAOhKgDsGQAsGoAIFU1xcLjQAFK1dAWAKoASmzMVz44cAB8YCazVSLQrrWJsy6ZhQZz3B93g6GCZOhlrPUSST6-YPknPI0umSvzIbJQeHPQwOgAG4nJ-Dycv9+foY1jAgD9DIA6wyANcMrYdkM9CpqKsZaFC-YFgASug0DJEhLyDjmIwak+M5XguUbLlopqodApY0oIFbmD4a7rpu25pkWIa4GGT65h6e4niiZ6khes6mteVq3qRxDkVAlHlnwFh0Y+67PpQf7yF++FcWxXqntafoSRGRE3nw8kjL+H4qRQgGAFUMgBrDIAHQyAOUMgD1DIAEwyQaw9ArLEhIwK0MAANYkiAujFuY9AwF5zHivGk54QpM7Yri3FChS+kiXwySEkZMybiWhJ1BFu4aVAHEwEeiVaUyfo5ZYWI4mAx5JcJPrpZKmXriZ-7rt+MztWZmxaP5KKBcFrpohiADKSCIngZ5FPV5W+qSbroENoJTbgj41gAtNKPBjDwMAzjw7hQF4pBLdqNjTdps1lbx2mLaNQ0wngG1VttxgaPth0aNmX0fZduB-Twz24L9B3-UoeDZoCe3g8DAPQ8dQMBEEIToOEXAQMjiiBME5SY2D3046j+ORNmv0je6oR9Mo0ZEHNd0VQ96JDQi4IvaQW07Wz6MVEDPMY2TDDmPzfSCxAWgTCLcM88ootrVowvy+zoNS8r-SK+YMM0Oryia4CSMyzTUNS9riCnedADi6BQKEo0AEKM3wACSYKkqwoQJN61o7py7Syil0b5X7iZeMmYXzTuhHyqlYimphMASc6rBahJO7qgBtaNdagAyDEKgAWDIAAHKAC+BgDqDIA+gyAJEMoSAEkMgAr8YAmgydqEBpdvlGZZjhh5PAW+UljwZbUTJXcjIBjauQpWrW7bDtO67aD+lo+byNV3tMpeQmByR96tZnwHgRPbVt3BRYIcCCfU-kuneWAA5YSPMwxQp04wNPdvoo7Prz6g-HpfNG-zhjj6FcppL4YAklJIeskd7FQ3K-G2790Cf2tN-f0fRr5rzSjAbOTJd7GSUqZH8fkApBVgiqIsr5CGTwIR1GYllbKORcu2J87lipaC8j5AaQ1QghTCr7CUbRYElXgTPD+c83ZVT-k7ZI8U6q3WSkA60zU8HdRob1GYK4uGkJwj1SmGI36zx9O7CgZCgysTDF7SO+UGZ8VCI+cOBixFfwkVIn0j4zqjQOEURxSCnbM2WkFVaKtki7R1o+c6x1GhEB8cgpk-inoAxCSjPGvMCYi3CZ4463iEGGOtPEoKss8AhMKaDYWBsIg8EfEAA

終わりに

以上がオブジェクトや配列の深いところの型をパスで取得する方法でした。この記事では書けませんでしたが、これ応用して特定のパスをstringからDateに変換したりできると、日付のコンバートが楽にできるなぁと思っています。これについては出来次第記事に上げられたらなと思っています。

Discussion