ことの発端
思えば当たり前なのだが、久々に小一時間ハマった。(実際は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】になる。