【TypeScript】オブジェクトの型を Generics で動的に決める
はじめに
こんにちは、FarStep です。
TypeScript を書いている皆さん、Generics 使っていますか?
今回は、Generics を使った型定義の実践的な例をご紹介します。
本記事は、10 分以内で読み終えることができます。
Generics(ジェネリクス)とは
まずはじめに、Generics(ジェネリクス)とは何でしょうか。
Generics は、型決定を遅延することができる 仕組みです。
これだけでは、あまりにも簡単な説明ですので、一つ例を示しましょう。
interface Identity<T> {
value: T
}
Identity 型を定義しました。
Generics を使っているため、value の型は、後で決定することができます。
また、Generics を利用する際、下記の四つが慣例的によく使われますので、覚えておきましょう。
- T:Type
- K:Key
- U:Unknown
- E:Element
Identity 型を適用する際には、下記のように記述します。
interface Identity<T> {
value: T
}
const identityStr: Identity<string> = { value: "myString" }
const identityNum: Identity<number> = { value: 123 }
Identity<string> とすると、value の型は string 型になります。
また、Identity<number> とすると、value の型は number 型になります。
したがって、Generics を使って指定した型以外の値を value にセットするとエラーが吐かれます。
interface Identity<T> {
value: T
}
const identityNum: Identity<number> = { value: "myNumber" }
// Type 'string' is not assignable to type 'number'.
Generics がどんなものかについて、理解できたでしょうか。
上記のコードの通り、Generics は 型における変数のような機能 を果たしていますね。
Generics を使ってオブジェクトの型を動的に決める
それでは、Generics を使った実践的な例をご紹介します。
実務でも使う場面があるかと思います。
想定する場面
今回は、オブジェクトの値を取り出す関数の引数「全て」に型をつけたい という場面を想定します。
TypeScript を書いている中で、オブジェクトの操作する際、型がつかなくなるときがよくありますよね。これからこの問題を解決していきます。
まずは、想定する場面をコードに落とし込みます。
const obj = {
userA: {
email: "farstep@example.com",
verified: true,
age: 23,
},
userB: {
email: "stepfar@example.com",
verified: false,
},
}
const extractValue = (obj, first, second) => {
return obj[first][second]
}
ユーザ情報が格納されたオブジェクト(obj)があります。
ただし、userA と userB とでは、存在するプロパティが異なっています。
userA には、age というプロパティがありますが、userB にはありませんね。
そして、extractValue 関数は、
- オブジェクト
- 一つ目のキー
- 二つ目のキー
という3つの引数を受け取り、指定されたキーの値を返します。
しかし、現在のコードでは、引数の型を指定していないため、下記のようなエラーが吐かれます。
const extractValue = (obj, first, second) => {
return obj[first][second]
}
// Parameter 'obj' implicitly has an 'any' type.
// Parameter 'first' implicitly has an 'any' type.
// Parameter 'second' implicitly has an 'any' type.
引数の全ての値が any 型になってしまっています。
any 型使うと、型チェックが弱くなりバグを発見できなくなる恐れがあります。一般的に、TypeScript を書くのであれば、any 型は避けるべきです。
引数の型が指定されていないと、下記のようなコードを記述してしまうかもしれません。
const obj = {
userA: {
email: "farstep@example.com",
verified: true,
age: 23,
},
userB: {
email: "stepfar@example.com",
verified: false,
},
};
const extractValue = (obj, first, second) => {
return obj[first][second];
};
const value = extractValue(obj, "userB", "age");
userB には、age というプロパティが無いのにも関わらず extractValue(obj, "userB", "age") に対してエラーは吐かれません。
一つ目のキーで指定した値によって、二つ目のキーに型をつけることはできないのでしょうか 🤔
しかし、一つ目のキーで指定した値によって、型が変わってしまうのです... 😱
この問題を解決するのが、Generics です。
Generics の使い方
Generics を使うことで extractValue 関数の引数である二つ目のキー(second)の型を、一つ目のキー(first)によって動的に変えることができます。
下記のように Generics を使います。
const obj = {
userA: {
email: "farstep@example.com",
verified: true,
age: 23,
},
userB: {
email: "stepfar@example.com",
verified: false,
},
};
const extractValue = <
TObj,
TFirst extends keyof TObj,
TSecond extends keyof TObj[TFirst]
>(
obj: TObj,
first: TFirst,
second: TSecond
) => {
return obj[first][second];
};
const value = extractValue(obj, "userA", "age");
extractValue 関数の中身を詳しく見てみます。
extractValue 関数の第一引数である obj は TObj 型 となっています。このようにすることで、どんな型のオブジェクトも受け付けることができます。
そして、extractValue 関数の第二引数である first は TFirst 型 となっています。TFirst 型は、TFirst extends keyof TObj と定義されているため、第一引数で受け取ったオブジェクトのキーのみ に制限されます。つまり、今回の例ですと、first には、"userA" もしくは "userB" のみを指定することができます。
最後に、extractValue 関数の第三引数である second は TSecond 型 となっています。TSecond 型は、TSecond extends keyof TObj[TFirst] と定義されているため、第二引数で受け取ったキーの値のキーのみ に制限されます。つまり、今回の例ですと、第一引数 first が、"userA" だった場合、第二引数で受け取ることができるのは、"email" と "verified" と "age" です。しかし、第一引数 first が "userB" だった場合、第二引数で受け取ることができるのは、"email" と "verified" のみとなります。
ちなみに、keyof はオブジェクト型からプロパティ名を型として返す型演算子です。
このように Generics を使えば extractValue 関数の引数の型を動的に決定することができるのです。例えば、下記のようなコードを記述するとエラーが吐かれるようになります。
const value = extractValue(obj, "userB", "age");
// Argument of type '"age"' is not assignable to parameter of type '"email" | "verified"'
extractValue 関数の第二引数において "userB" を渡したため、Generics により、second の型は obj["userB"] のキーのみとなりました。なぜなら、second は TSecond extends keyof TObj[TFirst] と指定されているからです。
Generics を使うことで、関数の第一引数と第二引数の値に補完が効くようになります。
第一引数には、"userA" もしくは "userB" しか指定することができません。

第一引数に "userA" を渡した場合、第二引数の補完の候補は三つです。

第二引数に "userB" を渡した場合、第二引数の補完の候補は二つです。

さらに嬉しいことに、extractValue 関数で返される値(value)も型推論が有効になっているという点です。

上記のように、オブジェクトの型を Generics で動的に決定することができました 🎉
存在しないプロパティを指定した場合には、コンパイルエラーが吐かれるようになったため、よりバグの少ないコードを書くことができます。
おわりに
いかがだったでしょうか。
Generics を使うことで 動的に オブジェクトの型を定義することができます。
動的に型を定義できる とはすなわち、型の共通化 に成功しているということです。
通常、コードの共通化すると型の安全性が弱まり、型の安全性を高めるとコードの共通化が難しくなるのですが、Generics は見事にこの問題を解決します。
是非、Generics を上手に使ってみてください。
参考文献
今回使用したコードは、下記 URL に残しておきます。
Discussion