LoginSignup
60
40

More than 3 years have passed since last update.

TypeScript: index signatureを持つインターフェイスにkeyofして、既知のキーだけを取り出したい

Last updated at Posted at 2020-05-14

この投稿では、TypeScriptの次のような問題と理想とその理想を実現する手法を紹介します。

  • 問題点: keyofをインデックスシグネチャを持つインターフェイスに対して使うと、string | numberになってしまう。
  • 理想: string | numberじゃなくて、インデックスシグネチャ以外の既知キーを知りたい。

keyofは良い :yum:

TypeScriptにはkeyofというものがあり、インターフェイスが持つキーを調べることができます:

interface Foo {
    a: number
    b: number
    c: number
}

type FooKeys = keyof Foo
//=> "a" | "b" | "c"

オブジェクトのキーを値で扱うときに、タイポに気づきやすく安全になったり、コード補完も効いたりして便利です:

let fooKeys: keyof Foo
fooKeys = 'a' // OK
fooKeys = 'b' // OK
fooKeys = 'c' // OK
fooKeys = 'd' // Error: dなんて無いよと教えてもらえる!

keyofをインデックスシグネチャに使うと良さが半減 :cry:

ところが、インデックスシグネチャを持つインターフェイスに対して、keyofをすると、string | numberのようなざっくりとした型になってしまうので、:

interface Bar {
    a: number
    b: number
    c: number
    [key: string]: any // インデックスシグネチャ
    [key: number]: any // インデックスシグネチャ
}

type BarKeys = keyof Bar 
//=> string | number

前述したような安心感が半減します:

let barKeys: keyof Bar //=> string | number
barKeys = 'a' // OK
barKeys = 'b' // OK
barKeys = 'c' // OK
barKeys = 'd' // OK (´・ω・`)
barKeys = 1 // OK (´・ω・`)

Barインターフェイスは「キーはstringか、numberだったら何でもいいよ」と定義しています。

なので、上のコードのように未知のキーを扱えるのは、論理的には正しいです。

既知のキーだけ取り出すKnownKeyOf<T>を作る :innocent:

とは言っても、実用上、既知のキーだけを知りたいことがあります。

理想的にはこうしたい:

let barKnownKeys: KnownKeyOf<Bar> //=> "a" | "b" | "c"
barKnownKeys = 'a' // OK
barKnownKeys = 'b' // OK
barKnownKeys = 'c' // OK
barKnownKeys = 'd' // Error: dなんて知らないよと教えてもらえる 😊
barKnownKeys = 1 // Error: 1なんて知らないよと教えてもらえる 😊

これを叶えてくれるKnownKeyOf<T>は次のような実装になります:

type KnownKeyOf<T> = {
  [K in keyof T]: string extends K ? never : 
                  number extends K ? never : K
} extends { [_ in keyof T]: infer X }
  ? X
  : never

KnownKeyOf<T>の解説

ちょっと込み入ったコードになるので説明します。

KnownKeyOf<T>は2つのパートに分けられます。

1つ目はこの部分:

{
  [K in keyof T]: string extends K ? never : 
                  number extends K ? never : K
}

これは、Tインターフェイスを、{ キー名: 値の型 }から{ キー名: キー名 }に変換する部分です。ただ、「値の型」から「キー名」に変換するとき、細工があって、「キー名」がリテラル型じゃないのときだけ、{ キー名: never }にするようになっています。つまり、インデックスシグネチャのキーだけneverで目印をつけているわけです。例えば、Barをこの部分に当てはめると、次のような構造のインターフェイスが導出されます:

type Part1 = {
  [K in keyof Bar]: string extends K ? never : 
                    number extends K ? never : K
}
// ↓
type Part1 = {
    [x: string]: never; // インデックスシグネチャだけ
    [x: number]: never; // neverになる!
    a: "a";
    b: "b";
    c: "c";
}

Part1に続く、2つ目のパートはここ:

Part1 extends { [_ in keyof T]: infer X }
  ? X
  : never

これは、Part1neverの目印をつけたキーを除去する部分です。infer Xは、{ キー名①: キー名② | never }の「キー名②」部分をXに代入します。この代入の段階で、neverが自動的にはじかれ、既知のキー名だけに絞り込まれます。

備考

複雑なインターフェイスでも期待通り動くことは確認済みです:

type Complex = {
    a: number
    readonly b: string
    readonly c?: boolean
    d: number | string
    [key: string]: any
}

type KnownKeyOfComplex = KnownKeyOf<Complex>
//=> type KnownKeyOfComplex = "a" | "b" | "c" | "d"
type ComputedKeys = {
    [key in 'a' | 'b']: string
} & {
    [key in 1 | 2 ]: string
} & {
    [key: string]: string
    [key: number]: string
}
type KnownKeyOfComputedKeys = KnownKeyOf<ComputedKeys>
//=> type KnownKeyOfComputedKeys = "a" | "b" | 1 | 2
60
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
60
40