📖

【TypeScript】オブジェクトの型を Generics で動的に決める

2022/11/28に公開

はじめに

こんにちは、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)があります。
ただし、userAuserB とでは、存在するプロパティが異なっています。
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 関数の第一引数である objTObj 型 となっています。このようにすることで、どんな型のオブジェクトも受け付けることができます。

そして、extractValue 関数の第二引数である firstTFirst 型 となっています。TFirst 型は、TFirst extends keyof TObj と定義されているため、第一引数で受け取ったオブジェクトのキーのみ に制限されます。つまり、今回の例ですと、first には、"userA" もしくは "userB" のみを指定することができます。

最後に、extractValue 関数の第三引数である secondTSecond 型 となっています。TSecond 型は、TSecond extends keyof TObj[TFirst] と定義されているため、第二引数で受け取ったキーの値のキーのみ に制限されます。つまり、今回の例ですと、第一引数 first が、"userA" だった場合、第二引数で受け取ることができるのは、"email""verified""age" です。しかし、第一引数 first"userB" だった場合、第二引数で受け取ることができるのは、"email""verified" のみとなります。

ちなみに、keyof はオブジェクト型からプロパティ名を型として返す型演算子です。
https://www.typescriptlang.org/docs/handbook/2/keyof-types.html

このように 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"] のキーのみとなりました。なぜなら、secondTSecond extends keyof TObj[TFirst] と指定されているからです。

Generics を使うことで、関数の第一引数と第二引数の値に補完が効くようになります。
第一引数には、"userA" もしくは "userB" しか指定することができません。

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

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

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

上記のように、オブジェクトの型を Generics で動的に決定することができました 🎉
存在しないプロパティを指定した場合には、コンパイルエラーが吐かれるようになったため、よりバグの少ないコードを書くことができます。

おわりに

いかがだったでしょうか。
Generics を使うことで 動的に オブジェクトの型を定義することができます。
動的に型を定義できる とはすなわち、型の共通化 に成功しているということです。
通常、コードの共通化すると型の安全性が弱まり、型の安全性を高めるとコードの共通化が難しくなるのですが、Generics は見事にこの問題を解決します。
是非、Generics を上手に使ってみてください。

参考文献

https://book.mynavi.jp/ec/products/detail/id=104703
https://www.typescriptlang.org/docs/handbook/2/generics.html
https://typescriptbook.jp/reference/generics
https://www.typescriptlang.org/docs/handbook/2/keyof-types.html

今回使用したコードは、下記 URL に残しておきます。
https://www.typescriptlang.org/play?#code/MYewdgzgLgBCBGArGBeGBvAUDGBXCApgE4CCAXBtjjAQLYCGAlgDYUBEAZvUdAQA4ABAgA96tPswIA6ULTYAaKjgBuxRh0YEAJhShFcBRdRj0A5gQoAmAMxGYAXzv5iAIQpZjdJqxhtefLiIhUXFJGRA5OxU1DW0KLmZCO0dMewBuTExQSFgRPXpgKAA1emYDVBgAHioAFQB5JDsagDFGHlzhKAIwLQgYAGsCAE8QDhh6xtqAZQJsrRpO7t6B4dHxhsQAbRa26ABdTAA+AAoqBEQKCcQ7DXbL1va7QjnLmbnMAEpUQ8ocIgIoLgiGA4EhNrd9ptnuAtHsMulMtloDBlKVymg8kQCsU0QRjud5L5nEQXApfF4WGwPmkgA

Discussion