以下の makeGetter
関数に型をつけることを考える。
const makeGetter = (obj) => {
return (key) => obj[key];
};
ただし、makeGetter
が返す関数の型は、 obj
の型に応じてオーバーロードされた状態になっていてほしい。つまり
type Author = {
name: string;
age: number;
};
declare const author: Author;
const getter = makeGetter(author);
const v1 = getter("name"); // const v1: string
const v2 = getter("age"); // const v2: number
const v3 = getter("email"); // error!!
// ^^^^^^^ No overload matches this call.
方針として、 makeGetter
は次のような形で型をつけることにする。
const makeGetter = <T extends object>(obj: T): Getter<T> => {
return ((key: keyof T) => obj[key]) as Getter<T>;
};
ここで Getter<T>
はこれから定義する型だが、次のような複数のシグネチャをオーバーロードした関数型を生成できればよい。
type G = Getter<Author>;
// type G = {
// (key: "name"): string;
// (key: "age"): number;
// };
このような Getter<T>
を定義するのがゴールとなる。
ゴールを観察すると T
(例では Author
)の各プロパティに対して 1 つのシグネチャが対応するから、まず mapped type を使ってプロパティごとに対応するシグネチャを作る。
type Temp1<T extends object> =
{
[P in keyof T]: (key: P) => T[P]
};
type Temp1_Author = Temp1<Author>;
// type Temp1_Author = {
// name: (key: "name") => string;
// age: (key: "age") => number;
// }
プロパティ値だけを取り出すと
type Temp2<T extends object> =
{
[P in keyof T]: (key: P) => T[P]
}[
keyof T
];
type Temp2_Author = Temp2<Author>;
// type Temp2_Author = ((key: "name") => string) | ((key: "age") => number)
この Temp2
で得られる型は、関数型の union の形になっており、欲しかったオーバーロードにはなっていない。
declare const temp2: Temp2_Author;
const v4 = temp2("name");
// ^^^^^^ Argument of type 'string' is not assignable to parameter of type 'never'.
そこで巷で union to intersection と呼ばれているテクニックを使って union を intersection に変換してやる。
type UnionToIntersection<U> =
(U extends U ? (u: U) => unknown : never) extends (i: infer I) => unknown ? I : never;
type Getter1<T extends object> =
UnionToIntersection<
{
[P in keyof T]: (key: P) => T[P]
}[
keyof T
]
>;
これでうまくいく。
declare const getter1: Getter1<Author>;
// const getter1: ((key: "name") => string) & ((key: "age") => number)
const v5 = getter1("name"); // const v5: string
const v6 = getter1("age"); // const v6: number
const v7 = getter1("email"); // error!!
// ^^^^^^^ No overload matches this call.
しかし、optional なプロパティがあると動かなくなる。
type Reputation = {
source?: string;
value: number;
};
declare const getter1_reputation: Getter1<Reputation>;
// const getter1_reputation: never
const v8 = getter1_reputation("source");
// ^^^^^^^^^^^^^^^^^^ This expression is not callable. Type 'never' has no call signatures.
ので、これに対処するため mapped type のところで optional を解除する。
type Getter2<T extends object> =
UnionToIntersection<
{
[P in keyof T]-?: (key: P) => T[P]
}[
keyof T
]
>;
declare const getter2_reputation: Getter2<Reputation>;
const v9 = getter2_reputation("source"); // const v9: string | undefined
Author
や Reputation
のような“ふつうの”オブジェクト型ならこれでうまくいく。だが、配列型を入れてみると、なんだかおかしなことになってしまう。
declare const getter2_array: Getter2<bigint[]>;
// const getter2_array: number & ((key: number) => bigint) & (() => IterableIterator<(key: number) => bigint>) & (() => {
// copyWithin: boolean;
// entries: boolean;
// fill: boolean;
// find: boolean;
// findIndex: boolean;
// keys: boolean;
// values: boolean;
// }) & ... 28 more ... & ((searchElement: (key: number) => bigint, fromIndex?: number | undefined) => boolean)
const v10 = getter2_array(0); // const v10: bigint ←これはよい
const v11 = getter2_array(); // const v11: IterableIterator<(key: number) => bigint> ←???
const v12 = getter2_array("0"); // const v12: string ←?????
この原因は、根本的には homomorphic mapped type では in keyof T
の T
が配列型やタプル型の場合にふつうのオブジェクトとは挙動が変わることにある。
(T
がプリミティブ型の場合も特殊な挙動をするが、今回は T extends object
の制約があるので気にしない。)
T
が配列型かタプル型のときは index を number
型に制限して配列のメソッドを除くことで問題を回避できる。
type Getter3<T extends object> =
UnionToIntersection<
{
[P in keyof T]-?: (key: P) => T[P]
}[
T extends readonly unknown[] ? number & keyof T : keyof T
]
>;
declare const getter3_array: Getter3<bigint[]>;
// const getter3_array: (key: number) => bigint
const v13 = getter3_array(0); // const v13: bigint
const v14 = getter3_array(); // error!!
// ^^^^^^^^^^^^^^^ Expected 1 arguments, but got 0.
const v15 = getter3_array("0"); // error!!
// ^^^ Argument of type 'string' is not assignable to parameter of type 'number'.
タプル型でどうなるか試してみる。
declare const getter3_tuple: Getter3<[string, boolean?]>;
// const getter3_tuple: ((key: "0") => string) & ((key: "1") => boolean | undefined)
const v16 = getter3_tuple(0);
// ^ No overload matches this call.
const v17 = getter3_tuple("0"); // const v17: string
getter3_tuple
のパラメータ型が文字列化されており 0
のような数値を渡せなくなっている。
配列型の場合と揃っていないのは変なので、数値っぽいプロパティ名には数値の型でもアクセスできるようにする。
type StringToNumber<P> =
P extends `${infer N extends number}` ? `${N}` extends P ? N : never : never;
type Getter<T extends object> =
UnionToIntersection<
{
[P in keyof T]-?: (key: P | StringToNumber<P>) => T[P]
}[
T extends readonly unknown[] ? number & keyof T : keyof T
]
>;
これでとりあえずできあがり。
declare const getter_tuple: Getter<[string, boolean?]>;
// const getter_tuple: ((key: 0 | "0") => string) & ((key: 1 | "1") => boolean | undefined)
const v18 = getter_tuple(0); // const v18: string
const v19 = getter_tuple(1); // const v19: boolean | undefined
// 文字列もOK
const v20 = getter_tuple("0"); // const v20: string
配列型や通常のオブジェクト型で問題が出ていないことを確認する。
declare const getter_array: Getter<bigint[]>;
// const getter_array: (key: number) => bigint
const v21 = getter_array(0); // const v21: bigint
declare const getter_reputation: Getter<Reputation>;
// const getter_reputation: ((key: "source") => string | undefined) & ((key: "value") => number)
const v22 = getter_reputation("source"); // const v22: string | undefined
const v23 = getter_reputation("value"); // const v23: number
実際に、最初に型を付けた makeGetter
が安全に使えることを確かめる。
const getter_temp1 = makeGetter({ text: "歯を磨く", priority: 30 });
const v24 = getter_temp1("text"); // const v24: string
const v25 = getter_temp1("deadline"); // error!!
// ^^^^^^^^^^ No overload matches this call.
const getter_temp2 = makeGetter({ red: "#FF0000", blue: "#0000FF" } as const);
const v26 = getter_temp2("red"); // const v26: "#FF0000"
const getter_temp3 = makeGetter([1, 1, 2, 3, 5, 8, 13, 21, 34, 55]);
const v27 = getter_temp3(1); // const v27: number
const v28 = getter_temp3("reverse"); // error!!
// ^^^^^^^^^ Argument of type 'string' is not assignable to parameter of type 'number'.
const getter_temp4 = makeGetter([0, 255, 0] as const);
const v29 = getter_temp4(1); // const v29: 255
const v30 = getter_temp4(3); // error!!
// ^ No overload matches this call.
declare const getter_union: Getter<Author | Reputation>;
// const getter_union: unknown
const v31 = getter_union("name");
// ^^^^^^^^^^^^ Object is of type 'unknown'.
getter_union
の型が unknown
型になるのは適切だろうか? 適切でないならば、どういう型が適切だろうか? Getter
をどう修正すればよいだろうか?
どう deep にするかはいくつかバリエーションがあると思う。どれか 1 つを選んで実装してみよう
type Book = {
title: string;
author: Author;
reputations: Reputation[];
};
declare const book: Book;
const deepGetter = makeDeepGetter(book);
// バリエーション1
const v32 = deepGetter("author", "name"); // const v32: string
const v33 = deepGetter("reputations", 0, "source"); // const v33: string | undefined
// バリエーション2
const v34 = deepGetter(["author", "name"]); // const v34: string
const v35 = deepGetter(["reputations", 0, "source"]); // const v35: string | undefined
// バリエーション3
const v36 = deepGetter("author.name"); // const v36: string
const v37 = deepGetter("reputations.0.source"); // const v37: string | undefined
// もしくは deepGetter(`reputations.${0}.source`);
// あるいは deepGetter("reputations[0].source"); など
// バリエーション4
const v38 = deepGetter("author")("name"); // const v38: string
const v39 = deepGetter("reputations")(0)("source"); // const v39: string | undefined
const v40 = getter_array("length");
// ^^^^^^^^ Argument of type 'string' is not assignable to parameter of type 'number'.
const v41 = getter_tuple("length");
// ^^^^^^^^ No overload matches this call.
配列型やタプル型の length
を getter で取得できるようにするにはどうしたらよいだろうか?
ふと気づいちゃったけどこれ
で終わりじゃないか()