ことの発端
思えば当たり前なのだが、久々に小一時間ハマった。(実際は3時間)
最近歳のせいか、鈍った自分へ喝をいれるため投稿。
具体例
function test<T, K extends keyof T>(
data: T,
key: T[K] extends string ? K : never,
) {
const x: string = data[key]; // error: 残念(泣)
}
interface X {
s: string;
n: number;
}
const x: X = {s: 'string', n: 1};
test(x, 's');
test(x, 'n'); // error: 期待通り
説明
-
test()
は第1引数にオブジェクトT
、第2引数にオブジェクトのプロパティK
を渡すことを想定 - オブジェクトのプロパティ
T[K]
の 【 値 】 はstring
のみにしたいことを表明するためkey: T[K] extends string ? K : never
として制約 -
test(x, 'n')
がエラーとなりハッピー - ところが、
test()
内でdata[key]
がstring
として認識しない
Type 'T[T[K] extends string ? K : never]' is not assignable to type 'string'.
Type 'T[K]' is not assignable to type 'string'.
Type 'T[keyof T]' is not assignable to type 'string'.
Type 'T[string] | T[number] | T[symbol]' is not assignable to type 'string'.
Type 'T[string]' is not assignable to type 'string'.ts(2322)
つまり
呼び出し時は test()
の第2引数を制約することはできるけど、制約された data
と key
を使って得られる値には制約が及ばない。
ここで as
を使いたくないのであれば、実行コストがかかるが typeof
を使わないといけない。
【対応1】asに負ける
function test<T, K extends keyof T>(
data: T,
key: T[K] extends string ? K : never,
) {
const x: string = data[key] as string;
}
【対応2】asに屈しない
function test<T, K extends keyof T>(
data: T,
key: T[K] extends string ? K : never,
) {
const x = data[key];
if(typeof x !== 'string') {
throw new Error('Invalid type');
}
// x は string 型
}
しっかり型管理されているのであれば 【対応1】
で良いと思う。しかしながら、型管理されているということは極論 as
なんか使わずに型を伝搬し続けることを意味するので、コード上に as
が登場するのは異常事態1であるので、相当なコメントを書くなどして、この as
の正当性をしっかりと説明する必要がある2。
また、データが外部から与えられた動的なものの可能性がある場合、パフォーマンスを考えるより堅牢性を考え 【対応2】
になる。