TypeScriptの基礎 - ジェネリクスの基本

提供: MochiuWiki : SUSE, EC, PCB

概要

ジェネリクス (Generics) とは、型をパラメータとして受け取ることで、型安全性を保ちながらコードの再利用性を高める機能である。

ジェネリクスを使用しない場合、汎用的な関数やデータ構造を作るために any 型に頼ることが多くなり、型安全性が失われる。
ジェネリクスを使用することにより、使用時に具体的な型を指定しながらも、同じコードを様々な型に対して再利用できる。

ジェネリクスの主な用途を以下に示す。

  • 関数
    引数の型と戻り値の型を関連付けて、型安全な汎用関数を定義する。
  • インターフェース
    型パラメータを持つ汎用的なデータ構造を定義する。
  • 型エイリアス
    オブジェクト型や関数型のジェネリックな定義を作成する。
  • クラス
    型パラメータを持つ汎用的なクラスを定義する。


型パラメータ名には慣習的に T (Type)、UV 等の大文字の英字が使われるが、任意の名前を使用できる。
複数の型パラメータを扱う場合は、TU のように連続した文字を使用することが多い。

また、意味を明確にする場合は、TKeyTValue のような名前も使用される。


ジェネリック関数

基本的な構文

ジェネリック関数は、関数名の後に型パラメータを <T> の形式で記述することで定義する。
型パラメータ T は、引数の型と戻り値の型を関連付けるために使用する。

引数をそのまま返す最も基本的なジェネリック関数を以下に示す。

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


アロー関数でジェネリクスを使用する場合は、<T> を引数リストの前に記述する。

 const identity = <T>(arg: T): T => arg;


TSX (TypeScript + JSX) 環境では、<T> がJSXタグと誤解されることがある。
この問題を回避するため、型パラメータの末尾にトレーリングコンマを追加する。

 // JSX環境では <T,> のようにトレーリングコンマを追加する
 const generic = <T,>(arg: T): T => arg;


TypeScript 5.0以降では、const 型パラメータを使用して、推論される型をリテラル型として保持できる。

 // const型パラメータ - リテラル型を保持する (TypeScript 5.0以降)
 declare function fnGood<const T extends readonly string[]>(args: T): void;
 fnGood(["a", "b"]);  // T は readonly ["a", "b"] として推論される


型引数の推論

ジェネリック関数を呼び出す時、型引数を明示的に指定する方法と、コンパイラによる推論に任せる方法がある。

 function identity<T>(arg: T): T {
    return arg;
 }
 
 // 型引数を明示的に指定する
 const result1 = identity<string>("hello!");  // resultの型 : string
 
 // 型推論に任せる (推奨)
 const result2 = identity("hello!");          // resultの型 : string (自動推論)
 const result3 = identity(42);                // resultの型 : number (自動推論)


多くの場合、コンパイラは渡された引数から型を自動的に推論できる。
明示的な型指定は可読性を高める場合もあるが、型推論に任せる方が簡潔なコードになるため推奨される。

複雑な型を扱う場合 や 推論結果が意図と異なる場合に限り、明示的な型指定を使用することが望ましい。

複数の型引数

複数の型パラメータが必要な場合は、カンマ区切りで複数の型引数を定義する。

2つの引数を受け取り、順序を入れ替えたタプルを返す関数を以下に示す。

 function swap<T, U>(a: T, b: U): [U, T] {
    return [b, a];
 }
 
 const result = swap("hello", 42);  // resultの型 : [number, string]


2つの型パラメータを持つより実用的な例を以下に示す。

 function zip<T, U>(arr1: T[], arr2: U[]): [T, U][] {
    return arr1.map((item, index) => [item, arr2[index]]);
 }
 
 const zipped = zip([1, 2, 3], ["a", "b", "c"]);
 // zippedの型 : [number, string][]


複数の型引数を定義する際は、それぞれの型引数が実際に必要かどうかを確認する。

型引数が1か所でしか使用されない場合、ジェネリクスが本当に必要かどうかを再考することが推奨される。


ジェネリックインターフェース

基本的な使用方法

インターフェースにも型パラメータを定義できる。
これにより、様々な型の値を保持する汎用的なデータ構造を定義できる。

 interface Box<Type> {
    contents: Type;
 }
 
 // string型のBoxを宣言する
 let stringBox: Box<string>;
 
 // number型のBoxを宣言する
 let numberBox: Box<number>;


複数の型パラメータを持つインターフェースの例を以下に示す。

 interface Pair<K, V> {
    key: K;
    value: V;
 }
 
 const pair: Pair<string, number> = { key: "age", value: 25 };


ジェネリックインターフェースの実装

クラスがジェネリックインターフェースを実装する時は、implements キーワードとともに型パラメータを指定する。

 interface Storage<T> {
    get(): T;
    set(value: T): void;
 }
 
 class GenericStorage<T> implements Storage<T> {
    private value: T;
 
    constructor(initialValue: T) {
       this.value = initialValue;
    }
 
    get(): T {
       return this.value;
    }
 
    set(value: T): void {
       this.value = value;
    }
 }
 
 const storage = new GenericStorage<string>("初期値");
 console.log(storage.get());  // "初期値"
 storage.set("新しい値");
 console.log(storage.get());  // "新しい値"


クラスに型パラメータを宣言することにより、インスタンス化時に具体的な型を指定できる。

インターフェースとクラスの両方に同じ型パラメータを使用することで、型の整合性を保てる。


ジェネリック型エイリアス

オブジェクト型のジェネリクス

type キーワードでも型パラメータを持つ型エイリアスを定義できる。

 type Container<T> = { value: T };
 
 const stringContainer: Container<string> = { value: "hello" };
 const numberContainer: Container<number> = { value: 42 };


ユニオン型やその他の型と組み合わせて、より複雑な型を定義することもできる。

 // nullを許容するジェネリック型
 type OrNull<Type> = Type | null;
 
 // 単一の値または配列を許容するジェネリック型
 type OneOrMany<Type> = Type | Type[];
 
 const value1: OrNull<string> = "hello";
 const value2: OrNull<string> = null;
 const value3: OneOrMany<number> = 42;
 const value4: OneOrMany<number> = [1, 2, 3];


関数型のジェネリクス

型エイリアスを使用して、ジェネリックな関数型を定義できる。

 // 入力型と出力型を型パラメータとして持つマッパー関数型
 type Mapper<T, U> = (input: T) => U;
 
 // 同じ型を入力・出力とするトランスフォーマー関数型
 type Transformer<T> = (input: T) => T;
 
 // 具体的な型を指定して使用する
 const stringify: Mapper<number, string> = (n) => n.toString();
 const double: Transformer<number> = (n) => n * 2;
 
 console.log(stringify(42));  // "42"
 console.log(double(21));     // 42


ジェネリックな関数型エイリアスを使用することにより、関数のシグネチャを再利用可能な形で定義できる。

コールバック関数を受け取る高階関数の引数型定義にも活用できる。


型引数のデフォルト値

型パラメータにデフォルト値を指定することにより、型引数を省略した場合に使用する型を定義できる。

デフォルト値を持つ型パラメータは、型引数を省略した場合にデフォルト型が使用される。

 type Container<T = string> = { value: T };
 
 // 型引数を省略するとstring型が使用される
 const stringContainer: Container = { value: "hello" };
 
 // 型引数を明示的に指定する
 const numberContainer: Container<number> = { value: 42 };


型制約 (extends) とデフォルト値を組み合わせることもできる。

 // 制約とデフォルト値を組み合わせる
 interface Container<T extends string | number = string> {
    value: T;
 }
 
 // 型引数を省略するとstring型が使用される
 const defaultContainer: Container = { value: "hello" };
 
 // 制約を満たす型を指定できる
 const numberContainer: Container<number> = { value: 42 };
 
 // 制約を満たさない型はエラーになる
 // const boolContainer: Container<boolean> = { value: true };  // エラー


デフォルト値を持つ型パラメータは、デフォルト値を持たない型パラメータより後に配置する必要がある。

ジェネリックなコンポーネントやユーティリティ型において、使用頻度の高い型をデフォルトとして設定することにより、使用する側のコードを簡潔に保てる。


実用的なパターン

React Hooksでの使用例

ReactのHooksはジェネリクスを積極的に活用しており、状態の型を明示的に指定できる。

useState は型パラメータを受け取り、状態と更新関数の型を定義する。

 // useStateにジェネリクスを使用する
 const [user, setUser] = useState<User | null>(null);
 
 // 型推論が可能な場合は省略できる
 const [count, setCount] = useState(0);  // number型として推論される


useReducer では、状態型とアクション型をそれぞれ型パラメータで指定できる。
判別可能なユニオン型を使用したアクション定義との組み合わせが一般的である。

 interface State {
    count: number;
    loading: boolean;
 }
 
 type Action =
    | { type: "INCREMENT" }
    | { type: "DECREMENT" }
    | { type: "SET_LOADING"; payload: boolean };
 
 function reducer(state: State, action: Action): State {
    switch (action.type) {
       case "INCREMENT":    return { ...state, count: state.count + 1 };
       case "DECREMENT":    return { ...state, count: state.count - 1 };
       case "SET_LOADING":  return { ...state, loading: action.payload };
    }
 }
 
 // useReducerはreducerの引数・戻り値から型を推論する
 const [state, dispatch] = useReducer(reducer, { count: 0, loading: false });


カスタムフックでジェネリクスを使用することにより、汎用的なデータ取得フックを定義できる。

 interface Post {
    id: number;
    title: string;
    body: string;
 }
 
 function useFetch<T>(url: string): {
    data: T | null;
    loading: boolean;
    error: Error | null;
 }
 {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);
 
    // fetchロジック
    // ...略
 
    return { data, loading, error };
 }
 
 // 使用例 : Post型のデータを取得する
 const { data: post, loading } = useFetch<Post>("/api/posts/1");


Tauriのinvokeでの使用例

Tauriでは、invoke 関数にジェネリクスを使用して、Rustバックエンドからの戻り値の型を安全に扱える。

 import { invoke } from "@tauri-apps/api/tauri";
 
 interface UserData {
    id: number;
    username: string;
    email: string;
 }
 
 // invoke<T>でRustコマンドの戻り値の型を指定する
 async function fetchUser(userId: number): Promise<UserData> {
    return invoke<UserData>("fetch_user", { userId });
 }
 
 // 使用例
 const user = await fetchUser(1);
 console.log(user.username);  // 型安全にプロパティにアクセスできる


invoke<T> に型パラメータを指定することでコマンドの戻り値を正確な型として扱えるため、
コンパイル時に型安全性を保証しながらRustとTypeScript間のデータのやり取りを記述できる。


関連情報