77
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

【TypeScript】ジェネリクスも便利だよ

Last updated at Posted at 2023-06-28

ジェネリクスがよく分からないと言われたので、噛み砕いて書いていこうと思います。

なお、ジェネリクスが何なのかの説明に終始し、実践的な活用方法にはあまり触れていません。
主に初学者向けですかね。悪しからず。

ジェネリクスとは

ジェネリックプログラミングの特徴は、型を抽象化してコードの再利用性を向上させつつ、静的型付け言語の持つ型安全性を維持できることである。

Wiki - ジェネリックプログラミング

型安全性を保ちつつ、再利用性を向上させることができるようですね。
型だけじゃなくて説明も抽象的ですね。
言い換えると、型によって一定の制約を設けて安全性は担保するけど、使い回しもしやすくする、ということですね。

ジェネリクスとジェネリックは表記揺れです。
TypeScript Deep Dive日本語版でも、タイトルが「ジェネリック型」ですが次の見出しは「ジェネリクス(Generics)」となっています。そんなもんです。細かいことは気にせずいきましょう。

公式に倣って、「与えられた値がなんであれそのまま返す」、echoコマンドのような関数をもとに考えてみます。

ジェネリクスを使わないと

まずは、ジェネリクスのことを一旦忘れて、関数を文字列と数字に限定して考えてみます。

与えられた値をそのまま返す(文字列と数字)
function identityForStr(val: string): string {
  return val;
}

function identityForNum(val: number): number {
  return val;
}

出来ましたが、似たような関数名に同じ処理です。
できれば、文字列でも数字でも、同じidentity関数を使いまわせるようにしたいですね。

与えられた値をそのまま返す(文字列でも数字でも同じ関数を利用する)
function identity(val: string | number): string | number {
  return val;
}

これで文字列でも数字でもどちらでもidentity関数を利用できるようになりましたが、二つ問題があります。

  • 「与えられた値がなんであれ」ということは、booleanarrayなども対応できる必要がある
  • 受け取った値の型が定まっていない
与えられた値をそのまま返す(二つの問題)
function identity(val: string | number): string | number {
  return val;
}

// 文字列と数字以外は入れられない
// const boolOutput = identity(true);

const strOutput = identity('hoge');
// 文字列のはずだが、string | numberで定まっていない
// console.log(strOutput.length);

なんでも受け取れるようにするために、anyunknownを設定することが考えられますが、
これでは二つ目の問題が解決できません。

与えられた値をそのまま返す(anyやunknownでは戻り値の型が定まらない)
function identity(val: unknown): unknown {
  return val;
}

// 文字列と数字以外も入れられるようになったが、、、
const boolOutput = identity(true);

const strOutput = identity('hoge');
// unknownで文字列として扱えないまま
// console.log(strOutput.length);

ジェネリクスを使わない場合、このように特定の型を指定する必要があり、その制約から逃れられません。

ジェネリクスを使う

要件を言い換えると、関数identityは、下記のようになりますね。

  • 定義時点ではどんなものを受け取るか定まっていない
  • 利用された(引数が与えられた)時点で型が定まる

この時利用できるのがジェネリクスです。

与えられた値をそのまま返す(ジェネリクスを利用)
function identity<Type>(val: Type): Type {
  return val;
}

// 文字列と数字以外も入れられる
const boolOutput = identity<boolean>(true);

const strOutput = identity<string>('hoge');
// 文字列として扱える
console.log(strOutput.length);

さて、この突然出てきたTypeとはどんな型なのか、それは利用されるまでわかりません。
関数名の後に付与された、<Type>がジェネリクス型パラメータ(または単に「型パラメータ」)で、
この関数が「利用時まで型が定めないけど、型パラメータが定まったら引数と戻り値はその型ね」という制約を意味しています。
この利用時に柔軟に型が適用される働きが、型安全性と再利用性を両立させます。

つまり、
function identity<Type>(val: Type): Typeは、
利用時に呼び出し側で、
identity<string>と記述すれば、function identity(val: string): stringと解釈でき、
identity<number>と記述すれば、function identity(val: number): numberと解釈できます。

このようにジェネリクスは、型の制約を抽象的にし、様々な可能性を持たせることで再利用性を向上させることができます。

型パラメータ<>内の名称は任意の文字列なので、<Type>でも<T>でも<hoge>でも有効ですが、
何かしらの意図を示す、且つ、型であることを示すことが一般的で、慣習的に頭文字の大文字が利用されます。

例)
T:Typeの略
R:Returnの略
E:Elementの略

型アサーションでも<型>という書き方をしますが、
これはキャストを意味し、ジェネリクスとは別物です。

型アサーション
const str: unknown = "this is a string";
const len: number = (<string>someValue).length; // これは型アサーション

型推論

ほんの少し話が逸れますが、ジェネリクスと型推論について書きます。

呼び出し側で指定した型で引数と戻り値に制約を付与すると説明しましたが、
上述の関数の場合、その指定を書かなくても成立します。

省略されることがある
function identity<T>(val: T): T {
  return val;
}

const strOutput1: string = identity<string>('hoge');
const strOutput2 = identity<string>('hoge');
const strOutput3 = identity('hoge');

上記のstrOutput1strOutput2strOutput3は、
いずれも有効で、いずれも同じ結果になります。

strOutput1が省略をせず全てを記述している例です。
宣言したstrOutput1が型注釈でstringであることを定義し、identityの型パラメータにもstringを設定しているため、戻り値がstringになる、という形です。

strOutput2は、strOutput2に対する型注釈を省略している例です。
型パラメータから戻り値がstringで確定しているため型注釈がなくともstringであることが定まります。

strOutput3は、型注釈の省略に加え、ジェネリクスの型パラメータを省略しています。
制約なくなってしまうのでは?となりますが、引数のhogeという値から型推論が働いてstringが適用されます。
「型パラメータの指定なかったけど、引数がstringってことはTstringだね」と勝手に解釈してくれます。便利ですね。

型パラメータの適用範囲

先ほどの例では、型パラメータを引数と戻り値、どちらにも適用していましたが、適用する範囲は任意に設定できます。
いくつかサンプルを用意しました。

型パラメータの適用範囲
// 引数と戻り値に適用
function identity<T>(val: T): T {
  return val;
}

// 引数に適用
function getLength<T>(ary: T[]): number {
  return ary.length;
}

// 第二引数と戻り値に適用
function createArray<T>(length: number, defaultValue: T): T[] {
  const array: T[] = [];
  for (let i = 0; i < length; i++) array.push(defaultValue);
  return array;
}

型パラメータの複数指定

なお、型パラメータは複数持つことができます。

型パラメータの複数指定(axios)
type User = {
  name: string;
  country: string;
  city: string;
}

type Address = {
  country: string;
  city: string;
}

type Person = {
  name: string;
}

function merge<T1, T2>(obj1: T1, obj2: T2): T1 & T2 {
  return { ...obj1, ...obj2 }
}

const person: Person = { name: 'hoge' };
const address: Address = { country: 'Japan', city: 'Yokohama' };
const user: User = merge(person, address);

普段利用しているライブラリを覗いてみると2つ、3つと利用していることがザラです。

ちょっぴり実務寄り?な簡単なサンプル

単純に値を返すだけでは利用イメージが沸かないと思うので、ほんの少し実践的な簡単なサンプルを考えてみます。

要件は下記のとおりとします。

  • 二つの配列から共通する要素だけ探し出して新しい配列を作る
  • 要素の型は問わず、何の配列でも使えるようにする
  • 二つの配列は同じ型の配列であることを担保する

まずは数字だけで考えてみます。

サンプル(数字だけ)
function fetchCommonElements(ary1: number[], ary2: number[]): number[] {
  return ary1.filter(item => ary2.includes(item));
}

const numAry1 = [1, 2, 3, 4, 5];
const numAry2 = [3, 5, 6];
const numRes = fetchCommonElements(numAry1, numAry2);
console.log(numRes); // [3, 5]

const strAry1 = ['a', 'b', 'c', 'd', 'e'];
const strAry2 = ['a', 'c', 'f'];
// 数字の配列ではないので渡せない
// const strRes = fetchCommonElements(strAry1, strAry2);

引数がどちらもnumber[]で制限されているため、数字の配列のみを受け付ける関数ができました。

では、ここから要素の型を問わない形にします。
といっても型パラメータを利用するだけです。簡単ですね。

サンプル(ジェネリクス利用)
function fetchCommonElements<T>(ary1: T[], ary2: T[]): T[] {
  return ary1.filter(item => ary2.includes(item));
}

const numAry1 = [1, 2, 3, 4, 5];
const numAry2 = [3, 5, 6];
const numRes = fetchCommonElements(numAry1, numAry2);
console.log(numRes); // [3, 5]

const strAry1 = ['a', 'b', 'c', 'd', 'e'];
const strAry2 = ['a', 'c', 'f'];
const strRes = fetchCommonElements(strAry1, strAry2);
console.log(strRes); // ['a', 'c']

// 第一引数と第二引数の型は同じでなければならないため、
// 文字列の配列と数字の配列では型不一致で渡せない
// const res = fetchCommonElements(strAry1, numAry1);

色々なジェネリクス

functionを用いた関数で説明してきましたが、
他にも色々利用できるのでザッと書き方だけまとめておきます。

関数
function identity<T>(val: T): T {
  return val;
}
アロー関数
const identity = <T>(val: T): T => {
  return val;
}
クラス
class Queue<T> {
  private data: T[] = [];
  add(item: T): void {
    this.data.push(item);
  }
  getAll(): T[] {
    return this.data;
  }
}
インターフェース
interface Builder<T> {
  build(args?: unknown): T[];
}
型エイリアス
type Result<T, E> = {
  success: boolean;
  data?: T;
  error?: E;
};

最後に

上手いこと活用できると実際再利用性が高まり活躍するシーンが多いと感じています。
逆にここジェネリクスの意味あるのかな、というケースも
ジェネリクスがどんなものなのか、その触りだけでも伝われば幸いです。

別記事でもう少し踏み込んだ内容書こうかなー。

77
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
77
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?