🚀

obj.hogeみたいに.で繋いだkeyに型を当てる方法

2022/05/22に公開

始めに

Vue.js 2系ではobjectの中にあるものを.で繋いでwatch指定することができます。

Vue.extend({
  data() {
    return {
      obj: {
        hoge: 'hogehoge'
      }
    }
  },
  watch: {
    'obj.hoge'() {
      console.log('change obj.hoge')
    }
  }
})

こうしたパスでネストしたオブジェクトの指定をするのが便利な一方で、型を当てるのが非常に難しいです。ですが最近のTypeScriptではリテラル型を結合する機能も実装されており実現は可能ですのでその方法についてまとめたいと思います。

使い方はこんな感じをイメージしています。

type Hoge = {
  a: string
  b: number
  obj: {
    hoge: number
    foo: string
  }
}

type keys = Paths<Hoge>
// `.`で繋いだパスを含めたキー一覧が出る
// 'a' | 'b' | 'obj' | 'obj.hoge' | 'obj.foo'

.で繋いだパスを含めてkey一覧を出す方法

.でリテラルタイプを結合するユーティリティを作る

まずは.で結合するユーティリティを用意します。ちょっとややこしそうな感じですがやっていることは単純で、KとPがそれぞれstring | numberの時だけKとPを.で繋いだ型を返しています。ユニオンタイプの場合もそれぞれに対して結合してくれます。

type Join<K, P> = K extends string | number 
  ? P extends string | number
    ? `${K}${'' extends P ? '' : '.'}${P}`
    : never
  : never
  
/*
Join<'a', 'b'>
→ 'a.b'

// ユニオンタイプの場合はそれぞれに対して結合される
Join<'a', 'b' | 'c'>
→ 'a.b' | 'a.c'
*/

再起呼び出しでkeyofで取得したキーリストをJoinしていく

結合ユーティリティが用意できたので、後はkeyofでキーリストを取得して再起で深い階層まで呼び出していきます。

type Paths<T> =
  T extends object
    ? {
      [K in keyof T]-?: K extends string | number
        ? K | Join<K, Paths<T[K]>>
	: never
      }[keyof T]
    : never

/*
Paths<{
  obj: {
    hoge: number
    foo: string
  }
}>
の場合、
K='obj'となり、
Join<'obj', Paths<{ hoge: number; foo: string }>>でPathsが再起呼び出しされる

Paths<{ hoge: number; foo: string }>
の場合は
{
  hoge: 'hoge'
  foo: 'foo'
}['hoge' | 'foo']
となり最終的には'hoge' | 'foo'が返る

よってJoin<'obj', 'hoge' | 'foo'>になり、'obj.hoge' | 'obj.foo'になる
*/

ただこのままだとPathsが永遠に呼び出される可能性があるのでエラーが起きてしまうので、再起呼び出しの回数制限ロジックを含めます。

+// 1ずつ減っていくタプル型を用意する
+type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...never[]]
+type Paths<T, D extends number = 10> = D extends never
   ? never
   : T extends object
     ? {
         [K in keyof T]-?: K extends string | number
+          ? K | Join<K, Paths<T[K], Prev[D]>>
           : never
       }[keyof T]
     : ''

.で繋いだkeyからvalueを取得する方法

上記のやり方で.で繋いだkey一覧は取得できました。しかしこれだとwatchのようにkeyを指定するだけなら良いですが、getのようにvalueを取り出す際に型を参照することができません。次はvalueの型を算出していきます。
keyofに出てくるものであればT[K]でvalueの型を算出し、そうでない場合はinferを使って最初にくる.の前と後にリテラルを分割してどんどん深い階層に入っていきます。

type _NestObjType<T extends object, P extends string> = P extends keyof T
  ? T[P]
  : P extends `${infer V}.${infer Rest}`
    ? V extends keyof T
      ? T[V] extends object
        ? _NestObjType<T[V], Rest>
	: never
      : never
    : never

これでvalueの型を取得できるようになりましたが、Pの部分を具体的にPathリストになっていると良いので制約として与えます。再起エラーが意外と早く起きたので最大階層は5にしています。

type NestObjType<T extends object, P extends Paths<T, 5>> =
  P extends string ? _NestObjType<T, P> : never

余談

今回Paths制約は最初だけにして再起ではPaths制約を外しています。理由は推論が重くなるかなと思ったのと、案外使わなくてもしっかり型を定義できたためです。
一応Paths制約を与えて再起を書くと以下のようになります。

type NestObjTypeFull<T extends object, P extends Paths<T, 5>> = P extends `${infer V}.${infer Rest}`
  ? V extends keyof T
    ? T[V] extends object
      ? NestObjTypeFull<T[V], Paths<T[V], 5>>
      : never
    : never
  : P extends keyof T
    ? T[P]
    : never

終わりに

以上が.で繋いだkeyに型を当てる方法でした。.で繋いだkeyは型の当てづらさ、実装のやりづらさの問題はありますが、それでも使い手としては使いやすかったりするのでもしこういう状況で型をしっかり当てたいと思った時はぜひ参考にしてもらえると嬉しいです。
今回のサンプルコードは以下に書かれていますので、興味がある方は見てください。

https://www.typescriptlang.org/play?#code/PTAEiHlRgAILlALALggDgZ2iVCCGBjA1gPYBuApgE4BmANoQO4B0uhAtsAI4CupWAloQDtUwAKwAOACwBmaWICcwBAE9kPXOV7IEAWgAmpUsm35SSwpW3nt2bQJ4JSuywCMAVqVwJRkqQDY5InIAxOLS-oEAUMqqoABShLwCADwA0gA0oAAKAHygALygKaCkAB4OArqooFgaAgDmoAA+oAKcLM4UoBGgoAD8WcVlpBVVNYkNza3tFN09faAABgAkAN4pAL6rAORbg+WVA-07oLBbDFubK5nrC7M9sHZk5LMPpE8RUSqkWeRv+aAAbUeFAyAAYMgBGDIAJgyUgyEgyIgyvgyAHYMmIMnJIeDQAwCcDyACALokz4xTLYBBwVBJAAqGQAInthgcph1yP8IaDcgUWaV9lUibN+iL7qB6ayRqBCG4PAg7vMVkq5oCiolQCYzJRJSTtL1YEVBWzRghahMWm1Oaq1fMis14olUhkqTS6fSASkSa7fsQAUySdlsra1a93nbQOsAdrzHqlacthTvgAZUjYMge5nS9nWzoFHl80ACoYy8XzcuwKUmmVy9yeJX9FWRnpe0Ca2O6+n6w2FHNmi3zJ3JdKgNMZngMr0+n5vANB3LhmaR6Od+Nh0A7D7Rb4ACUIdW+BWboGwsDG9VmzgeeeePTrsBPPTgB9IN+md7mlEIhHP5vGszrBEQHJqADhYNC-xurSST7oeIY7mB9hSP846ZrBr4hqBABy9gAPJuPSXwMv2sryp4rqkdBWagCIwZQaRF4NP0AD6uFYARrhEaoDKuouLRvDMEQgBAMDwEgaAYMAqDqNgyDOIQJQMPwijpiw1DYM42iUOQgj7MA3GkAAyuomgIAApAAolI5liBC5kAIKWVZIjmXIzliAAQlZNl2Y5aJGf+9QMKgyDULwFnWbZ9kOTZUV+Z5TI+dFtkSMlflyHFvkxWI6XQm5+XxfZchyHltmeSElAiBCYi4KQlDQlIaIiJQzigu1oK6BIpDQnIuA4fhhFfAAYpw1DUCRNYHHWCqUVNVTUbxtH0QUmSkcsKyJJQnQAGrrAwqxbZ0ABK9g3KKoA7aRa70o2koAjtJKkTNDaRv07EIJxhmjeNU6Pa61IwZ6-3LSGkZLp+EqVgM81aqYca3Wq-Sepk5IbiKoFsYNXHEdWpbTeRCBzfjA7jEWa2wzdF0o2jJwwyTiyHQI21cntB2bczJ1nbcSOXdd8NdrayMPU9sMvYqLbzFjHFDTxwMzqdWBgy2EO2qr6OCc8oHfoQ-wfV9xFwaQGRbHWDA61sIZAA

参考記事

https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object/58436959#58436959
https://scrapbox.io/teamlab-frontend/TypeScriptの型でString.splitっぽいことをする#5f518cef2375fb0000d4e29c

Discussion