TypeScript ではレコードにも連想配列にも object が使われがち。レコードのために object ではなく Map を使いつつ、object と同じような入力補完などの恩恵を受ける方法を考えた。実用的ではない。
Map とオブジェクトの比較は MDN に書いてある: Map - JavaScript | MDN
version: TypeScript 3.9
動機
クエリ (?
部分) をパースするとき、キーの集合は静的には限定されないので、結果は連想配列になる。しかし有効なキーの集合は本来限定されているので、レコードになってほしい。このギャップを埋めたかった。
レコードみたいな型がついた Map
次のような特定の組み合わせのキーからなる object をレコードと呼ぶことにする。(キーは動的に増えたり減ったりしない。これらのキーには常に値が設定されている。)
.
記法でプロパティを参照するとき、入力補完や型検査の恩恵を得られる。
type MyRecord = {
ok: boolean
status: number
}
declare const myRecord: MyRecord
const ok = myRecord.ok
// ^^ ok, status が補完される
// ^^ ok: boolean
Map の場合は、get, set などのキーに特定の文字列を受け取るオーバーロードを定義すれば、同様に入力補完などの恩恵を得られる。
type MyMap = {
get(key: "ok"): boolean // | undefined をつけてもよい
get(key: "status"): number
set(key: "ok", value: boolean): void
set(key: "status", value: number): void
}
declare const myMap: MyMap
const ok = myMap.get("ok")
// ^^^^ "ok", "status" が補完される
// ^^ ok: boolean
型定義はメタプログラミングを使えば短く書ける。
/**
* キーと値の型の対応がうまくついた get/set メソッドを持つ、Map 的なオブジェクトの型を作る型レベル演算子。
*
* USAGE: RecordMap<{ k: v, ... }>
**/
type RecordMap<T extends object> = {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
// has<K extends keyof T>(key: K): boolean etc.
}
// 上の myMap とだいたい同じ型になる。
type MyMap = RecordMap<{
ok: boolean
status: number
}>
インスタンスを作るには、Map リテラルはないので、代わりにオブジェクトリテラルから変換する。
const toMap = <T extends object>(record: T): RecordMap<T> =>
new Map<unknown, unknown>(Object.entries(record)) as RecordMap<T>
const myMap = toMap({
ok: true,
status: 200,
})
const ok = myMap.get("ok") // ちゃんと型がつく
オブジェクトのキーにならないものをキーに含めたいときは entries (キーと値のペアからなる配列) から作る。
/**
* entries の型からキーの型を取る。
*/
type EntriesToKeyType<E extends [unknown, unknown][]> =
E[number] extends infer TPair ? (
TPair extends [unknown, unknown] ? (
TPair[0]
) : never
) : never
/**
* entries からキーに対応する値の型を探す。
*/
type EntriesFindValueType<E extends [unknown, unknown][], K> =
E[number] extends infer TPair ? (
TPair extends [K, unknown] ? (
TPair[1]
) : never
) : never
/**
* entries の型からマップのようなものの型を作る。
*/
type EntriesToMapType<E extends [unknown, unknown][]> = {
get<K extends EntriesToKeyType<E>>(key: K): EntriesFindValueType<E, K>
set<K extends EntriesToKeyType<E>>(key: K, value: EntriesFindValueType<E, K>): void
}
const fromEntries = <E extends [unknown, unknown][]>(entries: E): EntriesToMapType<E> =>
new Map<unknown, unknown>(entries) as EntriesToMapType<E>
const map = fromEntries([
[fromEntries, 0],
])
const zero = map.get(fromEntries)
あるいは Map<unknown, unknown>
を動的に検査してから as
で強制的にキャストする。スマートキャストは効かなさそう。
const unknownMap = new Map<unknown, unknown>(JSON.parse(entriesJsonText))
const valid = typeof unknownMap.get("ok") === "boolean"
&& typeof unknownMap.get("status") === "number"
if (valid) {
const myMap = unknownMap as MyMap
}