ゴール:「クラスも“値”として扱えて、関数から返せる」と腑に落とす
まず一番大事なポイントはこれです。
クラスは「型」でもあり、「値」でもある
ということです。
「型」としては 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");
TypeScriptnew (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 に応じて、Dog か Cat の“クラスそのもの”を返す」
関数です。
戻り値の型 AnimalConstructor は、
「new () できて、speak() を持つオブジェクトを返すコンストラクタ」
という意味です。
呼び出し側は、
- まずクラスを受け取る(
const Animal = getAnimalClass(...)) - そのクラスを
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();
};
}
TypeScriptTBase 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 クラス”を返す」
関数になっています。
DebugLogger と ErrorLogger は、
それぞれ別々のクラスですが、
どちらも 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;
};
}
TypeScriptwithFlag の戻り値は、
「Base の機能+isActive を持つクラス」
という型になります。
ここまで来ると少し高度ですが、
本質はやはり、
「クラスを受け取って、クラスを返す」
という構造です。
まとめ:「クラスを返す関数」を自分の言葉で整理すると
最後に、あなた自身の言葉でこうまとめてみてください。
クラスは「型」であると同時に、「コンストラクタという値」でもある。
だから、関数の戻り値として「クラス(コンストラクタ)」を返すことができる。
その結果として、
条件によって返すクラスを変えたり、
元のクラスに機能を足したクラスを返したり、
設定に応じてカスタマイズされたクラスを返したりできる。
型としては、「new (...) => ...」というコンストラクタ型で表現する。
ジェネリクスと組み合わせると、「元のクラスの型を保ったまま拡張したクラス」を返せる。
まずは、
「クラスを変数に代入して、関数からその変数を返す」
「条件によって Dog か Cat のクラスを返す」
くらいのシンプルなところから、
実際に手を動かしてみると感覚がつかめてきます。
