こんにちは!TypeScriptの型システムの世界へようこそ。
日々フロントエンドからバックエンドまでコードを書いていると、「クラスのインスタンスを関数の引数にどう型付けするか」という問題に直面しますよね。
他のオブジェクト指向言語(JavaやC#など)からやってきた開発者の方の多くは、とりあえずこんな風にコードを書きがちです。
// よくある書き方
function processUser(user: User) { … }
もちろんこれでも動くのですが、実はここにTypeScript特有の「構造的部分型」と「`instanceof`の限界」という、非常に重要な落とし穴が潜んでいます。
今回は、ここをクリアすればTypeScriptの型システムの基本はバッチリマスターできる、という核心部分を、優しく丁寧に紐解いていきましょう!
—
1. クラスを型として使うことの「見えない制約」
まずは、私たちがよくやりがちな例を見てみましょう。ここでは「ユーザー」を表すクラスと、それを処理する関数を定義しています。
class User {
constructor(public name: string, public age: number) {}
isAdult(): boolean {
return this.age >= 18;
}
}
// Userクラスのインスタンスを引数に取る関数
function printUserGreeting(user: User) {
console.log(`こんにちは、${user.name}さん!`);
}
const tanaka = new User(“田中”, 25);
printUserGreeting(tanaka); // 「こんにちは、田中さん!」と出力される
ここまでは「おっ、普通に動くじゃん!」と思いますよね。
しかし、ここに「テスト用のモックデータを使いたい」「APIからJSONとして飛んできたプレーンなオブジェクトをそのまま渡したい」という要件が加わった途端、このコードは牙を剥きます。
`instanceof` の限界と、クラス型が持つ「壁」
例えば、テストコードを書くときや、外部から受け取ったオブジェクトをそのまま関数に渡したいとき、こんな風に書きたくなりますよね。
// わざわざインスタンス化せずに、オブジェクトリテラルを渡したい!
printUserGreeting({ name: “佐藤”, age: 30 });
これをTypeScriptでコンパイルしようとすると、以下の残酷なエラー(コンパイルエラー)が発生します。
> Argument of type ‘{ name: string; age: number; }’ is not assignable to parameter of type ‘User’.
> Property ‘isAdult’ is missing in type ‘{ name: string; age: number; }’ but required in type ‘User’.
「あれ? `name` も `age` も持っているのに、なぜ怒られるの?」って思いませんでしたか?
これがまさに、クラスを型として使うことの限界です。
TypeScriptにおいて、`class User` は「値(コンストラクタ)」であると同時に、「そのクラスから作られたインスタンスでなければならない」という強い制約(ブランド)を型として生み出します。そのため、中身のプロパティが完全に一致していても、`new User()` で生成された実体(プロトタイプチェーン)でなければ、TypeScriptは受け入れてくれません。
—
2. 救世主:TypeScriptの本質「構造的部分型(Structural Typing)」
ここで、TypeScriptの最大の武器である「構造的部分型」の登場です。
他の多くの言語が採用している「名前的型付け(Nominal Typing)」では、「Userという名前(型)の設計図から作られたもの」しか許しません。しかし、TypeScriptは「そのオブジェクトが、必要なプロパティやメソッドを構造として持っていればOKとする」という哲学を持っています。
このTypeScriptの性質を最大限に活かすためのベストプラクティスが、「クラスではなく、Interface(インターフェース)を引数の型に採用する」というアプローチです。
Interfaceベースの型定義への移行
先ほどのコードを、Interfaceを使って書き換えてみましょう。
// 1. データの「形(構造)」だけを定義するインターフェース
interface UserProfile {
name: string;
age: number;
isAdult(): boolean; // メソッドの形も含められる
}
// 2. クラス側は、このインターフェースを「実装(implements)」する
class User implements UserProfile {
constructor(public name: string, public age: number) {}
isAdult(): boolean {
return this.age >= 18;
}
}
// 3. 関数の引数には、クラスではなく「インターフェース」を指定する!
function printUserGreeting(user: UserProfile) {
console.log(`こんにちは、${user.name}さん!`);
}
この変更によって、世界がどう変わるでしょうか?
// ── パターンA:これまで通りクラスのインスタンスを渡す ──
const tanaka = new User(“田中”, 25);
printUserGreeting(tanaka); // 完璧に動きます!
// ── パターンB:プレーンなオブジェクトリテラルを渡す ──
printUserGreeting({
name: “佐藤”,
age: 30,
isAdult: () => true
}); // なんと、これもエラーなく通過します!
// ── パターンC:テスト用のモックオブジェクトを渡す ──
const mockUser: UserProfile = {
name: “テストユーザー”,
age: 20,
isAdult: function() { return this.age >= 18; }
};
printUserGreeting(mockUser); // これも当然OK!
素晴らしいですね!関数の引数が `UserProfile` という「構造(インターフェース)」を求めるようになったおかげで、呼び出し側の自由度が劇的に向上しました。
—
3. なぜこのアプローチが実務で愛されるのか?(アーキテクトからの視点)
現場で大規模なTypeScriptアプリケーションを設計する際、私たちアーキテクトが「関数の引数にクラスを直接指定するな」と口を酸っぱくして言うのには、明確な理由があります。
1. テスト容易性の劇的な向上(Testability)
ユニットテストを書く際、わざわざ重たいクラスのインスタンスを `new` する必要がなくなります。必要なプロパティを持ったシンプルなオブジェクト(モック)をポンと渡すだけでテストが成立するため、テストコードが非常に軽快になります。
2. API連携やJSONとの親和性
バックエンドから送られてくるJSONデータは、ただのプレーンなオブジェクトです。これらをわざわざ `new APIResponseModel(…)` のようにクラスに変換しなくても、インターフェースさえ満たしていればそのままフロントエンドのビジネスロジック関数に流し込むことができます。
3. 結合度の低下(Decoupling)
関数が「特定のクラス(実装)」に依存せず、「必要なデータ構造(インターフェース)」だけに依存するようになるため、コードの変更に強くなります。
—
まとめ:ここをクリアすればTypeScriptの基本はバッチリ!
今回のポイントをサクッと振り返ってみましょう。
- `instanceof` やクラス名をそのまま型に使うと、柔軟性が失われる
(「そのクラスから作られたインスタンス」しか受け付けなくなるため、プレーンなオブジェクトやモックが使えない)
- TypeScriptの真髄は「構造的部分型」
(「名前」ではなく「中身の形」で型を判断する)
- 関数の引数にはクラスではなく `interface` や `type` を使おう
(データの構造を定義したインターフェースを引数に指定することで、クラスのインスタンスもプレーンなオブジェクトも自由自在に受け取れるようになる)
この「構造に基づく型定義」の考え方をモノにすると、TypeScriptを書く手が驚くほど軽くなります。ぜひ、明日のコードから意識して取り入れてみてくださいね。
あなたのTypeScriptライフが、より豊かで型安全なものになりますように!