クラスインスタンスを引数にするな:`instanceof` の呪縛を解き、構造的部分型で堅牢なフロントエンドを築く方法
テックリードの私だ。今日のコードレビューで、若手がこんなコードを書いてきた。
class PostgreSQLUserRepo {
save(user: User) { / … / }
}
class UserService {
constructor(private repo: PostgreSQLUserRepo) {}
register(user: User) {
if (!(this.repo instanceof PostgreSQLUserRepo)) {
throw new TypeError(“Invalid repository”);
}
this.repo.save(user);
}
}
「おい、ちょっと待て」と私は止めた。
一見すると、型安全で堅牢なコードに見えるかもしれない。しかし、TypeScriptのコンパイラと型システムの挙動、そして実務におけるスケーラビリティを本気で理解している者からすれば、このコードには「悪臭(Smell)」が漂っている。
今回は、クラスインスタンスを関数の引数や依存性として受け取る際の罠、`instanceof` が持つ致命的な限界、そしてTypeScriptの真骨頂である「構造的部分型(Structural Typing)」を活かしたインターフェースベースの設計への移行について、徹底的に解説しよう。
—
1. なぜ `instanceof` はTypeScriptにおいて「アンチパターン」なのか?
JavaScriptのランタイムにおいて、`instanceof` はプロトタイプチェーンを辿ってオブジェクトの出自を検証する正当な手段だ。しかし、TypeScriptの静的型システムと組み合わせた瞬間、それは百害あって一利なしの足枷と化す。
① 構造的部分型(Structural Typing)への背信
TypeScriptは、JavaやC#のような公称型(Nominal Typing)ではなく、構造的部分型を採用している。「アヒルのように歩き、アヒルように鳴くなら、それはアヒルだ」というDuck Typingの思想を静的に解決する仕組みだ。
クラスを引数の型に指定するということは、そのクラスの「実装(implementation)」までをもコードベースに強要することになる。これはTypeScriptの柔軟性を自らドブに捨てる行為に他ならない。
② モジュール境界とバンドラの罠(`instanceof` がすり抜ける瞬間)
フロントエンド開発において、次のようなケースで `instanceof` は簡単に破綻する。
- Monorepo構成(npm workspacesやTurborepoなど)で、パッケージ間の依存関係が重複したとき
- WebpackやViteなどのバンドラが、同じクラス定義を別モジュールとして二重にバンドルしたとき
- VitestやJestなどのテスト環境において、モジュールのモック化(`vi.mock`等)を行ったとき
ランタイムの参照が異なるため、「型は一致しているのに `instanceof` が `false` を返す」という、デバッグ泣かせの怪奇現象が発生する。静的型チェックをパスしたのに、実行時チェックで弾かれる地獄の完成だ。
—
2. 理想郷:インターフェースによる抽象化と構造的部分型
では、どうすべきか?答えはシンプルだ。「クラス」という具象ではなく、「振る舞い(契約)」である「インターフェース」を引数の型に指定するのだ。
次のプロダクションコードを見てほしい。フロントエンドのAPIクライアントや、複雑な状態管理ストアの依存性注入(DI)を想定した実用的な設計だ。
/
- 1. 振る舞い(契約)をインターフェースで定義する
/
export interface HttpCacheStrategy {
get
set
clear(): Promise
}
/
- 2. 具象クラスA:Redis等のインメモリキャッシュ(フロントエンドのモック等)
/
export class MemoryCache implements HttpCacheStrategy {
private store = new Map
async get
const item = this.store.get(key);
if (!item || Date.now() > item.expiry) return null;
return item.data as T;
}
async set
this.store.set(key, { data: value, expiry: Date.now() + ttlMs });
}
async clear(): Promise
this.store.clear();
}
}
/
- 3. 具象クラスB:LocalStorageラッパー(全く異なる実装)
/
export class LocalStorageCache implements HttpCacheStrategy {
async get
const raw = localStorage.getItem(key);
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async set
localStorage.setItem(key, JSON.stringify(value));
}
async clear(): Promise
localStorage.clear();
}
}
/
- 4. 消費者(Consumer):インターフェースのみに依存する
- `instanceof` は一切不要。構造が合致していれば何でも受け入れられる。
/
export class UserPreferencesService {
// コンストラクタでは具体的なクラスではなく、インターフェースを受け取る
constructor(private cache: HttpCacheStrategy) {}
async getUserTheme(userId: string): Promise
const cached = await this.cache.get
if (cached) return cached;
// APIフェッチのフォールバック(簡略化)
const theme = “dark”;
await this.cache.set(`theme:${userId}`, theme);
return theme;
}
}
この設計がもたらす圧倒的なアドバンテージ
1. `instanceof` の排除: ランタイムのプロトタイプチェーンに依存しないため、バンドル時のモジュール重複やテスト時のモック化トラブルが完全に消滅する。
2. ダックタイピングの恩恵: 極端な話、`HttpCacheStrategy` を `implements` と明示的に宣言していなくても、同じプロパティとメソッドを持つプレーンなオブジェクト(POJO)さえあれば、このサービスクラスに突っ込むことができる。
3. テスト容易性(Testability)の爆発的な向上:
// テスト時はスタブオブジェクトをそのまま渡せる(クラスのインスタンス化すら不要)
const mockCache: HttpCacheStrategy = {
get: async () => “light”,
set: async () => {},
clear: async () => {}
};
const service = new UserPreferencesService(mockCache);
—
3. パフォーマンスとコンパイル時の型評価への影響
チーフアーキテクトとして、パフォーマンスの話もしなければならない。
TypeScriptのクラスは、JavaScriptにトランスパイルされるとそのまま `class` 構文(ES2015以降)またはコンストラクタ関数に変換される。
一方で、TypeScriptの `interface` は、コンパイル後にコードベースから跡形もなく消え去る(Zero-Cost Abstraction)。
- メモリフットプリントとバンドルサイズ:
インターフェースや型エイリアスはランタイムのJavaScriptコードを出力しない。不要なクラスのインスタンスチェックやポリモーフィズムの強制を排除することで、余計なヘルパー関数が生成されるのを防ぎ、バンドルサイズを最小化できる。
- コンパイラ(tsc)の型チェック効率:
コンパイラはクラスの継承ツリーや `instanceof` の関係性を解決する際、プロパティの互換性だけでなく型の階層構造を追跡するためにより多くのメモリと時間を消費する。構造的部分型に則ったインターフェースの比較は、TypeScriptコンパイラにとっても非常に親和性が高く、型チェックの高速化(Incremental Compilationの効率化)に寄与する。
—
4. 例外:どうしても `instanceof` やクラスが必要なケース
もちろん、全否定はしない。次のようなドメイン特化型のケースでは、クラスや `instanceof` が正解になることもある。
1. カスタムエラー(Custom Error)のハンドリング:
`try / catch` の `catch (e)` で得られるエラーは `unknown` であり、特定の例外クラスで分岐させたい場合。
if (e instanceof ApiRateLimitError) {
// リトライ処理へ
}
2. ドメインモデルでの不変条件(Invariants)の強制とカプセル化:
コンストラクタでバリデーションを行い、不正な状態のインスタンスが絶対に存在できないようにするValue Objectなど。
しかし、これらはあくまで「データのコンテナ」や「例外機構」の話であり、ビジネスロジックを処理するサービス層やコンポーネントの依存性において、具象クラスを直接引数に指定する理由にはならない。
—
結び:コードレビューの基準を一段引き上げろ
今日から、チームのコードレビューでクラスのインスタンスを引数に取っているコードを見つけたら、こう問いかけてほしい。
> 「その引数、本当にそのクラスでなければならないのか? インターフェースで抽象化できないのか?」
`instanceof` に頼る設計は、コードを硬直しさせ、変更に対する耐性を奪う。構造的部分型を理解し、インターフェースによる疎結合な設計をマスターすることこそが、長期的な保守性に耐えうるモダンなTypeScriptアプリケーションの生命線なのだ。
さあ、今すぐ君のコードベースにある `instanceof` を洗い出し、美しいインターフェースへとリファクタリングするとしよう。