🔃

TypeScriptで2つのタプル型からオブジェクトの型を作る

2022/11/14に公開約2,200字3件のコメント

とあるコミュニティ[1]で2つのタプルの型からオブジェクトを作れないかという質問があって色々やってたらそれっぽいのが出来上がったのでメモしておきます。[2]

課題

const FOO_COLUMNS = ["id", "name", "flag"] as const;
type FooColumnTypes = [number, string, boolean];

type FooTableRow = {
  id: number,
  name: string,
  flag: boolean
};

に変形したい

回答

const FOO_COLUMNS = ["id", "name", "flag"] as const;
type FooColumnTypes = [number, string, boolean];
type FooIndex = Exclude<keyof typeof FOO_COLUMNS, keyof unknown[]>;
type FooTableRow = {
  [P in FooIndex as typeof FOO_COLUMNS[P]]: FooColumnTypes[P];
};

で、これ何やってるの?

FOO_COLUMNSからインデックスの番号を抽出した上でそれぞれの要素にインデックスアクセスしてマッピングしています。

TypeScriptのタプルはそれぞれのインデックスの型と長さが指定されたArrayの型として表現されています。そこからインデックス部分だけを抽出できればMapped Typesでごにょごにょできるはずです。キーのUnionはkeyofで抜き出せるので後は型を抽出できればいいわけです。というわけでExtract<T, U>を使って書いてみたのですが、上手く行きません。

type FooIndex = Extract<keyof typeof FOO_COLUMNS, number>;

この例だとFooIndexはnumberになってしまいます。しかし推論の結果は番号で得られるので(エラーを起こすとわかる)何かしらの方法はあるはずと調べていたらTypeScriptのとあるissueに行き着きました。そこに書いてあったコメントによるとExclude<T, U>を使うと正しくリテラル型が得られるよとのことでした。という訳でこのように書き直してみました。

type FooIndex = Exclude<keyof typeof FOO_COLUMNS, keyof unknown[]>;

配列の型はlengthを含む配列由来のキーを全て持っていて、それをタプルから引き去るとインデックスのキーだけが残るという寸法です。これを試した所上手く行きました。後はMapped Typesで変形するだけです。

type FooTableRow = {
  [P in FooIndex as typeof FOO_COLUMNS[P]]: FooColumnTypes[P];
};

キーのremapの仕方がなかなか分からなくて(TypeScript4.1で増えた仕様らしいので仕方ないかも)調べるのに苦労しましたがKey Remappingのまとめを書いで下さっていた方がいて助かりました。

さいごに

一応目的は達成できそうなコードは書けましたが、私の拙いTypeScriptちからではジェネリクスにすることはできませんでした。(特定の長さを持つタプルのジェネリックな表現方法が分からなかった)
一方でこの話が出てきたコミュニティ、vim-jpでは再帰的な型定義を駆使しジェネリクスで動く物を作られた方々もいました。
私から見て素晴らしいと感じるエンジニアが多数所属しており、元々Vimのコミュニティではありますが中には関係なく参加されている方もいます。興味がある方は是非参加してみてください。

脚注
  1. テキストエディタのコミュニティのはずなんですが、あらゆる話題で盛り上がっていてJavaScriptに関するチャンネルも活発です ↩︎

  2. 原題はジェネリクスでやることだったのでこれはレギュレーション違反ですね ↩︎

GitHubで編集を提案

Discussion

再帰なしでジェネリクスにできた……ような気がします。

const FOO_COLUMNS = ["id", "name", "flag"] as const;
type FooColumnTypes = [number, string, boolean];

type TableRow<
  T extends readonly string[],
  U extends readonly unknown[]
> = {
  [
    K in Exclude<keyof T, keyof unknown[]> as
      K extends keyof T
        ? Extract<T[K], string>
        : never
  ]: K extends keyof U ? U[K] : never
};

type FooTableRow = TableRow<typeof FOO_COLUMNS, FooColumnTypes>;

お、ありがとうございます。読んでみます

見直したところ、一段階シンプルにできるようです。
ついでに型の名前も変更してみました。

const FOO_COLUMNS = ["id", "name", "flag"] as const;
type FooColumnTypes = [number, string, boolean];

type ZipObj<
  T extends readonly string[],
  U extends readonly unknown[]
> = {
  [
    K in Exclude<keyof T, keyof unknown[]> as Extract<T[K], string>
  ]: K extends keyof U ? U[K] : never
};

type FooTableRow = ZipObj<typeof FOO_COLUMNS, FooColumnTypes>;
ログインするとコメントできます