TypeScriptのinferを今度こそちゃんと理解する
infer 難しい
使う機会が少ないけど、個人的には React Query のキー管理とかでこういう使い方をすることが多い。
あまり積極的に infer
を使わないのは理解しきれていないからだと思うから、一個づつ分解して理解を深めたい
export const QUERY_KEYS = ["users", "post", "comments"] as const;
export type Unpacked<T> = T extends { [K in keyof T]: infer U } ? U : never;
export type QueryKeysTypes = Unpacked<typeof QUERY_KEYS>;
const アサーション
as const
これは const アサーションというもの。それを説明する前に前提として Widening Literal Types と NonWidening Literal Types を理解する必要がある。
下記のように const で変数を定義した場合、 hoge
の型は "HOGE"
になる。
const hoge = "HOGE";
// type hoge: "HOGE"
let sameHoge = hoge;
// type sameHoge: string
が、これを別の変数に代入すると sameHoge: string
になってしまっている。要するに変数を定義した場合暗黙的に Widening Literal Types になる。
そもそも const で型定義をすると Literal Types になるが、これには暗黙的に 2 種類存在していて、前述の Widening Literal Types と NonWidening Literal Types である。
string でかつ "HOGE" と限定的だった型が再代入によって string と拡大されてしまっていることがわかる。では逆に NonWidening Literal Types は何かというとその拡大をしない Literal Types のこと。
では NonWidening Literal Types にするにはどうすれば良いかというと、const アサーションを使うことで変数の宣言と同時に型の拡大を抑えることができる。
const hoge = "HOGE" as const;
let sameHoge = hoge;
// type sameHoge: "HOGE"
// また他のオブジェクトに代入をしてもNonWidening Literal Typesであることがわかる
const obj = {
hoge, // "HOGE"
};
obj.hoge = "fuga"; // おこ
const アサーションはもちろん配列やオブジェクトで適用可能でさらに readonly が付与される。それにより、配列への再代入ができなくなっている。
const hoge = ["HOGE"] as const;
// type hoge: readonly ["HOGE"]
hoge[1] = "huga"; // Tuple type 'readonly ["HOGE"]' of length '1' has no element at index '1'
readonly が付与され、再代入ができなくなるので、React プロジェクトでもっと積極的に使っていきたいと思う。
Conditional Types
Conditional Types は型定義における条件分岐のことで、三項演算子と同じように書くことができる。
わかりやすくいうと、T の型が〇〇だったらという条件を型に持ち込むことができる。
type IsNumber<T> = T extends number ? true : false;
type Hoge = IsNumber<"a">; // false
type Fuga = IsNumber<1>; // true
type Foo = IsNumber<boolean>; // false
keyof と組み合わせるとこんなこともできる
オブジェクトにある値を取り出す関数で渡すオブジェクトにプロパティがあれば値を返し、なければ type エラーになる。
これによりオブジェクトに応じて柔軟に対応できるし、何よりタイポを防ぐことができる。
const getValue = <T, U extends keyof T>(value: T, key: U): T[U] => {
return value[key];
};
const dog = {
name: "Taro",
age: 10,
};
const cat = {
name: "Jiro",
age: 3,
type: "hoge",
};
getValue(dog, "name");
// function getValue<{
// name: string;
// age: number;
// }, "name">(value: {
// name: string;
// age: number;
// }, key: "name"): string
getValue(dog, "type"); // Argument of type '"type"' is not assignable to parameter of type '"name" | "age"'.
getValue(cat, "type");
// function getValue<{
// name: string;
// age: number;
// type: string;
// }, "type">(value: {
// name: string;
// age: number;
// type: string;
// }, key: "type"): string
infer
直訳すると推論という意味で、要するに型を割り出すことができる。
例えば T が id というプロパティを持っている場合は、その id の型を返す。
もし、id というプロパティがなければ never を返す。
type Id<T> = T extends { id: infer U } ? U : never;
また、infer を使える場所は conditional types の extends の条件部分に限定するため、使い方としては conditional types と組み合わせて使うことになる。
元のコードを紐解く
では、最初のコードを紐解いていく。
export const QUERY_KEYS = ["users", "post", "comments"] as const;
export type Unpacked<T> = T extends { [K in keyof T]: infer U } ? U : never;
export type QueryKeysTypes = Unpacked<typeof QUERY_KEYS>;
まず、配列を const アサーションで NonWidening Literal Types にしている。
そうすることで QUERY_KEYS: readonly ["users", "post", "comments"]
となる。
export const QUERY_KEYS = ["users", "post", "comments"] as const;
もし仮に const アサーションをしていない場合はただの string[]
になってしまう。
export const QUERY_KEYS = ["users", "post", "comments"];
// QUERY_KEYS: string[]
次に Unpacked
は何をしているかみてみる。
全体像としては T の [K in keyof T]
プロパティを持っているのならその型を返すということになる。
export type Unpacked<T> = T extends { [K in keyof T]: infer U } ? U : never;
では今回の T とは何かというと ["users", "post", "comments"]
なので、
[K in keyof T]
[0]: なら infer U は "users"
[1]: なら infer U は "post"
[2]: なら infer U は "comments"
ってことなる。また、never は「存在し得ない型」だが、今回のコードの特性上、[K in keyof T]が存在しないことはないので never に入ることはない。
最後に下記で、変数 QUERY_KEYS の型から配列それぞれの型を取り出している。
export type QueryKeysTypes = Unpacked<typeof QUERY_KEYS>;
なので、QueryKeysTypes の型としては下記のようになる
type QueryKeysTypes = "users" | "post" | "comments";
まとめ
infer を使うことで若干まわりくどい型定義になってしまうが、実コードで使える or 使っている配列から型定義できるのは良い。配列の作成と型の作成を同時に行える感覚。
大分理解ができたので、業務でも積極的に使っていきたい。
Discussion