💨
Mapをある程度型安全に拡張してみる
TL;DR
- Map で
has()
した直後にget()
しても、TS は「undefined | V」のままにする(型が狭まらない)。 -
has()
をタイプガード化する(限界はあるけどね)
Map
以下のような Map があるとします。
const map = new Map<string, number>([
['a', 1],
['b', 2],
]);
この時、map にa
がキーがあることを確かめるために has()
を使うことができます。
if (map.has('a')) {
const value = map.get('a');
if (value !== undefined) {
console.log(value);
}
}
if で null チェックをしているので、value の型は number
になると思いきや、undefined | number
になります。
そのため undefined を取り除こうとしたら、再度 if でチェックする必要があるので、冗長です。
その回避手段として。has は使わずにいきなり get して、null チェックする方法もありますね。
const value = map.get('a');
if (value !== undefined) {
console.log(value);
}
ただ、そもそも value に undefined があり得る設計だと区別がつかない。
そこで、もう 1 つの手段として、次のようにhas()
をタイプガード化する方法を考えてみます。
interface Map<K, V> {
has<Key extends K>(key: Key): this is { get(key: Key): V } & this;
}
この Map を使用すると、次のようになります。
if (map.has('a')) {
const value = map.get('a'); // number
const value2 = map.get('c'); // 存在しないキーなのでnumber | undefined
}
しかし、Key が リテラル型ではなく、string のような広い型になるとうまくいきません。
const a: string = 'a';
if (map.has(a)) {
const value = map.get(a); // number
// cは存在しないのだから本当はstring | undefined になってほしい
}
Key が string のような広い型になると、
「get(key: string): V
」という強すぎるオーバーロードが足されてしまい、全 string キーが存在するかのように見えてしまいます。
そこでユーティリティの isWide 型を定義して、リテラル型の時だけタイプガードを適用するようにしてみます。
type IsWide<T> = [string] extends [T]
? T extends string
? true
: false
: false;
type NonWide<T> = IsWide<T> extends true ? never : T;
interface Map<K, V> {
has<Key extends K>(
key: NonWide<Key> & Key
): this is { get(key: Key): V } & this;
has(key: K): boolean;
}
if (m.has('a')) {
const v = m.get('a'); // number
}
const c: string = 'c';
if (m.has(c)) {
const v = m.get(c);
// v: string | undefined ← string型では狭まらない
}
けど、これでもまだ完全ではありません。
例えばテンプレートリテラルは上手く機能しません。
type literal = `user:${string}`;
const map = new Map<literal, string>([['user:1', 'Alice']]);
const u: literal = 'user:1';
if (map.has(u)) {
const g = map.get('user:999');
// string になってしまう
}
まとめ
Map の型安全性を高めるために、has()
をタイプガード化するアプローチがあります。
しかし、不完全なため拡張せず Map を使った方がいいかもしれません。
Discussion