iimon TECH BLOG

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

型に縛られず、型を守る。忘れがちな「ジェネリクス」を改めて整理して武器にする

こんにちは。iimonでエンジニアをしているhayashiと申します。

普段は主に拡張機能を開発しております。

今回はジェネリクスについて何となく分かったつもりではいたけれど、

忘れがちという事に気付きまして、ちゃんとアウトプットしてみようと思います。

ジェネリクスとは

ジェネリクスは関数やクラスを、ジェネリクスは「型を引数として受け取る」仕組みのことです。

なぜジェネリクスが必要なのか

例えば以下のような配列の一番最初の要素を返す関数があります。

想定としては、配列が何の型の配列であれ0番目を返したいだけとします。

この関数は現状、宣言したnumberしか扱えません。

なので以下のようにstringを渡すとコンパイルエラーになります。

// 配列の最初の要素を返す関数
const getFirst = (arr: number[]): number => {
  return arr[0];
};

// 文字列配列には使えない...
getFirst(['a', 'b', 'c']); // エラー!

当然any を使えば動きますが、型安全性が失われます。

const getFirstAny = (arr: any[]): any => {
  return arr[0];
};

const result = getFirstAny([1, 2, 3]);
// result は any 型... 型の恩恵がない

ユニオン型にする手もありますが、渡される配列の型が増えれば増えるほど追加していかなければなりません。

今回の例で言えば、与えられた配列が何の型の配列であれ0番目を返したいだけなのでユニオン型が増えていくのは冗長だと思われます。

const getFirst = (arr: number[] | string[]): number | string => {
  return arr[0];
};

getFirst(['a', 'b', 'c']);
getFirst([1, 2, 3]);

また、返り値がユニオン型だと呼び出し元でもエラーが出ます。

例えば以下のように目で見ればnumberと分かりきっていると思っていても、stringの返り値の可能性もあるよって言われてしまいます。

stringの場合も同じようにnumberの可能性のエラーが出ます。

const getFirst = (arr: number[] | string[]): number | string => {
  return arr[0];
};

const firstNumber = getFirst([1, 2, 3]);
const hoge = firstNumber + 1
// stringになる可能性があるため、コンパイルエラー

ユニオン型で対応しようとすると、呼び出すたびに型を特定(絞り込み)しなければならず、利用側のコードが煩雑になってしまいます。

const firstNumber = getFirst([1, 2, 3]);
const hoge = Number(firstNumber) + 1 // 冗長
const hoge2 = (firstNumber as number) + 1 // 冗長

DRY(Don't Repeat Yourself)に書きたい気持ちは分かるのですが、キャストや変換をし直さないとならないなら、型付けのメリットは大幅に薄れるので、単一責任を意識してメソッドに分けた方が保守性が高いです。

const getFirstNumber = (arr: number[]): number => arr[0];
const getFirstString = (arr: string[]): string => arr[0];

const num = getFirstNumber([1, 2, 3]);  // num: number
const str = getFirstString(['a', 'b']);  // str: string

でも型が違うだけでコード重複してるし、もっと簡潔にならないかなぁーって思いますよね?

ここで活躍するのがジェネリクスです!

ジェネリクスで解決

ジェネリクスを使えば「型安全」と「DRY(重複排除)」を両立できます!

アプローチ 型安全 DRY
ユニオン型で1関数
型ごとに関数分ける
ジェネリクス

関数に<T> を宣言してあげます。

Typeという意味で慣習としてTがよく使われますが、AでもTypeでも何でも構いません。

そして呼び出せば、呼び出し時に具体的な型が入るというわけです。

こうする事で関数の型を汎用的にできます。

以下のように<number><string>などとして<T>の型を明示的に宣言する事もありますが、

const getFirst = <T>(arr: T[]): T => arr[0];
const num = getFirst<number>([1, 2, 3]);       
const str = getFirst<string>(['a', 'b', 'c']); 

型推論も効くので呼び出し時の型の宣言は省略することも基本的には可能です。

const getFirst = <T>(arr: T[]): T => arr[0];
const num = getFirst([1, 2, 3]);       
const str = getFirst(['a', 'b', 'c']); 

また、ジェネリクスはカンマで区切れば複数定義する事が可能です。

慣習として、T の次は U, V とアルファベット順に名付けることが多いですが、意味のわかる名前(例:TData, TResponse)をつけても問題ありません。

const echo = <T, U, V, W>(arg: T, arg2: U, arg3: V, arg4: W): [T, U, V, W] => [
  arg,
  arg2,
  arg3,
  arg4,
];
console.log(echo('hoge', 1, true, ['fuga']))

// 実行結果
//[ 'hoge', 1, true, [ 'fuga' ] ]

型パラメーターに制約をつける

ジェネリクスの型に制約をつけたい場合もあると思います。

例えば{name : string}を必ず持っていないと型エラーを出したい場合は

<T>に対してextendsを使って指定をすると制約を付けられます。

extendsはクラスの継承などで使われますが、ジェネリクスでは『<T> は特定のプロパティを持っていなければならない』という制約として考えると分かりやすいかもしれません。

const getFirst = <T  extends {name : string}>(arr: T[]): T => arr[0];
console.log(getFirst([{name: 'iimon'}, {name: 'taro'}]));
// 実行結果
// { name: 'iimon' }

仮に以下の様にnameのkeyがオブジェクト内に無いと

const getFirst = <T  extends {name : string}>(arr: T[]): T => arr[0];
console.log(getFirst([{nameHoge: 'iimon'}, {name: 'taro'}]));

エラーが出ます。

keyof との組み合わせ

オブジェクトから安全にプロパティを取得する時などにkeyofと組み合わせて使う事がよくあります。

keyofはオブジェクトの型からプロパティ名を型として返す型演算子です。

例えば以下のオブジェクトに対して、keyofを使うと文字列リテラル型の"name" | "email" が得られます。 つまり、オブジェクトのkeyを得られる為、それをジェネリクスと併用してTの型にそのkeyが無ければ型エラーを出すという方法です。

つまり以下の様に第二引数Kは第一引数TのオブジェクトのkeyだよってK extends keyof Tによって制約を持たせることができるので、第一引数の中に無いkeyを第二引数で指定すると

型エラーを出してくれて、安全にobjからプロパティを取得することができるというわけですね。

const getProperty = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];

const person = { userName: "Taro", age: 20 };
const userName = getProperty(person, "userName"); // name: string
const age = getProperty(person, "age"); // age: number
// getProperty(person, 'foo');              // エラー: "foo" は存在しない

つまり、ジェネリクスでextendskeyofを使用することによって、

  • 存在しないキーへのアクセスをコンパイル時に防げる
  • 戻り値の型推論: T[K]で正確な型が返る
    • userNameの場合はstring
    • ageの場合はnumber

上記のメリットがあるわけです。

ジェネリクスクラス

ジェネリクスはクラスでも使用可能です。

例えばdataを保持するクラスで以下のようにユニオン型にするのは上記でも記載している通りユニオンの型の分だけ自由にsetできてしまって制限ができていません

class Data {
  private data: (string | number)[] = []

  set(item: string | number) {
    this.data.push(item)
  }

  get() {
    return this.data
  }
}

const stringData = new Data()
stringData.set('test') 
stringData.set(21)
// 本来は文字列のみを扱いたいインスタンスなのに、数値も許容されてしまう。

なので以下のようにジェネリクスにすればTでインスタンス作成時に渡した型のデータしか保持しない汎用的な書き方ができます。

class Data<T extends string | number> {
  private data: T[] = []

  set(item: T) {
    this.data.push(item)
  }

  get() {
    return this.data
  }
}

const stringData = new Data<string>()
stringData.set('test')
// stringData.set(21) // number 型エラー
const numberData = new Data<number>()
numberData.set(21) 
// numberData.set('test') // string 型エラー

それ以外にも具体例を出しましょう。

例えばAPIへリクエストを送る処理で、違うのはエンドポイントと返り値の型だけの場合にも使えそうです。

以下のように重複した部分が多いとAPI自体が修正された時に全部直す必要があったりして、保守性が高いと言えません。

type User = {
  id: number;
  name: string;
  email: string;
};

type Product = {
  id: number;
  name: string;
  price: number;
};

// ユーザー用
class UserApiClient {
  async getAll(): Promise<User[]> {
    const res = await fetch("/api/users");
    return await res.json();
  }
  async getById(id: number): Promise<User> {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  }
  async create(data: Omit<User, "id">): Promise<User> {
    const res = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    return await res.json();
  }
  async update(id: number, data: Partial<User>): Promise<User> {
    const res = await fetch(`/api/users/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    return await res.json();
  }
  async delete(id: number): Promise<void> {
    await fetch(`/api/users/${id}`, { method: "DELETE" });
  }
}

// 商品用(エンドポイントと型以外は全く同じ...)
class ProductApiClient {
  async getAll(): Promise<Product[]> {
    const res = await fetch("/api/products");
    return await res.json();
  }
  async getById(id: number): Promise<Product> {
    const res = await fetch(`/api/products/${id}`);
    return await res.json();
  }
  async create(data: Omit<Product, "id">): Promise<Product> {
    const res = await fetch("/api/products", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    return await res.json();
  }
  async update(id: number, data: Partial<Product>): Promise<Product> {
    const res = await fetch(`/api/products/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    return await res.json();
  }
  async delete(id: number): Promise<void> {
    await fetch(`/api/products/${id}`, { method: "DELETE" });
  }
}

そのような場合もジェネリクスを使えば便利です。

type User = {
  id: number;
  name: string;
  email: string;
};

type Product = {
  id: number;
  name: string;
  price: number;
};

class ApiClient<T> {
  private endpoint: string;

  constructor(endpoint: string) {
    this.endpoint = endpoint;
  }

  async getAll(): Promise<T[]> {
    const res = await fetch(this.endpoint);
    return await res.json();
  }
  async getById(id: number): Promise<T> {
    const res = await fetch(`${this.endpoint}/${id}`);
    return await res.json();
  }
  // Omit<T, "id"> = T から id を除いた型
  async create(data: Omit<T, "id">): Promise<T> {
    const res = await fetch(this.endpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    return await res.json();
  }
  async update(id: number, data: Partial<T>): Promise<T> {
    const res = await fetch(`${this.endpoint}/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    return await res.json();
  }
  async delete(id: number): Promise<void> {
    await fetch(`${this.endpoint}/${id}`, { method: "DELETE" });
  }
}

// 使うときにエンドポイントと型を指定するだけ!
const userApi = new ApiClient<User>("/api/users");
const productApi = new ApiClient<Product>("/api/products");

1つのクラスで、型安全に複数のデータ型を扱えるので保守性が高まります。

そして上記の ApiClient<T> を使えば、レスポンスの型が自動的に決まり、型安全にもなります。

type User = {
  id: number;
  name: string;
  email: string;
};

type Product = {
  id: number;
  name: string;
  price: number;
};

// 同じクラスで異なる型を扱える
const userApi = new ApiClient<User>("/api/users");
const productApi = new ApiClient<Product>("/api/products");

(async () => {
  // User の操作(型安全!)
  const users = await userApi.getAll();     // User[]
  const user = await userApi.getById(1);    // User
  console.log(user.name);                   // OK
  // console.log(user.price);               // エラー!User に price はない

  // Product の操作(型安全!)
  const products = await productApi.getAll();   // Product[]
  const product = await productApi.getById(1);  // Product
  console.log(product.price);                   // OK
  // console.log(product.email);                // エラー!Product に email はない
})();

interfaceやtypeでのジェネリクスの型定義

以下のようにinterfaceやtypeでもジェネリクスを使うことができます。            

[interface]

interface Box<T> {
  value: T;
}

const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };

[type]

type Box<T> = {
  value: T;
};

const numBox: Box<number> = { value: 42 };
const strBox: Box<string> = { value: "hello" };

実例として上記ジェネリクスクラスで定義したApiClient<T>クラスのResponseをSuccessResponseとして 、そこのdataをジェネリクスにしてみました。

// 型定義
type User = {
  id: number;
  name: string;
  email: string;
};

type Product = {
  id: number;
  name: string;
  price: number;
};

// APIが返すレスポンスの形式
type SuccessResponse<T> = {
  success: true;
  data: T;
};

type ErrorResponse = {
  success: false;
  error: string;
};

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

// タイプガード
const isSuccess = <T>(res: ApiResponse<T>): res is SuccessResponse<T> => {
  return res.success;
};

// ジェネリクスクラス(APIのレスポンスをそのまま返す)
class ApiClient<T> {
  constructor(private endpoint: string) {}

  async getAll(): Promise<ApiResponse<T[]>> {
    const res = await fetch(this.endpoint);
    return await res.json();
  }

  async getById(id: number): Promise<ApiResponse<T>> {
    const res = await fetch(`${this.endpoint}/${id}`);
    return await res.json();
  }

  async create(data: Omit<T, "id">): Promise<ApiResponse<T>> {
    const res = await fetch(this.endpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    return await res.json();
  }
}

これによりresもジェネリクスで汎用的に型付けができ、エラーハンドリングもできたので安心です。

[使用例]

// 同じクラスで異なる型を扱える
const userApi = new ApiClient<User>("/api/users");
const productApi = new ApiClient<Product>("/api/products");

// User の操作
const displayUser = async (id: number) => {
  try {
    const result = await userApi.getById(id);

    if (!isSuccess(result)) {
      console.error(`APIエラー: ${result.error}`);
      return;
    }

    // result.data は User 型
    console.log(`ユーザー名: ${result.data.name}`); // OK
    console.log(`メール: ${result.data.email}`); // OK
    // console.log(result.data.price);  // エラー!User に price はない

  } catch (e) {
    console.error(`通信エラー: ${e}`);
  }
};

// Product の操作
const displayProduct = async (id: number) => {
  try {
    const result = await productApi.getById(id);

    if (!isSuccess(result)) {
      console.error(`APIエラー: ${result.error}`);
      return;
    }

    // result.data は Product 型
    console.log(`商品名: ${result.data.name}`); // OK
    console.log(`価格: ${result.data.price}円`); // OK
    // console.log(result.data.email);  // エラー!Product に email はない

  } catch (e) {
    console.error(`通信エラー: ${e}`);
  }
};

displayUser(1);
displayProduct(1);

それ以外にも色々と汎用的に使えそうです。

ユーティリティ型

ユーティリティ型は、既存の型を変換して新しい型を作ってくれます。 TypeScriptに組み込まれており、ジェネリクスで実装されています。

Partial - 全プロパティをオプショナルに

type User = {
  id: number;
  name: string;
  email: string;
};

type PartialUser = Partial<User>;
// {
//   id?: number;
//   name?: string;
//   email?: string;
// }

上記のように必須の型も全てオプショナルになります。

Required - 全プロパティを必須に

type Config = {
  host?: string;
  port?: number;
  debug?: boolean;
};

type RequiredConfig = Required<Config>;
// {
//   host: string;
//   port: number;
//   debug: boolean;
// }

上記のようにオプショナルの型も全て必須のプロパティになります。

Pick<T, K> - 特定のプロパティだけ抽出

type User = {
  id: number;
  name: string;
  email: string;
  password: string;
};

type UserCredentials = Pick<User, "email" | "password">;
// {
//   email: string;
//   password: string;
// }

上記の様にtype UserCredentialsの様な型があったとして、Pickを使うとtype Useremailpasswordを抽出した型になります。

Omit<T, K> - 特定のプロパティを除外

type User = {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
};

type CreateUserInput = Omit<User, "id" | "createdAt">;
// {
//   name: string;
//   email: string;
// } 

上記の様にtype CreateUserInputの様な型があったとしてtype UseridcreatedAtを除外した型になります。

他にも、まだまだあるので気になる方は公式ドキュメントを参照して見て下さい

まとめ

いかがでしたでしょうか。

ジェネリクスは汎用的で使いこなせれば、とても強い武器になると思います。

ユーティリティ型には最後サラッと触れましたが、ちゃんと解説するには

Mapped TypesやConditional TypesやPromiseもジェネリクスで型付けされてること、

返り値を例えばstringの場合は:Promise<string>みたいに型付けしないと型エラーで怒られるけど、そこにもユーティリティのAwaitedが使われてたり、そういうのも入れたくなるので

今回は時間の都合上カットしました。

次回以降にでも機会があれば解説したいと思います。

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします!

iimon採用サイト / Wantedly / Green

参照

https://typescriptbook.jp/reference/generics

https://qiita.com/tak001/items/3ba5e399757050ce2b55

https://typescriptbook.jp/reference/type-reuse/keyof-type-operator

https://www.typescriptlang.org/docs/handbook/utility-types.html

https://typescriptbook.jp/reference/asynchronous/promise

https://zenn.dev/k_sk/articles/1ba1210fcc9e8d