【TypeScript】「コンストラクタ関数」を型安全に飼い慣らす。new制約とConstructorParametersを極限まで活用した依存注入・ファクトリ設計
フロントエンドからバックエンドまで、大規模なアプリケーションを設計する際、避けて通れないのが「クラスの動的生成(ファクトリパターンやDI: 依存性注入)」です。
しかし、多くの現場で見かけるコードにおいて、コンストラクタ関数(クラスそのもの)を引数として受け取る設計は、型安全性の「最大の抜け穴」になりがちです。
「とりあえず `Function` や `any` で受けておこう」
「`typeof MyClass` を使ってみたが、抽象クラスを渡されてランタイムで落ちた」
このような妥協やバグは、TypeScriptの型システムが持つ「newシグネチャ」の本質を理解していないことに起因します。
今回は、開発プロジェクトのテクニカルリードとしての視点から、「引数として受け取るコンストラクタ関数」の型定義を極限まで厳密に定義し、ランタイムエラーをコンパイル段階で完全に撲滅する設計手法を伝授します。
—
1. アンチパターンの解剖:なぜ `Function` や `typeof` では不十分なのか
まずは、コードレビューで即却下すべき、脆く危険なコードから見ていきましょう。
危険なパターン1:`Function` 型の乱用
// 避けるべき実装:何でも受け入れてしまうファクトリ
function createService(ServiceClass: Function) {
// コンパイルは通るが、実行時に new できない関数や
// 引数が全く異なるクラスが渡されても検知できない
return new (ServiceClass as any)();
}
なぜ非効率(かつ危険)なのか?
`Function` 型は、JavaScriptのあらゆる呼び出し可能オブジェクト(アロー関数、通常の関数、クラス、ジェネレータなど)を受け入れてしまいます。
コンストラクタとして呼び出す(`new` を適用する)ことが不可能なオブジェクトが渡された場合、実行時に `TypeError: ServiceClass is not a constructor` を吐いてクラッシュします。型安全性を放棄していると言わざるを得ません。
危険なパターン2:特定の具象クラスに依存した `typeof` の密結合
class UserService {
constructor(public id: string) {}
}
// 避けるべき実装:柔軟性がなく、再利用できない
function createUserService(ServiceClass: typeof UserService, id: string) {
return new ServiceClass(id);
}
なぜ非効率なのか?
これでは `UserService` またはその厳密なサブクラスしか受け取れません。
「共通のインターフェースを持つ異なるサービス(例:`MockUserService` や `PremiumUserService`)」をポリモーフィカルに受け取ることができず、ファクトリ関数としての価値を失っています。
—
2. 核心:`new` シグネチャ(Constructor Signature)の完全理解
TypeScriptで「インスタンス化可能(`new` 可能)なコンストラクタ関数」を厳密に表現するには、newシグネチャを使用します。
基本的なnewシグネチャの構文
// T型のインスタンスを生成する、引数を持たないコンストラクタの型
type Constructor
この定義は、「`new` 演算子を適用して呼び出すことができ、その結果として `T` 型のインスタンスを返すオブジェクト」を意味します。
これに引数の型を連動させ、ジェネリクスを用いて抽象化を極限まで高めたのが、以下の定義です。
type GenericConstructor
抽象クラス(abstract class)の罠と回避策
実務では、基底クラスを `abstract class`(抽象クラス)として定義することが多々あります。
しかし、抽象クラスは `new` できません。
abstract class BaseLogger {
abstract log(message: string): void;
}
// エラー:抽象クラスは ‘new’ シグネチャを持つ型に代入できない
const LoggerClass: new () => BaseLogger = BaseLogger;
TypeScriptは、`new () => T` という型に対して抽象クラスを渡すことを許しません。インスタンス化できないからです。
もし「抽象クラスのコンストラクタ(またはそれを継承した具象クラスのコンストラクタ)」を型レベルで許容したい場合は、`abstract new` シグネチャを使用します。
// 抽象クラスも具象クラスも許容する型定義
type AbstractConstructor
この挙動の違いを理解し、「インスタンス化(`new`)を行うファクトリには具象コンストラクタ型(`new`)を要求し、クラスのメタ情報を参照するだけの場所には抽象コンストラクタ型(`abstract new`)を要求する」という設計の撃ち分けが極めて重要です。
—
3. 実戦プロダクションコード:型安全なDIコンテナ・ファクトリ
それでは、実務でそのまま使える堅牢なコンポーネントファクトリの実装例を示します。
ここでは、以下の要件をコンパイルレベルで完全保証します。
1. 渡されたクラスのコンストラクタ引数の型と数(`ConstructorParameters
2. 生成されるインスタンスの型(`InstanceType
3. 抽象クラスが渡された場合は、コンパイルエラーとして即座に弾く。
/
- 1. サービス層のインターフェースと抽象クラス定義
/
interface Service {
initialize(): Promise
}
// 抽象クラス(これ自体は new できない)
abstract class AbstractApiService implements Service {
constructor(public readonly endpoint: string, public readonly timeout: number) {}
abstract initialize(): Promise
}
/
- 2. 具象クラスの実装
/
class UserApiService extends AbstractApiService {
async initialize(): Promise
console.log(`[UserApiService] Initialized for ${this.endpoint} (timeout: ${this.timeout}ms)`);
}
}
class ProductApiService extends AbstractApiService {
async initialize(): Promise
console.log(`[ProductApiService] Initialized for ${this.endpoint} (timeout: ${this.timeout}ms)`);
}
}
/
- 3. 極限まで型安全性を高めたジェネリック・ファクトリ関数
- @template C – 具象クラスのコンストラクタ型。newシグネチャを持つことを要求。
- @param ConcreteClass – 生成対象のクラスそのもの
- @param args – ConcreteClass のコンストラクタが要求する引数のタプル型(自動追従)
- @returns ConcreteClass から生成されたインスタンス(正確な型を維持)
/
function safeComponentFactory
ConcreteClass: C,
…args: ConstructorParameters
): InstanceType
try {
return new ConcreteClass(…args);
} catch (error) {
throw new Error(`[FactoryError] Failed to instantiate ${ConcreteClass.name}: ${error}`);
}
}
// ==========================================
// 実行例と型検証
// ==========================================
// 成功ケース:引数の型、数、順序が完全に一致しているためコンパイルが通る
const userService = safeComponentFactory(UserApiService, “https://api.example.com/v1”, 5000);
// 推論された型: const userService: UserApiService
userService.initialize();
// 失敗ケース1:引数の型が異なる(コンパイルエラー)
// @ts-expect-error: Argument of type ‘string’ is not assignable to parameter of type ‘number’.
const badService1 = safeComponentFactory(UserApiService, “https://api.example.com/v1”, “5000”);
// 失敗ケース2:引数の数が足りない(コンパイルエラー)
// @ts-expect-error: Expected 2 arguments, but got 1.
const badService2 = safeComponentFactory(UserApiService, “https://api.example.com/v1”);
// 失敗ケース3:抽象クラスを直接渡そうとする(コンパイルエラー)
// @ts-expect-error: Cannot assign an abstract constructor type to a non-abstract constructor type.
const badService3 = safeComponentFactory(AbstractApiService, “https://api.example.com/v1”, 5000);
この設計の美しさと優位性
この `safeComponentFactory` の最大の強みは、「クラスのコンストラクタシグネチャを変更した際、ファクトリを呼び出している全コードに即座に型エラーが伝播し、修正漏れを防げる」点にあります。
仮に `UserApiService` のコンストラクタに第3引数として `retryCount: number` を追加した場合、型定義を手動でメンテナンスすることなく、すべての呼び出し元で「引数が足りない」というコンパイルエラーが発生します。
—
4. ディープダイブ:型推論と共変・反変(Covariance & Contravariance)
TypeScriptコンパイラがコンストラクタ関数をどのように型評価しているか、その深淵に迫りましょう。
コンストラクタの型を扱う際、「引数は反変(Contravariant)であり、戻り値は共変(Covariant)である」という型システムの基本原則が牙を剥きます。
type BaseCtor = new (arg: string) => Object;
type SubCtor = new (arg: any) => string;
let base: BaseCtor;
let sub: SubCtor = class { constructor(arg: any) { return “hello”; } };
// 代入可能か?
base = sub; // OK!
なぜこれが許されるのか。
1. 戻り値の共変性: `string`(`SubCtor` の戻り値)は `Object`(`BaseCtor` の戻り値)のサブタイプであるため、より具体的な型を返す関数は安全に代入できます。
2. 引数の反変性: `any`(`SubCtor` の引数)は `string`(`BaseCtor` の引数)のスーパータイプです。より広い範囲の入力を受け付ける関数は、より狭い範囲を想定している場所に安全に代入できます。
この原則があるため、ファクトリ関数を設計する際は、引数の型を `any[]` や `unknown[]` で曖昧に扱わず、ジェネリクス `C` でコンストラクタそのものの型を丸ごとキャプチャし、`ConstructorParameters
—
5. まとめとアーキテクトからのアドバイス
実務における「コンストラクタ関数の受け渡し」は、以下のルールを徹底してください。
1. `Function` は禁止: ランタイムエラーの温床であり、TypeScriptの導入意義を自ら破壊する行為。
2. `new (…args: any[]) => T` を基本とする: インスタンス化を伴うファクトリには、この具象newシグネチャを強制する。
3. `ConstructorParameters
4. 抽象クラスには `abstract new`: ポリモーフィズムを実現するメタプログラミングでは、抽象コンストラクタ型を適切に使い分ける。
型システムは、単に「エラーを出さないための制約」ではありません。
「コードがどう動くべきかという設計図(仕様)を、コンパイラと開発者の間で寸分の狂いもなく共有するための言語」です。
コンストラクタを型安全に飼い慣らし、リファクタリングにびくともしない堅牢なアーキテクチャを築き上げてください。