Typescript(ジェネリクス part2)

2023/03/04に公開

ジェネリクスは、関数やクラスなどの型をパラメータ化する方法を提供します。これにより、異なる型の引数に対して同じ関数を再利用できます。また、ジェネリック型を使用することで、型安全性が向上し、コードの再利用性が高まります。

ジェネリック関数

ジェネリック関数は、引数と戻り値の型をパラメータ化することができます。以下は、単純な例です。

function identity<T>(arg: T): T {
  return arg;
}

let output1 = identity<string>("hello"); // output1はstring型
let output2 = identity<number>(1); // output2はnumber型

ここで、identity関数は、"<T>"で型をパラメータ化し、引数argと戻り値Tの型を指定しています。output1は、identity<string>がhelloを受け取ってstringを返すため、string型になります。同様に、output2は、identity<number>1を受け取ってnumberを返すため、number型になります。

ジェネリッククラス

ジェネリッククラスは、クラス内のメンバーの型をパラメータ化することができます。以下は、単純な例です。

class Queue<T> {
  private items: T[] = [];

  enqueue(item: T) {
    this.items.push(item);
  }

  dequeue(): T | undefined {
    return this.items.shift();
  }
}

let queue1 = new Queue<number>();
queue1.enqueue(1);
queue1.enqueue(2);
console.log(queue1.dequeue()); // 1

let queue2 = new Queue<string>();
queue2.enqueue("hello");
queue2.enqueue("world");
console.log(queue2.dequeue()); // "hello"

ここで、Queueクラスは、<T>で型をパラメータ化し、items配列とenqueue、dequeueメソッドの引数と戻り値の型を指定しています。queue1とqueue2は、それぞれQueue<number>とQueue<string>のインスタンスであり、それぞれnumber型とstring型の要素を持ちます。dequeueメソッドは、T | undefined型を返すため、要素がない場合はundefinedを返します。

有効的な使用場面

ジェネリック型は、特定の型に依存しない関数やクラスを作成するために使用されます。以下は、ジェネリック型が有効な使用例の一部です。

データ構造:
ジェネリック型は、様々なデータ構造に使用されます。例えば、Array<T>やMap<K, V>などの配列やマップに使用されます。ジェネリック型を使用することで、異なる型の要素を格納する配列やマップを作成することができます。

let array1: Array<number> = [1, 2, 3];
let array2: Array<string> = ["hello", "world"];
let map1: Map<string, number> = new Map([["one", 1], ["two", 2]]);
let map2: Map<number, string> = new Map([[1, "one"], [2, "two"]]);

関数:関数を書くとき、引数の型や戻り値の型をジェネリック型として宣言することができます。これにより、同じ関数を異なる型の引数に対して再利用することができます。

function reverse<T>(items: T[]): T[] {
  return items.reverse();
}

let array1 = [1, 2, 3];
let array2 = ["hello", "world"];
console.log(reverse(array1)); // [3, 2, 1]
console.log(reverse(array2)); // ["world", "hello"]

クラス:クラスのメソッドやプロパティの型をパラメータ化することができます。これにより、同じクラスを異なる型のプロパティやメソッドに対して再利用することができます。

class Stack<T> {
  private items: T[] = [];

  push(item: T) {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }
}

let stack1 = new Stack<number>();
stack1.push(1);
stack1.push(2);
console.log(stack1.pop()); // 2

let stack2 = new Stack<string>();
stack2.push("hello");
stack2.push("world");
console.log(stack2.pop()); // "world"

以上が、ジェネリック型が有効な使用例の一部です。ジェネリック型は、一般的なプログラミングの問題に対して柔軟な解決策を提供します。

有効な使用場面の例をいくつか挙げてみましょう。

  1. 配列とオブジェクトのマッピング
    配列とオブジェクトのマッピングを行う際にジェネリック型を使用すると、タイプセーフなコードを実装することができます。
interface IUser {
  name: string;
  age: number;
}

interface IUserInfo {
  [id: number]: IUser;
}

function mapArrayToObj<T>(arr: T[], key: string): IUserInfo {
  return arr.reduce((obj, item) => {
    return {
      ...obj,
      [item[key]]: item
    };
  }, {});
}

const users = [
  { name: "Alice", age: 23 },
  { name: "Bob", age: 35 },
  { name: "Charlie", age: 42 }
];

const userInfo = mapArrayToObj(users, "age");
console.log(userInfo[23]); // { name: "Alice", age: 23 }
  1. 抽象化されたコレクションクラスの実装
    ジェネリック型は、コレクションクラスの実装に非常に有用です。例えば、JavaScriptの標準的なMapオブジェクトは、ジェネリック型を使用して、キーと値の型を指定することができます。
const map = new Map<string, number>();
map.set("one", 1);
map.set("two", 2);
console.log(map.get("one")); // 1
console.log(map.get("two")); // 2

3.型安全なパラメータのバリデーション
関数やクラスのメソッドで引数のバリデーションを行う場合、ジェネリック型を使用して、型安全なバリデーションを実装することができます。

interface IUser {
  name: string;
  age: number;
}

function validateUser<T extends IUser>(user: T): boolean {
  return !!user && typeof user.name === "string" && typeof user.age === "number";
}

const user1 = { name: "Alice", age: 23 };
const user2 = { name: "Bob", age: "35" }; // 型エラー
console.log(validateUser(user1)); // true
console.log(validateUser(user2)); // false
  1. 非同期処理の型安全な制御
    非同期処理を扱う場合、PromiseやAsync/Awaitなどの機能が使われますが、このような場合にもジェネリック型を使用することができます。例えば、Promiseを返すAPIを定義する際に、返却されるデータの型をジェネリック型で指定することができます。
interface IUser {
  name: string;
  age: number;
}

async function getUser<T extends IUser>(id: string): Promise<T> {
  const res = await fetch(`/users/${id}`);
  const user = await res.json();
  if (validateUser(user)) {
    return user as T;
  }
  throw new Error("Invalid user data");
}

(async () => {
  const user = await getUser<IUser>("123");
  console.log(user.name, user.age);
})();

このように、ジェネリック型を使用することで、非同期処理を扱う関数が返却するPromiseオブジェクトに対して型安全な制御が行えるようになります。

  1. 関数の型安全なオーバーロード
    関数のオーバーロードを実現するために、ジェネリック型を使用することができます。例えば、以下のような関数を定義することができます。
function foo<T>(arg: T[]): T;
function foo<T>(arg: T): T;
function foo<T>(arg: T | T[]): T {
  if (Array.isArray(arg)) {
    return arg[0];
  }
  return arg;
}

const arr = [1, 2, 3];
const num = 42;
console.log(foo(arr)); // 1
console.log(foo(num)); // 42

このように、ジェネリック型を使用することで、関数のオーバーロードを実現することができます。

  1. クラスの型安全な制御
    ジェネリック型は、クラスにも適用することができます。クラスにジェネリック型を導入することで、インスタンス生成時に型を指定することができます。例えば、以下のようなクラスを考えます。
class Container<T> {
  private items: T[] = [];

  addItem(item: T) {
    this.items.push(item);
  }

  getItem(index: number): T {
    return this.items[index];
  }
}

const numContainer = new Container<number>();
numContainer.addItem(42);
console.log(numContainer.getItem(0)); // 42

const strContainer = new Container<string>();
strContainer.addItem("Hello, world!");
console.log(strContainer.getItem(0)); // "Hello, world!"

このように、ジェネリック型を使用することで、インスタンス生成時に型を指定することができます。また、ジェネリック型を導入することで、クラス内部で行われる処理において、型安全な制御が行えるようになります。

  1. 複数の型パラメータを持つジェネリック型
    ジェネリック型は、複数の型パラメータを持つこともできます。例えば、以下のような関数を考えます。
function zip<T, U>(arr1: T[], arr2: U[]): [T, U][] {
  return arr1.map((x, i) => [x, arr2[i]]);
}

const arr1 = [1, 2, 3];
const arr2 = ["a", "b", "c"];
console.log(zip(arr1, arr2)); // [[1, "a"], [2, "b"], [3, "c"]]

このように、ジェネリック型は、複数の型パラメータを持つことができ、複雑な型の制御を実現することができます。

  1. 関数型プログラミングのサポート
    TypeScriptのジェネリック型は、関数型プログラミングのサポートにも役立ちます。例えば、以下のような高階関数を考えます。
function map<T, U>(arr: T[], f: (x: T) => U): U[] {
  return arr.map(f);
}

const arr = [1, 2, 3];
console.log(map(arr, x => x * 2)); // [2, 4, 6]

このように、ジェネリック型を使用することで、高階関数を記述することができます。高階関数を使用することで、コードの再利用性が向上し、関数型プログラミングの利点を活用することができます。

  1. 型安全なReduxの実装
    Reduxは、JavaScriptの状態管理ライブラリであり、TypeScriptとも相性が良いため、TypeScriptでReduxを実装することが一般的になっています。Reduxでは、アプリケーションの状態を格納するためのストアを作成します。ストアには、アクションを発行することで状態を変更することができます。このとき、ジェネリック型を使用することで、アクションの型安全な制御を行うことができます。
interface Action<T extends string> {
  type: T;
}

interface AddAction extends Action<"ADD"> {
  payload: number;
}

interface RemoveAction extends Action<"REMOVE"> {
  payload: number;
}

type MyAction = AddAction | RemoveAction;

function reducer(state: number, action: MyAction): number {
  switch (action.type) {
    case "ADD":
      return state + action.payload;
    case "REMOVE":
      return state - action.payload;
    default:
      return state;
  }
}

const initialState = 0;
console.log(reducer(initialState, { type: "ADD", payload: 10 })); // 10
console.log(reducer(initialState, { type: "REMOVE", payload: 5 })); // -5

このように、ジェネリック型を使用することで、アクションの型安全な制御を行うことができます。Reduxの実装においても、ジェネリック型は非常に役立つ機能です。

以上が、ジェネリック型の有効な使用例の一部です。ジェネリック型は、TypeScriptの強力な機能であり、開発者が安全かつ効率的なコードを記述するのを支間的に支援します。ここからは、ジェネリック型を使用する際に注意すべき点について説明します。

ジェネリック型を使用する際の注意点

  1. 型パラメーター名の付け方
    ジェネリック型を定義する際には、型パラメーター名を適切に命名する必要があります。型パラメーター名は、TやUなど、アルファベット1文字から始めるのが一般的です。ただし、型パラメーターが何を表しているかが明確になるように、より具体的な名前を付けることもできます。

  2. ジェネリック型の制限
    ジェネリック型は、あらゆる型に対して使用できるわけではありません。たとえば、ジェネリック型を使用する際には、その型が持つメソッドやプロパティに制限をかけることができます。また、ジェネリック型を使用する際には、特定の型に限定することもできます。

  3. ジェネリック型の型推論
    TypeScriptは、ジェネリック型の型推論にも対応しています。つまり、ジェネリック型が必要な場合には、TypeScriptが自動的に型パラメーターを推論してくれます。ただし、必ずしも正しい型を推論してくれるわけではないため、適切な型パラメーターを指定することが重要です。

  4. ジェネリック型の拡張
    ジェネリック型を使用する場合、既存の型を拡張して新しい型を作成することもできます。たとえば、配列を操作するためのジェネリック型を定義し、それを拡張して、特定の条件に合致する配列を作成することができます。

まとめ
ジェネリック型は、TypeScriptの強力な機能であり、開発者が安全かつ効率的なコードを記述するのを支援する役割を果たしています。ジェネリック型を使用することで、型安全なコードを記述し、再利用性の高いコードを作成することができます。また、ジェネリック型は、関数型プログラミングやReduxなど、多くの場面で役立つ機能です。ただし、ジェネリック型を使用する際には、型パラメーター名の命名や制限、型推論、拡張など注意点がありますので、それらを考慮して使用することが重要です。適切な使用方法を学び、効果的にジェネリック型を活用してください。

以下は、ジェネリック型の具体的な使用例です。

例1: 配列の要素をシャッフルする

function shuffle<T>(array: T[]): T[] {
  const newArray = [...array];
  for (let i = newArray.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [newArray[i], newArray[j]] = [newArray[j], newArray[i]];
  }
  return newArray;
}

const numbers = [1, 2, 3, 4, 5];
const shuffledNumbers = shuffle(numbers);
console.log(shuffledNumbers); // [3, 1, 2, 5, 4]

const strings = ['a', 'b', 'c', 'd', 'e'];
const shuffledStrings = shuffle(strings);
console.log(shuffledStrings); // ['c', 'a', 'b', 'e', 'd']

この例では、T型の配列を受け取り、その配列の要素をシャッフルする関数を定義しています。この関数を使用することで、数値や文字列など、あらゆる型の配列をシャッフルすることができます。

例2: ReduxのReducerをジェネリック型で定義する

interface CounterState {
  count: number;
}

type CounterAction =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset' };

function counterReducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      return state;
  }
}

interface TodosState {
  todos: string[];
}

type TodosAction =
  | { type: 'add'; text: string }
  | { type: 'remove'; index: number };

function todosReducer(state: TodosState, action: TodosAction): TodosState {
  switch (action.type) {
    case 'add':
      return { todos: [...state.todos, action.text] };
    case 'remove':
      return { todos: state.todos.filter((_, index) => index !== action.index) };
    default:
      return state;
  }
}

function rootReducer<S, A>(state: S, action: A): S {
  // ここでは、stateとactionを使用して新しいstateを返すロジックを実装する
}

const initialState = {
  counter: { count: 0 },
  todos: { todos: [] },
};

const reducer = (state = initialState, action: CounterAction | TodosAction) => {
  switch (action.type) {
    case 'increment':
    case 'decrement':
    case 'reset':
      return { ...state, counter: counterReducer(state.counter, action) };
    case 'add':
    case 'remove':
      return { ...state, todos: todosReducer(state.todos, action) };
    default:
      return state;
  }

この例では、ReduxのReducerをジェネリック型で定義しています。Reducerは、stateとactionを受け取り、新しいstateを返す純粋関数です。Reduxでは、Reducerの戻り値の型がState型と一致する必要があります。そのため、Reducerをジェネリック型で定義し、stateの型とactionの型を指定することができます。

例えば、CounterStateとCounterActionを定義した場合、counterReducer関数はCounterState型とCounterAction型を引数に取り、CounterState型を返す必要があります。同様に、TodosStateとTodosActionを定義した場合、todosReducer関数はTodosState型とTodosAction型を引数に取り、TodosState型を返す必要があります。

最後に、rootReducer関数を定義し、stateとactionの型をジェネリック型で指定しています。rootReducer関数では、引数として渡されたstateとactionを使用して新しいstateを返すロジックを実装します。この例では、counterReducer関数とtodosReducer関数を組み合わせて、複数のstateを扱うことができるようにしています。

まとめると、ジェネリック型は、様々な型を扱う汎用的なコードを実現するための機能です。ジェネリック型を使用することで、型安全性を確保しながら、再利用可能なコードを作成することができます。上記の例で紹介したように、ジェネリック型は配列や関数、クラス、インターフェースなど、様々な場面で活用されています。

Discussion