みなさんTypeScriptで開発していますか?TypeScriptで開発する際はよく以下のようなものをみませんか?
<T>
これを総称型(ジェネリクス)という.TypeScriptにおける総称型,またはジェネリクスは,関数やクラス,インターフェースなどにおいて,様々な型に対応できる柔軟なコードを記述するための仕組みである.ジェネリクスを使用することで,再利用性が高く,安全性の高いコードを書くことが可能となる.本記事では,ジェネリクスの基本から応用までを詳しく解説し,初心者にも分かりやすいように様々な例を用いて説明する.
シリーズ TypeScriptで学ぶプログラミング言語の世界
Part1 手続型からオブジェクト指向へ
Part2 ORMってなんなんだ?SQLとオブジェクト指向のミスマッチを感じませんか?
Part3 プログラミングパラダイムの進化と革命:機械語からマルチパラダイムへ...
Part4 アクセス修飾子とは? public? private? protected?
他のシリーズ記事
TypeScriptを知らない人は以下の記事から.
上の記事も〇〇チートシートとしてシリーズ化しているのでぜひご覧ください.様々な言語,フレームワーク,ライブラリなど開発技術の使用方法,基本事項,応用事例を網羅し,手引書として記載したシリーズです.
git/gh,lazygit,docker,vim,typescript,SQL,プルリクエスト/マークダウン,ステータスコード,ファイル操作,OpenAI AssistantsAPI,Ruby/Ruby on Rails のチートシートがあります.以下の記事に遷移した後,各種チートシートのリンクがあります.
情報処理技術者試験合格への道[IP・SG・FE・AP]
情報処理技術者試験の単語集です.
IAM AWS User クラウドサービスをフル活用しよう!
AWSのサービスを例にしてバックエンドとインフラ開発の手法を説明するシリーズです.
なぜジェネリクスが必要なのか
従来の型指定では,特定の型に対してのみ動作する関数やクラスを作成する必要があった.例えば,配列の中身をそのまま返す関数を考えてみよう.数値の配列と文字列の配列でそれぞれ別々の関数を作るのは非効率であり,コードの重複を招く.ジェネリクスを使用することで,このような重複を避け,汎用的な関数やクラスを作成できる.
ジェネリクスを活用しているプログラミング言語の紹介
ソフトウェア開発において,ジェネリクスは型の再利用性と安全性を向上させる強力な仕組みだ.この記事では,ジェネリクスを活用している主なプログラミング言語について紹介する.
Java
JavaではジェネリクスがJava 5で導入された.これにより,特にコレクションフレームワークでの型安全性が大きく向上した.たとえば,ArrayListを使って文字列のリストを管理する場合,以下のように記述できる.
import java.util.ArrayList;
public class Main {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
for (String item : list) {
System.out.println(item);
}
}
}
このコードでは,ArrayListに文字列のみを格納することが型で保証されており,誤って他の型のデータを追加しようとするとコンパイルエラーが発生する.
C#
C#では,ジェネリクスがC# 2.0(.NET Framework 2.0)で導入された.C#のジェネリクスは,特にコレクションやカスタムデータ型で幅広く利用されている.以下は簡単なリスト操作の例だ.
using System;
using System.Collections.Generic;
class Program {
static void Main() {
List<int> numbers = new List<int>();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);
foreach (int number in numbers) {
Console.WriteLine(number);
}
}
}
この例では,Listが整数型のリストとして機能しており,型安全かつ効率的な操作を提供する.
C++
C++はジェネリクスの概念を「テンプレート」という形でC++98から提供している.テンプレートは型そのものを引数として受け取ることができ,非常に柔軟だ.以下は関数テンプレートの例である.
#include <iostream>
using namespace std;
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
cout << add(3, 5) << endl; // 整数の加算
cout << add(2.5, 4.5) << endl; // 小数の加算
return 0;
}
このコードでは,テンプレートによって整数型でも浮動小数点型でも同じ関数addを再利用できる.
Swift
Swiftではジェネリクスが言語設計の中核的な部分として組み込まれている.型安全性と柔軟性を兼ね備えたジェネリクスを簡単に記述できる.以下は配列の中から最大値を見つける関数の例だ.
func findMax<T: Comparable>(_ array: [T]) -> T? {
guard !array.isEmpty else { return nil }
return array.max()
}
let numbers = [3, 5, 1, 9]
if let maxNumber = findMax(numbers) {
print("最大値は\(maxNumber)です")
}
この例では,findMax関数がComparableプロトコルを準拠する任意の型に対応可能である.
Go
Goでは型パラメータがGo 1.18で導入された.これにより,型に依存しない汎用的なコードを記述できる.以下はスライス内の要素をフィルタリングする汎用関数の例だ.
package main
import "fmt"
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2 4]
}
この例では,Filter関数が型に依存しない形でスライスを操作している.
ジェネリクスの基本
ここからはTypeScriptを用いて総称型を説明する
ジェネリクスを使うことで,型をパラメータとして受け取ることができる.以下に基本的な使い方を示す.
function identity<T>(arg: T): T {
return arg;
}
let result = identity<number>(42);
この例では,identity関数はジェネリック型Tを受け取り,その型をそのまま返す.呼び出し時に具体的な型(この場合はnumber)を指定することで,型安全性を保ちながら柔軟な関数を実現している.
型推論
TypeScriptはジェネリック型を自動的に推論する能力を持っている.明示的に型を指定しなくても,引数から型を推論してくれる.
let inferredNumber = identity(42); // Tはnumberと推論される
let inferredString = identity('TypeScript'); // Tはstringと推論される
console.log(inferredNumber); // 出力: 42
console.log(inferredString); // 出力: 'TypeScript'
このように,TypeScriptは関数の呼び出し時に引数の型からジェネリック型を推論し,型指定を省略することができる.
ジェネリクスの利点
- 型安全性の向上: ジェネリクスを使用することで,コンパイル時に型の不整合を検出できるため,ランタイムエラーのリスクを減少させる.
- 再利用性の向上: 一つのジェネリック関数やクラスで,様々な型に対応できるため,コードの再利用性が高まる.
- 可読性の向上: 明確な型指定により,コードの意図が分かりやすくなる.
具体的な例
配列の要素を操作する関数
例えば,配列の最初の要素を取得する関数をジェネリクスを使って実装してみよう.
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
let numberArray = [1, 2, 3];
let firstNumber = getFirstElement<number>(numberArray);
console.log(firstNumber); // 出力: 1
let stringArray = ['a', 'b', 'c'];
let firstString = getFirstElement<string>(stringArray);
console.log(firstString); // 出力: 'a'
このように,getFirstElement関数はどんな型の配列にも対応できるため,コードの再利用性が高まっている.
クラスにおけるジェネリクス
ジェネリクスはクラスにも適用できる.例えば,データを格納する汎用的なContainerクラスを考えてみよう.
class Container<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
setValue(newValue: T): void {
this.value = newValue;
}
}
let numberContainer = new Container<number>(100);
console.log(numberContainer.getValue()); // 出力: 100
numberContainer.setValue(200);
console.log(numberContainer.getValue()); // 出力: 200
let stringContainer = new Container<string>('Hello');
console.log(stringContainer.getValue()); // 出力: 'Hello'
stringContainer.setValue('World');
console.log(stringContainer.getValue()); // 出力: 'World'
Containerクラスはジェネリック型Tを使用しており,任意の型の値を保持することができる.これにより,異なる型のデータを同じクラスで扱うことが可能となる.
インターフェースにおけるジェネリクス
インターフェースにジェネリクスを適用することで,より柔軟な型定義が可能となる.
interface Pair<T, U> {
first: T;
second: U;
}
let pair: Pair<number, string> = {
first: 1,
second: 'one'
};
console.log(pair.first); // 出力: 1
console.log(pair.second); // 出力: 'one'
Pairインターフェースは二つの異なる型TとUを持つプロパティを定義しており,異なる型のデータを一つのオブジェクトで管理することができる.
制約付きジェネリクス
場合によっては,ジェネリック型に対して特定の制約を設けたいことがある.例えば,lengthプロパティを持つ型に限定したい場合,extendsキーワードを使用して制約を設定できる.
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): void {
console.log(arg.length);
}
logLength([1, 2, 3]); // 出力: 3
logLength('Hello'); // 出力: 5
// logLength(10); // エラー: 型 'number' の引数は型 'Lengthwise' に割り当てられません.
この例では,logLength関数はlengthプロパティを持つ型にのみ適用可能である.これにより,不適切な型の引数が渡されるのを防ぐことができる.
ジェネリクスと型エイリアス
型エイリアスにジェネリクスを使用することもできる.
type Response<T> = {
status: number;
data: T;
};
let stringResponse: Response<string> = {
status: 200,
data: 'Success'
};
let numberResponse: Response<number> = {
status: 200,
data: 12345
};
Response型はジェネリック型Tを持ち,異なる型のデータを含むレスポンスを表現できる.
ジェネリクスの高度な使い方
複数のジェネリクス
ジェネリック型は複数使用することも可能であり,より複雑な型の関係を表現できる.
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
let mergedObject = merge({ name: 'Alice' }, { age: 30 });
console.log(mergedObject.name); // 出力: 'Alice'
console.log(mergedObject.age); // 出力: 30
merge関数は二つの異なる型TとUを受け取り,それらを組み合わせた新しい型T & Uを返す.これにより,異なる型のオブジェクトを統合することができる.
デフォルト型引数
ジェネリクスにはデフォルトの型引数を設定することができる.これにより,型引数を省略した場合にデフォルトの型が使用される.
interface Options<T = string> {
value: T;
label: string;
}
let stringOption: Options = {
value: 'Default',
label: 'String Option'
};
let numberOption: Options<number> = {
value: 42,
label: 'Number Option'
};
デフォルト型引数を使用することで,型指定を省略した場合の型をあらかじめ決めておくことができる.
条件型とジェネリクス
条件型とジェネリクスを組み合わせることで,より高度な型の操作が可能となる.
type IsString<T> = T extends string ? 'Yes' : 'No';
type Test1 = IsString<string>; // 'Yes'
type Test2 = IsString<number>; // 'No'
条件型を使用することで,型に基づいた条件分岐を行うことができる.ジェネリクスと組み合わせることで,柔軟な型定義が可能となる.
Mapped Typesとジェネリクス
マップドタイプとジェネリクスを組み合わせることで,既存の型から新しい型を生成することができる.
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
interface User {
name: string;
age: number;
}
type ReadonlyUser = Readonly<User>;
let user: ReadonlyUser = {
name: 'Bob',
age: 25
};
// user.name = 'Alice'; // エラー: Cannot assign to 'name' because it is a read-only property.
この例では,Readonly型エイリアスはジェネリック型Tを受け取り,全てのプロパティをreadonlyにした新しい型を生成する.
ジェネリック制約と型推論の組み合わせ
ジェネリック制約を使用することで,より安全な型推論を行うことができる.
interface HasId {
id: number;
}
function getById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
interface User extends HasId {
name: string;
}
let users: User[] = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
let user = getById(users, 1);
console.log(user?.name); // 出力: 'Alice'
この例では,getById関数はHasIdインターフェースを拡張した型Tに制約されているため,idプロパティが存在することが保証されている.これにより,関数内部で安全にidプロパティにアクセスできる.
ジェネリクスを用いたユーティリティタイプの実装
TypeScriptの標準ユーティリティタイプの多くはジェネリクスを利用して実装されている.例えば,Partial型やPick型などがある.
type Partial<T> = {
[P in keyof T]?: T[P];
};
interface Todo {
title: string;
description: string;
}
type PartialTodo = Partial<Todo>;
let todo: PartialTodo = {
title: 'Learn TypeScript'
};
Partial型は,指定した型の全てのプロパティをオプショナルにする型を生成する.
高度なジェネリクスの例: 高階ジェネリクス
高階ジェネリクスとは,ジェネリック型を受け取るジェネリック型のことを指す.これにより,より柔軟で抽象的な型定義が可能となる.
type Transformer<T, U> = (input: T) => U;
function transformArray<T, U>(arr: T[], transformer: Transformer<T, U>): U[] {
return arr.map(transformer);
}
let numbers = [1, 2, 3];
let strings = transformArray(numbers, num => num.toString());
console.log(strings); // 出力: ['1', '2', '3']
この例では,Transformer型はジェネリック型TとUを受け取り,TをUに変換する関数の型を定義している.transformArray関数は,ジェネリック型TとUを受け取り,Tの配列をUの配列に変換する.
ジェネリクスの動作の仕組み
TypeScriptのジェネリクスは,コンパイル時に型情報を利用して型安全性を確保し,ランタイムには影響を与えない.ジェネリクスは型レベルの機能であり,実際のJavaScriptコードにはジェネリック型の情報は存在しない.TypeScriptは型情報をコンパイル時に消去(Type Erasure)するため,ジェネリック型によるオーバーヘッドはない.
型推論と型パラメータの解決
ジェネリクスを使用する際,TypeScriptは関数呼び出し時の引数やコンテキストから型パラメータを推論する.型パラメータが複数存在する場合や複雑な依存関係がある場合,TypeScriptの型推論アルゴリズムが適切に型を解決する.
型の再利用と一貫性
ジェネリクスは,型の再利用と一貫性を提供する.複数の場所で同じジェネリック型パラメータを使用することで,関連する型間の関係性を保ちながら柔軟に型を扱うことができる.
ジェネリクスのベストプラクティス
- 1.明確な型パラメータ名を使用する: T,U,Vなどの一般的な名前よりも,具体的な意味を持つ名前を使用することで,コードの可読性が向上する.
interface Repository<Entity, ID> {
getById(id: ID): Entity | null;
save(entity: Entity): void;
}
-
2.必要な場合にのみジェネリクスを使用する: ジェネリクスは強力な機能だが,過度に使用するとコードが複雑になる可能性がある.汎用性が必要な場合に適切に使用することが重要である.
-
3.制約を適切に設定する: ジェネリック型に制約を設けることで,意図しない型の使用を防ぎ,型安全性を高める.
-
4.デフォルト型引数を活用する: デフォルト型引数を設定することで,ジェネリック型の使用を簡略化し,柔軟性を向上させる.
-
5.ユーティリティタイプを活用する: TypeScriptが提供するユーティリティタイプを活用することで,ジェネリクスを用いた型操作を効率的に行うことができる.
追加の実例と応用
ジェネリック関数でのデフォルト型引数
function createArray<T = string>(length: number, value: T): T[] {
return new Array(length).fill(value);
}
let stringArray = createArray(3, 'hello');
console.log(stringArray); // 出力: ['hello', 'hello', 'hello']
let numberArray = createArray<number>(3, 42);
console.log(numberArray); // 出力: [42, 42, 42]
デフォルト型引数を設定することで,型を明示的に指定しない場合にデフォルトの型が使用される.
ジェネリック型を用いた関数オーバーロード
function reverse<T>(items: T[]): T[];
function reverse(items: any[]): any[] {
return items.reverse();
}
let reversedNumbers = reverse([1, 2, 3]);
console.log(reversedNumbers); // 出力: [3, 2, 1]
let reversedStrings = reverse(['a', 'b', 'c']);
console.log(reversedStrings); // 出力: ['c', 'b', 'a']
関数オーバーロードとジェネリクスを組み合わせることで,異なる型の引数に対して適切な戻り値の型を提供できる.
ジェネリッククラスでの継承
class BaseCollection<T> {
protected items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return this.items;
}
}
class UniqueCollection<T> extends BaseCollection<T> {
add(item: T): void {
if (!this.items.includes(item)) {
this.items.push(item);
}
}
}
let numbers = new UniqueCollection<number>();
numbers.add(1);
numbers.add(2);
numbers.add(2);
console.log(numbers.getAll()); // 出力: [1, 2]
UniqueCollectionクラスはBaseCollectionクラスを継承し,要素の重複を防ぐ機能を追加している.ジェネリクスを用いることで,BaseCollectionとUniqueCollectionは任意の型に対して再利用可能である.
ジェネリックインターフェースの継承
interface Entity {
id: number;
}
interface User extends Entity {
name: string;
}
interface Repository<T extends Entity> {
getById(id: number): T | null;
save(entity: T): void;
}
class UserRepository implements Repository<User> {
private users: User[] = [];
getById(id: number): User | null {
return this.users.find(user => user.id === id) || null;
}
save(entity: User): void {
this.users.push(entity);
}
}
let userRepo = new UserRepository();
userRepo.save({ id: 1, name: 'Alice' });
console.log(userRepo.getById(1)); // 出力: { id: 1, name: 'Alice' }
ジェネリックインターフェースを使用することで,異なるエンティティに対するリポジトリを統一的に扱うことができる.
ジェネリック型とユニオン型
type Result<T> = { success: true; data: T } | { success: false; error: string };
function fetchData<T>(url: string): Promise<Result<T>> {
return fetch(url)
.then(response => response.json())
.then(data => ({ success: true, data }))
.catch(() => ({ success: false, error: 'Failed to fetch data' }));
}
interface User {
id: number;
name: string;
}
fetchData<User>('https://api.example.com/user/1')
.then(result => {
if (result.success) {
console.log(result.data.name);
} else {
console.error(result.error);
}
});
Result型は成功時と失敗時の両方のケースを表現するユニオン型であり,ジェネリクスを用いることで成功時のデータ型を柔軟に指定できる.
ジェネリック型の制約と条件型の組み合わせ
type NonNullableProperties<T> = {
[P in keyof T]: T[P] extends null | undefined ? never : P
}[keyof T];
interface Person {
name: string;
age?: number;
address: string | null;
}
type NonNullablePersonProps = NonNullableProperties<Person>; // 'name'
この例では,NonNullableProperties型エイリアスは,指定された型Tのうちnullまたはundefinedではないプロパティ名のみを抽出する.ジェネリクスと条件型を組み合わせることで,より精密な型操作が可能となる.
ジェネリック型を用いたフックの実装(Reactの例)
Reactでカスタムフックを作成する際にもジェネリクスは有用である.
import { useState } from 'react';
function useToggle(initialValue: boolean): [boolean, () => void];
function useToggle<T>(initialValue: T): [T, (value: T) => void];
function useToggle<T>(initialValue: T): [T, (value: T) => void] {
const [state, setState] = useState<T>(initialValue);
const toggle = (value: T) => {
setState(value);
};
return [state, toggle];
}
// 使用例
const [isVisible, setIsVisible] = useToggle(false);
const [count, setCount] = useToggle<number>(0);
この例では,useToggleフックはジェネリック型を用いて,異なる型の状態管理を可能にしている.これにより,フックの再利用性が向上し,様々な型の状態を一貫して管理できる.
ジェネリクスのパフォーマンスへの影響
TypeScriptのジェネリクスはコンパイル時の型チェックにのみ影響を与え,実行時にはJavaScriptコードに変換される際にジェネリック型の情報は消去されるため,ランタイムパフォーマンスには影響を与えない.したがって,ジェネリクスを適切に使用しても,パフォーマンスの低下を心配する必要はない.
ジェネリクスの制限と注意点
- 1.型情報の消失: ジェネリクスはコンパイル時の型チェックにのみ存在し,実行時には型情報が消失するため,実行時に型に基づいた操作は行えない.
- 2.複雑な型定義: ジェネリクスを過度に使用すると,型定義が複雑になり,コードの可読性が低下する可能性がある.
- 3.型推論の限界: TypeScriptの型推論は強力だが,複雑なジェネリック型や条件型を使用すると,期待通りに型が推論されない場合がある.そのため,明示的に型を指定する必要が生じることがある.
TypeScriptのジェネリクスは,型安全性を保ちながら柔軟で再利用可能なコードを記述するための強力なツールである.関数やクラス,インターフェースにおいてジェネリクスを適用することで,様々な型に対応可能な汎用的な構造を作成できる.制約を設けることで,さらに安全性を高めることも可能であり,複雑な型の関係性を表現する際にも有効である.ジェネリクスの理解と活用は,TypeScriptを効果的に使用するために欠かせないスキルであり,初心者から上級者まで幅広く役立つ.実際にコードを書いてジェネリクスに慣れることで,その威力を実感できるだろう.