TypeScript | 関数・クラス・ジェネリクス:クラス設計 – クラスを返す関数

TypeScript TypeScript
スポンサーリンク

ゴール:「クラスも“値”として扱えて、関数から返せる」と腑に落とす

まず一番大事なポイントはこれです。

クラスは「型」でもあり、「値」でもある

ということです。

「型」としては User 型などに使い、
「値」としては User という“コンストラクタ関数”として扱えます。

「クラスを返す関数」というのは、
この「クラス=値」の側面を使って、

「関数の戻り値として“クラス(コンストラクタ)”を返す」

というテクニックです。


基本:クラスは「コンストラクタ関数」という“値”でもある

クラスを変数に入れてみる

まずは、クラスを「値」として扱う感覚をつかみましょう。

class User {
  constructor(public name: string) {}

  greet(): void {
    console.log(`こんにちは、${this.name}です`);
  }
}

const UserClass = User; // クラスを変数に代入

const u = new UserClass("Taro");
u.greet();
TypeScript

ここでやっていることは、

User というクラス(コンストラクタ)を
UserClass という変数に代入しているだけです。

UserClass は「new できる値」です。

この時点で、

「クラスは、他の値と同じように“渡したり返したりできる”」

という感覚を持ってください。

型として書くとどう見えるか

「クラスという値」の型は、ざっくり言うと「コンストラクタの型」です。

type UserConstructor = {
  new (name: string): User;
};

const C: UserConstructor = User;
const u = new C("Hanako");
TypeScript

new (name: string): User というのは、

new で呼べて、User を返す関数」

という意味です。

この「コンストラクタの型」を理解しておくと、
「クラスを返す関数」の型付けがスッと入ってきます。


一番シンプルな「クラスを返す関数」

条件によって返すクラスを変える

まずは、戻り値としてクラスを返すだけのシンプルな例から。

class Dog {
  speak(): void {
    console.log("ワン");
  }
}

class Cat {
  speak(): void {
    console.log("ニャー");
  }
}

type AnimalConstructor = {
  new (): { speak(): void };
};

function getAnimalClass(kind: "dog" | "cat"): AnimalConstructor {
  if (kind === "dog") {
    return Dog;
  } else {
    return Cat;
  }
}

const Animal = getAnimalClass("dog");
const a = new Animal();
a.speak(); // "ワン"
TypeScript

ここでのポイントを整理します。

関数 getAnimalClass は、

kind に応じて、DogCat の“クラスそのもの”を返す」

関数です。

戻り値の型 AnimalConstructor は、

new () できて、speak() を持つオブジェクトを返すコンストラクタ」

という意味です。

呼び出し側は、

  1. まずクラスを受け取る(const Animal = getAnimalClass(...)
  2. そのクラスを new してインスタンスを作る(new Animal()

という二段階の流れになります。

ここで大事なのは、

「関数の戻り値として“クラス(コンストラクタ)”を返している」

という構造を意識することです。


応用1:クラスを“加工して”返す(Mixin 的な使い方)

元のクラスに機能を足したクラスを返す

「クラスを返す関数」が一気に面白くなるのは、
元のクラスに何かを“足して”新しいクラスを返すときです。

いわゆる Mixin パターンに近い使い方です。

class Person {
  constructor(public name: string) {}
}

type Constructor<T = {}> = new (...args: any[]) => T;

function withTimestamp<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    createdAt = new Date();
  };
}

const TimestampedPerson = withTimestamp(Person);

const p = new TimestampedPerson("Taro");
console.log(p.name);       // Person のプロパティ
console.log(p.createdAt);  // 追加されたプロパティ
TypeScript

ここで起きていることを分解します。

withTimestamp は、

「Base というクラスを受け取り、
それを継承して createdAt プロパティを追加した“新しいクラス”を返す関数」

です。

つまり、

「クラスを受け取って、クラスを返す関数」

になっています。

このとき、戻り値は「Base を継承した無名クラス」です。

const TimestampedPerson = withTimestamp(Person); の時点で、
TimestampedPerson は「Person に createdAt が足されたクラス」になっています。

型パラメータで「元のクラスの型」を引き継ぐ

ジェネリクスを使っているのは、
「元のクラスのコンストラクタやプロパティの型を引き継ぐ」ためです。

type Constructor<T = {}> = new (...args: any[]) => T;

function withTimestamp<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    createdAt = new Date();
  };
}
TypeScript

TBase extends Constructor によって、

「Base はコンストラクタである」

という制約をかけています。

こうしておくことで、

  • withTimestamp(Person) と書いたときに
    Person のコンストラクタ引数(name: string)がそのまま使える
  • 戻り値のクラスは「Person の機能+createdAt」を持つ型になる

という、型的に気持ちいい設計になります。

ここが少し難しいところですが、
本質はシンプルで、

「クラスを受け取って、クラスを返すことで“機能を足したクラス”を作っている」

という構造を理解できれば十分です。


応用2:設定に応じて“カスタマイズされたクラス”を返す

関数の引数でクラスの振る舞いを変える

「クラスを返す関数」は、
設定値を受け取って“カスタマイズされたクラス”を作るのにも使えます。

type LogLevel = "debug" | "info" | "error";

function createLoggerClass(level: LogLevel) {
  return class Logger {
    log(message: string): void {
      console.log(`[${level}] ${message}`);
    }
  };
}

const DebugLogger = createLoggerClass("debug");
const ErrorLogger = createLoggerClass("error");

const d = new DebugLogger();
const e = new ErrorLogger();

d.log("細かい情報");
e.log("致命的なエラー");
TypeScript

ここでは、

createLoggerClass が、

level に応じて、そのレベルを内部に“閉じ込めた Logger クラス”を返す」

関数になっています。

DebugLoggerErrorLogger は、
それぞれ別々のクラスですが、
どちらも log メソッドを持っています。

このパターンのポイントは、

「関数の引数でクラスの“性格”を決め、その結果としてクラスを返す」

というところです。

「設定ごとにクラスを分けたいけど、
ロジックは共通にしたい」というときに便利です。


型の観点から見る「クラスを返す関数」

戻り値の型は「コンストラクタ型」で表現する

「クラスを返す関数」の戻り値の型は、
基本的に「コンストラクタの型」で表現します。

例えば、さっきの Logger の例なら、

type LoggerConstructor = {
  new (): { log(message: string): void };
};

function createLoggerClass(level: LogLevel): LoggerConstructor {
  return class {
    log(message: string): void {
      console.log(`[${level}] ${message}`);
    }
  };
}
TypeScript

こう書いておくと、

  • createLoggerClass(...) の戻り値は「new () できて log を持つクラス」
  • 呼び出し側は const C = createLoggerClass(...); const c = new C(); と書ける

ということが型で保証されます。

大事なのは、

「クラスを返す関数の戻り値は、“new できる値”として型付けする」

という視点です。

ジェネリクスと組み合わせると「元のクラスの型」を保てる

Mixin の例のように、

「元のクラスを受け取って、拡張したクラスを返す」

場合は、ジェネリクスで元の型を引き継ぐのが定番です。

type Constructor<T = {}> = new (...args: any[]) => T;

function withFlag<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    isActive = true;
  };
}
TypeScript

withFlag の戻り値は、

「Base の機能+isActive を持つクラス」

という型になります。

ここまで来ると少し高度ですが、
本質はやはり、

「クラスを受け取って、クラスを返す」

という構造です。


まとめ:「クラスを返す関数」を自分の言葉で整理すると

最後に、あなた自身の言葉でこうまとめてみてください。

クラスは「型」であると同時に、「コンストラクタという値」でもある。
だから、関数の戻り値として「クラス(コンストラクタ)」を返すことができる。
その結果として、

条件によって返すクラスを変えたり、
元のクラスに機能を足したクラスを返したり、
設定に応じてカスタマイズされたクラスを返したりできる。

型としては、「new (...) => ...」というコンストラクタ型で表現する。
ジェネリクスと組み合わせると、「元のクラスの型を保ったまま拡張したクラス」を返せる。

まずは、

「クラスを変数に代入して、関数からその変数を返す」
「条件によって DogCat のクラスを返す」

くらいのシンプルなところから、
実際に手を動かしてみると感覚がつかめてきます。

タイトルとURLをコピーしました