Object.create(null)

TypeError: Cannot convert object to primitive value

Template String Types でパス文字列を解析してクエリする

※この記事に含まれる内容は TypeScript 4.1 のプレビュー版のものです. 今後仕様が変わり動かなくなる可能性もありますのでご注意ください.

話題の template string types で早速遊んでみます.

ゴール

.foo[1].bar といった形のパス文字列を型レベルで解析してクエリしちゃいます. こういう感じ:

type R1 = Query<{ foo: number }, "">;                                 // R1 = { foo: number }
type R2 = Query<{ foo: number }, ".foo">;                             // R2 = number
type R3 = Query<[number, string, boolean], "[1]">;                    // R3 = string
type R4 = Query<{ foo: { bar: string } }, ".foo.bar">;                // R4 = string
type R5 = Query<[[number, string], [boolean, undefined]], "[1][0]">;  // R5 = boolean
type R6 = Query<[{ foo: [[{ bar: string }]] }], "[0].foo[0][0].bar">; // R6 = string

type RE = Query<{ foo: number }, "[">;                                // RE = never
type RU = Query<{ foo: number }, ".bar">;                             // RU = unknown

.foo.bar のようなドットつなぎのみのパスについては PR でも挙げられていますが, [1] のようなパスにも同時に対応するにはどうしたらよいか, という話.

やっていき

コード全体はここから試せます.

まずはキーによる参照を定義しておきます. わざわざこれを定義するのはキーが存在しなかった場合の扱いを一括で決めておくため. Subtyping のことを考えると undefined ではなく unknown が妥当でしょう.

type Access<T, Key extends string> = Key extends keyof T ? T[Key] : unknown;

続いてクエリ実行部分本体です. ウワー.

type Sep = "." | "[" | "]";
type Query<T, Path extends string> =
    Path extends "" ? T
  : Path extends `.${infer Key}${Sep}${infer Rest}` ? QueryDot<T, Path, Key>
  : Path extends `[${infer Key}]${Sep}${infer Rest}` ? QueryBracket<T, Path, Key>
  : Path extends `.${infer Key}` ? Access<T, Key>
  : Path extends `[${infer Key}]` ? Access<T, Key>
  : never;
type QueryDot<T, Path extends string, Key extends string> =
  Key extends `${infer A}${Sep}${infer B}`
    ? never
    : Query<Access<T, Key>, Path extends `.${Key}${infer Rest}` ? Rest : never>;
type QueryBracket<T, Path extends string, Key extends string> =
  Key extends `${infer A}${Sep}${infer B}`
    ? never
    : Query<Access<T, Key>, Path extends `[${Key}]${infer Rest}` ? Rest : never>;

順番に見ていきましょう. まずは Sep はパス中の区切り文字を定義しています.

type Sep = "." | "[" | "]";

続いて Query. 一行目は空文字列ならそのまま T を返すだけ.

type Query<T, Path extends string> =
    Path extends "" ? T
  : ...;

後ろの三行もまあ簡単で, .foo[1] のようなパスを見てアクセスするだけです.

type Query<T, Path extends string> =
    ...
  : Path extends `.${infer Key}` ? Access<T, Key>
  : Path extends `[${infer Key}]` ? Access<T, Key>
  : never;

問題は真ん中の二つ. まずは .foo のあとにさらにパスが続く場合の処理を見てみましょう.

type Query<T, Path extends string> =
    ...
  : Path extends `.${infer Key}${Sep}${infer Rest}` ? QueryDot<T, Path, Key>
  : ...;

もしここに Path として .foo[1].bar が入ってきた場合, Key"foo" | "foo[1" | "foo[1]" となります. Sep が三種類あるからですね. 一方でこれらの Key のうち正しいもの (最短でマッチしているもの) は "foo" です. ではこれをどう取り出しているかというのが QueryDot の部分.

type QueryDot<T, Path extends string, Key extends string> =
  Key extends `${infer A}${Sep}${infer B}`
    ? never
    : Query<Access<T, Key>, Path extends `.${Key}${infer Rest}` ? Rest : never>;

まず conditional type で union type になっている Key を分解しつつ, Key の中に Sep が含まれるような場合は never を返すことで除いています. そして Sep が含まれない場合は Key を使ってアクセスしつつ, 残りを Path から抜き出して再度 Query に渡しています. 簡単ですね.

[1] のあとにさらにパスが続く場合もほとんど同じ. (追記: これ ] で区切られているのでわざわざこんなことしなくてもよいのでは?)

type Query<T, Path extends string> =
    ...
  : Path extends `[${infer Key}]${Sep}${infer Rest}` ? QueryBracket<T, Path, Key>
  : ...;
type QueryBracket<T, Path extends string, Key extends string> =
  Key extends `${infer A}${Sep}${infer B}`
    ? never
    : Query<Access<T, Key>, Path extends `[${Key}]${infer Rest}` ? Rest : never>;

これで完成.

type R1 = Query<{ foo: number }, "">;                                 // R1 = { foo: number }
type R2 = Query<{ foo: number }, ".foo">;                             // R2 = number
type R3 = Query<[number, string, boolean], "[1]">;                    // R3 = string
type R4 = Query<{ foo: { bar: string } }, ".foo.bar">;                // R4 = string
type R5 = Query<[[number, string], [boolean, undefined]], "[1][0]">;  // R5 = boolean
type R6 = Query<[{ foo: [[{ bar: string }]] }], "[0].foo[0][0].bar">; // R6 = string

type RE = Query<{ foo: number }, "[">;                                // RE = never
type RU = Query<{ foo: number }, ".bar">;                             // RU = unknown

はい.