iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

TypeScriptのジェネリクス型を使って型に柔軟性を持たせる

■はじめに

こんにちは、株式会社iimonでエンジニアをしている「白水」です。

業務では、主にフロントエンドを担当させていただいています。

本記事はiimonアドベントカレンダー13日目の記事となります。

今回はTypeScriptの「ジェネリクス型」について書いていきたいと思います。

ジェネリクス型とは?

ここにnamesという変数があり、文字列を要素として持っている配列があります。

この配列の型は、画像の通りstring[]です。

これは2つの型が組み合わさって、1つの型になっています。

この場合は、string +[]が組み合わさっています。

const names: string[] = ['田中', '佐藤'];

なので、上記のように明示的に型を書くことができます。

しかし、TypeScriptには、string[]と同様の型を作る、組込みのGenerics型があります。

例えば、Array<T>型です。

実際にArrayと型を書いてみます。

Arrayと書いてもエラーになるので、エラー内容を確認すると、Array<T>と記載されています。

この<T>が「ジェネリクス型」です。

ジェネリクス型」は、日本語で「一般的な・包括的な」という意味があります。

そのため、<T>に対して型を引数として渡してあげることで、string +[]string部分を動的に変えることができます。

ちょうど、関数の引数に値を渡すように、型を<T>に渡してあげることができます。

そのため、string[]と型を指定するのと、Arrayと型を指定するのは同じです。

const names: Array<string | number> = ['田中', '佐藤'];

他にもstring | numberとUnion型(AまたはB)にすることもできます。

これが、TypeScriptに組み込まれた「ジェネリクス型」です。

ジェネリクス型」は、「汎用型」であり、追加の型情報(ここでいうとstring | number)という情報をArray型に提供することで、型を動的に決めることができます。

ジェネリクス関数を作る

次にジェネリクス関数を作ってみます。

function objBind(objA: object, objB: object) {
  const mergeObj = { ...objA, ...objB };
  return mergeObj;
}

const objA = { name: '田中' };
const objB = { age: 20 };

const result = objBind(objA, objB);
console.log(result.name);

2つのオブジェクトを引数で受け取って、合体したオブジェクトを返す関数を作成してみます。

関数を呼び出した戻り値から、nameプロパティにアクセスしようとするとエラーになります。

nameプロパティは存在していますが、型が{}としか認識されておらず、曖昧な型になっているため、nameプロパティにアクセスできません。

function objBind(objA: { name: string }, objB: { age: number }) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

const objA = { name: '田中' };
const objB = { age: 20 };
const result = objBind(objA, objB);
result.name;

これを回避するために、引数の型を{ name: string }のように明確にすることはできます。

しかし、この関数はどんなオブジェクトでも合体する汎用的な関数にしたいと仮定した場合、型の制約を設けると、使えるオブジェクトの型が限定されてしまいます。

そこで、柔軟性を持たせるために「ジェネリクス関数」を作ってみます。

function objBind<T extends object, U extends object>(objA: T, objB: U) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

関数名の後ろに<>(山括弧)を追加して、ジェネリクス関数にします

2つの識別子を受け取ります。通常は型(Type)から取ってTから開始します。

「T」の次はアルファベットの「U」なので、2つ目の識別子は「U」にするのが慣習です。

ジェネリクス型にマウスを当てると、TとUの交差型を返すようになっています。

function objBind<T extends object, U extends object>(objA: T, objB: U) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

const objA = { name: '田中' };
const objB = { age: 20 };
const result1 = objBind(objA, objB);
console.log(result1.name);

この状態で、関数を呼び出してみます。

すると、TypeScriptは合体したobjectにnameとageプロパティがあることを認識してくれます。

これで、result1.nameでエラーが出ないようになりました。

function objBind<T extends object, U extends object>(objA: T, objB: U) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

const objA = { name: '田中' };
const objB = { age: 20 };
const result1 = objBind(objA, objB);
console.log(result1.name);

const objC = { admin: true, likeSports: ['baseball', 'soccer'] };
const objD = {
    level: 1,
    nest: {
        level: 2,
    },
};

const result2 = objBind(objC, objD);
console.log(result2.level);

このジェネリクス関数のよいところは、オブジェクトであればどんな形でも引数で取れる汎用性を兼ね備えているところです。

そのため、objCやobjDといった、objAやobjBとは違ったデータ型のオブジェクトも問題なく引数で受け取ることができます。

この関数にとっては、受け取るobjectがどのような型であるかは関係ありません。

ただ、オブジェクトであれば問題ないという広く受け取れるようになりました。

function objBind<T extends object, U extends object>(objA: T, objB: U) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

const objA = { name: '田中' };
const objB = { age: 20 };
const result1 = objBind<{ name: string }, {age: number }>(objA, objB);
console.log(result1.name);

objBind<{ name: string }, {age: number }>(objA, objB);のように、ジェネリクス関数を呼び出す側で型を明示的に指定することもできます。

こうすれば、objAやobjBが<{ name: string }, {age: number }>型にそぐわない場合にエラーになって気づくことができます。

■extendsで制約を付与したジェネリクス

function objBind<T extends object, U extends object>(objA: T, objB: U) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

const objA = { name: '田中' };
const objB = { age: 20 };
const result1 = objBind(objA, objB);
console.log(result1.name);

先ほどのコードで、extends objectを型に付与しています。

これは、型の制約を付与しています。

function objBind<T extends object, U>(objA: T, objB: U) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

const objA = { name: '田中' };
const result1 = objBind(objA, 30);
console.log(result1.name);
console.log(result1.age);

例えば、Uに書いていたextends objectを削除して、関数を呼び出してみます。

すると、ageプロパティにアクセスしているところでエラーになります。

これでもいいですが、このobjBind関数は、2つのオブジェクトを受け取ることを前提に書いています。

そのため、30というnumber型や、何かしらの文字列を引数で渡している部分(objBind(objA, 30);)でエラーが出ないのは違和感があります。

function objBind<T extends object, U extends object>(objA: T, objB: U) {
    const mergeObj = { ...objA, ...objB };
    return mergeObj;
}

const objA = { name: '田中' };
const objB = { age: 20 };
const result1 = objBind(objA, 30);
console.log(result1.name);
console.log(result1.age);

これを回避するために、objBind関数が引数で受け取る型に制約を設けることができます。

そうすると、objBind関数を呼び出している部分で、numberを渡しているのでエラーになります。

これで開発時に、ageプロパティがないことが悪いのではなく、オブジェクトを渡す必要があることに気づくことができます。

Generics型はextendsキーワードをつけて制約をつけることができます。

引数TやUは、どんな構造のobjectでも問題ないですが、必ずobject型でなければならないということを保証することができます。

■keyofで制約を付与したジェネリクス

次に、keyofを使って、オブジェクトに特定のキーが含まれていることを保証するようにしてみます。

function accessObjProperty(obj: object, property: string) {
  return obj[property];
}

console.log(accessObjProperty({}, 'name'));

第1引数で受け取ったオブジェクトのプロパティにアクセスして値を返す関数を作ります。

しかし、この関数の引数はobject型なので、propertyが存在することを保証できずエラーになります。

function accessObjProperty(obj: { name: string }, property: 'name') {
  return obj[property];
}

console.log(accessObjProperty({ name: '田中' }, 'name'));

上記のように明示的に型を指定することで、エラーを回避できますが、汎用的に使える関数にはなれません。

そこでジェネリクス型を用います。

function accessObjProperty<T extends object, U>(obj: T, property: U) {
  return obj[property];
}

console.log(accessObjProperty({ name: '田中' }, 'name'));

今までと同様にすると、「Tオブジェクト」に「Uプロパティ」があるかわからないのでエラーになります。

そのため、Tで受け取ったオブジェクトに存在するプロパティだけをUが受け取れるようにする必要があります。

そこで、keyofを使います。

function accessObjProperty<T extends object, U extends keyof T>(obj: T, property: U) {
  return obj[property];
}

console.log(accessObjProperty({ name: '田中' }, 'name'));

U extends keyof Tとすることで、TのプロパティのkeyだけをUの型に設定することができる制約を設けることができます。

そのため、{ name: '田中' }には'name'プロパティが存在するのでエラーになりません。

逆に、オブジェクトに存在しないプロパティを渡すとエラーになるので、必ず引数で渡すオブジェクトにプロパティがあることを保証することができます。

ジェネリクスクラスを作る

class Monster {
    monsterInfo = [];

    addMonsterInfo(info) {
        this.monsterInfo.push(info);
    }

    removeMonsterInfo(info) {
        this.monsterInfo.splice(this.monsterInfo.indexOf(info), 1);
    }

    showMonsterInfo() {
        return this.monsterInfo;
    }
}

ジェネリクス型は、クラスにも使うことができます。

モンスターの情報を追加したり、削除したり、一覧を見たりするメソッドを持つクラスを作りました。

このクラスには型を指定していないので、型がanyになってエラーになっています。

class Monster {
    monsterInfo: { name: string; attack: number; defense: number }[] = [];

    addMonsterInfo(info: { name: string; attack: number; defense: number }) {
        this.monsterInfo.push(info);
    }

    removeMonsterInfo(info: { name: string; attack: number; defense: number }) {
        this.monsterInfo.splice(this.monsterInfo.indexOf(info), 1);
    }

    showMonsterInfo() {
        return this.monsterInfo;
    }
}

このクラスのメソッドの引数やプロパティに型をつけるために、{ name: string; attack: number; defense: number }など、1個1個書いていくこともできます。

しかし、このクラスではinfoの型が統一されていることだけを保証したいです。

どの引数のinfoにも同じ型の情報が来るということを、情報として設定したいです。

class Monster<T> {
    monsterInfo: T[] = [];

    addMonsterInfo(info: T) {
        this.monsterInfo.push(info);
    }

    removeMonsterInfo(info: T) {
        this.monsterInfo.splice(this.monsterInfo.indexOf(info), 1);
    }

    showMonsterInfo() {
        return this.monsterInfo;
    }
}

const franken = { name: 'Franken', attack: 60, defense: 30 };
const dracula = { name: 'Dracula', attack: 40, defense: 70 };

const monster = new Monster<{ name: string; attack: number; defense: number }>();
monster.addMonsterInfo(franken);
monster.addMonsterInfo(dracula);
monster.removeMonsterInfo(franken);
console.log(monster.showMonsterInfo());

そこでジェネリクス型が使えます。

Monster<T>と書いて、各メソッドの引数の型もTにしています。

こうすることで、Monsterクラスをnewするときに型情報を渡すことで、柔軟な型を持つクラスにすることができます。

これで、型安全性も保ちながら、柔軟性も持つことができます。

class Monster<T> {
    monsterInfo: T[] = [];

    addMonsterInfo(info: T) {
        this.monsterInfo.push(info);
    }

    removeMonsterInfo(info: T) {
        this.monsterInfo.splice(this.monsterInfo.indexOf(info), 1);
    }

    showMonsterInfo() {
        return this.monsterInfo;
    }
}

const franken = { name: 'Franken', attack: 60, defense: 30 };
const dracula = { name: 'Dracula', attack: 40, defense: 70 };

const monster = new Monster<{ name: string; attack: number; defense: number }>();
monster.addMonsterInfo(franken);
monster.addMonsterInfo(dracula);
monster.removeMonsterInfo(franken);
console.log(monster.showMonsterInfo());

const monster2 = new Monster<{ name: string; attack: number; defense: number, magic: boolean }>();

例えば、monster2のように、オブジェクトにmagic: booleanと新しいプロパティを追加すれば、ジェネリクス型では、Tに渡した型情報で柔軟に型を決定してくれます。

■ユーティリティのジェネリクス

開発者自身でジェネリクス関数や、ジェネリクスクラスを作ることもできますが、TypeScriptが用意してくれているユーティリティのジェネリクス型も存在します。

◆Required < Type >

type ObjType = {
  key1?: number;
  key2?: string;
}

const obj: ObjType = { key1: 5 };

const obj2: Required<ObjType> = { key1: 10 };

ObjTypeのプロパティがオプショナルで定義されている時、それをオプショナルではなく必須にしたい場合にRequiredが使えます。

◆Readonly< Type >

function objFreeze<T>(obj: T): Readonly<T> {
  return Object.freeze(obj);
}

const freezeObj = objFreeze({ name: '田中', age: 30 });
freezeObj.name = '佐藤';

Readonly<T>は、オブジェクトのプロパティの値を書き換えられないようにできます。

freezeObj.name = '佐藤';でエラーが発生するようになります。

◆Pick< Type, Keys >

type PersonInfoType = {
  name: string;
  age: number;
  likeFood: string;
}

type PersonInfoNameType = Pick<PersonInfoType, 'name'>;

Pick<Type, Keys>を使うと、オブジェクトの型から特定のキーだけを抽出することができます。

全体の型が定義されていて、そこから特定の方だけを抽出した特定の型を作りたい場合に使うことができます。

Documentation - Utility Types

■最後に

最後までご覧いただいてありがとうございます。

弊社ではエンジニアを募集しております。

カジュアルにお話させていただきたく、是非ご応募をお願いします。

Wantedly / Green

次のアドベントカレンダーの記事担当は「タクシ」さんです!

リーダーとしてチームを牽引してくださっている「タクシ」さんが、どんな記事を書くのかとても楽しみです!

■参考記事

ジェネリック型

ジェネリクスが使われている標準ライブラリ | TypeScript入門『サバイバルTypeScript』