📝 注記
私は日本語が得意ではありません。この記事はAIの翻訳サポートを受けて書いています。ご了承ください。
📖 目次
- 問題の提示 – どんな時にこのテクニックが必要か
- 悪い例 – まずはダメなコードを見せる
- 良い例 – TypeScriptの高度機能で解決する
- Playgroundリンク – その場で試せる
- 課題 – シニア向けのチャレンジ問題
- まとめ
1. 問題の提示 – どんな時にこのテクニックが必要か
あなたはTypeScriptで大規模なアプリケーションを開発しています。以下のようなコードに出会ったとします。
class User {
id: string;
name: string;
email: string;
}
class Customer {
id: string;
name: string;
email: string;
}
function sendEmailToUser(user: User) {
console.log(`Sending email to ${user.email}`);
}
const customer = new Customer();
sendEmailToUser(customer); // 🤔 これは許可されるべき?されないべき?
問題点:
-
UserとCustomerは構造がまったく同じだが、意味は異なる - JavaやC#ではエラーになるが、TypeScriptではエラーにならない
- なぜTypeScriptはこれを許可するのか?
- 同じ構造でも区別したい場合はどうすればいいのか?
問いかけ:
構造的部分型(Structural Typing) と 名前的部分型(Nominal Typing) の違いを理解すれば、TypeScriptの型システムの本質が見えてきます。
2. 悪い例 – まずはダメなコードを見せる
2.1 意図しない型互換性
// ❌ 構造が同じなら何でも代入できてしまう
class Product {
id: number;
name: string;
price: number;
}
class OrderItem {
id: number;
name: string;
price: number;
}
function calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
const products: Product[] = [
{ id: 1, name: "Laptop", price: 1000 },
{ id: 2, name: "Mouse", price: 50 }
];
// 😱 Productの配列をOrderItemの配列として渡せる!
const total = calculateTotal(products); // 1050
// ProductとOrderItemは概念的に全く異なるのに...
2.2 過剰な互換性による問題
// ❌ 意図しないプロパティの互換性
interface Point2D { x: number; y: number; }
interface Point3D { x: number; y: number; z: number; }
function drawPoint(p: Point2D) {
console.log(`Drawing at (${p.x}, ${p.y})`);
}
const point3D: Point3D = { x: 10, y: 20, z: 30 };
drawPoint(point3D); // ✅ 許可される(余計なプロパティは無視される)
// 便利な場合もあるが、意図しないデータの受け渡しを許してしまう
2.3 クラス名の違いを無視
// ❌ クラス名が違っても構造が同じならOK
class UserID {
constructor(public value: string) {}
}
class OrderID {
constructor(public value: string) {}
}
function getUserById(id: UserID): any {
return { id: id.value, name: "Alice" };
}
const orderId = new OrderID("ORD-123");
const user = getUserById(orderId); // 😱 コンパイルエラーにならない!
// UserIDとOrderIDは全く別の概念なのに...
なぜ悪いのか:
| 問題 | 説明 |
|---|---|
| 意図しない結合 | 構造が同じなら別の概念でも結合されてしまう |
| バグの混入リスク | 商品と注文明細など、混同すべきでないものを混同 |
| ドメイン境界の曖昧化 | 値オブジェクトの意味が型システムに反映されない |
| リファクタリングの困難 | 片方の型を変更すると、意図せず別の型も影響を受ける |
3. 良い例 – TypeScriptの高度機能で解決する
基本: 構造的部分型(Structural Typing)とは?
TypeScriptは構造的部分型を採用しています。これは、型の互換性を「名前」ではなく「構造(shape)」で判断するという考え方です。
// ✅ TypeScriptの基本原則: 構造が同じなら互換性がある
interface Person {
name: string;
age: number;
}
interface Employee {
name: string;
age: number;
employeeId: string;
}
function greet(person: Person) {
console.log(`Hello, ${person.name}`);
}
const employee: Employee = { name: "Alice", age: 30, employeeId: "E123" };
greet(employee); // ✅ OK: EmployeeはPersonの構造を「含んでいる」
// TypeScriptは「余計なプロパティがあっても問題ない」と判断する
ユースケース1: 構造的部分型の利点
// ✅ 柔軟性とコード再利用性の向上
interface HasName {
name: string;
}
function logName(item: HasName) {
console.log(item.name);
}
class User { constructor(public name: string, public email: string) {} }
class Product { constructor(public name: string, public price: number) {} }
class Org { constructor(public name: string, public taxId: string) {} }
// すべてのクラスがHasNameを明示的に実装していなくても使える!
logName(new User("Alice", "alice@example.com")); // ✅ OK
logName(new Product("Laptop", 1000)); // ✅ OK
logName(new Org("Tech Corp", "123-456")); // ✅ OK
// JavaやC#では、すべてのクラスが明示的にinterfaceを実装する必要がある
// TypeScriptでは構造が合えば自動的に互換性がある
ユースケース2: Duck Typingとの関係
// ✅ 「ダックタイピング」をTypeScriptで型安全に表現する
interface Quackable {
quack(): void;
}
function makeItQuack(thing: Quackable) {
thing.quack(); // 型安全!
}
class Duck {
quack() { console.log("Quack!"); }
}
class Dog {
bark() { console.log("Woof!"); }
quack() { console.log("I'm a fake duck!"); } // quackメソッドを持っている
}
makeItQuack(new Duck()); // ✅ OK
makeItQuack(new Dog()); // ✅ OK(quackメソッドがあるから)
ユースケース3: 名前的部分型(Nominal Typing)が必要な場合
// ✅ Branded Typesを使って「名前的」な振る舞いを追加する
type Brand<T, B extends string> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;
function getUserById(id: UserId): any {
return { id: id as string, name: "Alice" };
}
function getOrderById(id: OrderId): any {
return { id: id as string, total: 100 };
}
const userId = "user_123" as UserId;
const orderId = "order_456" as OrderId;
getUserById(userId); // ✅ OK
getOrderById(orderId); // ✅ OK
// ❌ これらはエラーになる(意図した通り!)
// getUserById(orderId); // エラー
// getOrderById(userId); // エラー
ユースケース4: 他言語との比較(参考)
📝 参考: 以下はJava/C#とTypeScriptの考え方の違いを示す概念的な比較です。
Java / C#(名前的部分型):
class Foo { method(): number { return 42; } }
class Bar { method(): number { return 42; } }
Foo foo = new Bar(); // ❌ エラー(名前が違う)
TypeScript(構造的部分型):
class Foo { method(): number { return 42; } }
class Bar { method(): number { return 42; } }
const foo: Foo = new Bar(); // ✅ OK(構造が同じ)
Flow(Facebook製型チェッカー)は混合アプローチ:
- オブジェクトリテラル → 構造的部分型
- クラス → 名前的部分型
ユースケース5: 静的型付け vs 動的型付け
// TypeScriptは「静的型付け」: 型チェックはコンパイル時に行われる
// 動的型付け(JavaScript): ランタイムまで何が起こるかわからない
// function add(a, b) { return a + b; }
// add(1, 2); // 3
// add("hello", "world"); // "helloworld" ← 意図しない結果
// 静的型付け(TypeScript): コンパイル時に型エラーを検出
function add(a: number, b: number): number {
return a + b;
}
add(1, 2); // ✅ 3
// add("hello", "world"); // ❌ コンパイルエラー!
ユースケース6: 型互換性の詳細ルール
// ルール1: オブジェクト型 — 必要なプロパティを含んでいれば互換
interface HasName { name: string; }
const obj1 = { name: "Alice", age: 30 };
const named: HasName = obj1; // ✅ 余分なプロパティがあってもOK
// const obj2 = { firstName: "Alice" };
// const named2: HasName = obj2; // ❌ 必要なプロパティが足りない
// ルール2: 関数の戻り値 → 共変(より狭い型を返せる)
type StringProducer = () => string;
type StringOrNumProducer = () => string | number;
const f1: StringOrNumProducer = (() => "hello") satisfies StringProducer;
// ✅ stringを返す関数を string | number を返す関数として使える(共変)
// ルール3: 関数の引数 → strictFunctionTypes有効時は反変
// (詳細はPart 4 Varianceを参照)
ユースケース7: 実践的なユースケース
// 1. テストのモッキング
interface Database {
query(sql: string): any[];
}
// テスト用のモック(interfaceを明示的に実装しなくてもOK!)
const mockDatabase = {
query: (sql: string) => [{ id: 1, name: "Test" }]
};
function getUsers(db: Database) {
return db.query("SELECT * FROM users");
}
getUsers(mockDatabase); // ✅ 構造が合っているので渡せる
// 2. 段階的な型付け
interface PartialUser { name: string; }
interface FullUser extends PartialUser { email: string; age: number; }
function logUserName(user: PartialUser) {
console.log(user.name);
}
const fullUser: FullUser = { name: "Alice", email: "alice@example.com", age: 30 };
logUserName(fullUser); // ✅ FullUserはPartialUserの構造を含んでいるのでOK
// 3. Reactのprops(型安全な例)
interface ButtonProps {
label: string;
onClick: () => void;
}
// ✅ ButtonPropsを満たすオブジェクトなら何でも渡せる
const btnProps: ButtonProps = {
label: "Click me",
onClick: () => console.log("Clicked")
};
4. Playgroundリンク – その場で試せる
理論だけでは実感しにくいので、実際に動かして確認してみましょう。
TypeScript Playgroundはブラウザ上でTypeScriptを実行できる公式ツールです。インストール不要、すぐに試せます。
🔗 Playground URL: https://www.typescriptlang.org/play/
何を確認できるのか?
下のコードをコピーしてPlaygroundに貼り付けた後、コメントアウトされた ❌ の行のコメントを外してみてください。どの代入が許可され、どれがエラーになるかが視覚的に確認できます。
// ① 構造的部分型の基本
class User { constructor(public name: string, public email: string) {} }
class Customer { constructor(public name: string, public email: string) {} }
function sendEmail(contact: { name: string; email: string }) {
console.log(`Sending email to ${contact.email}`);
}
// どちらも渡せる!
sendEmail(new User("Alice", "alice@example.com")); // ✅
sendEmail(new Customer("Bob", "bob@example.com")); // ✅
sendEmail({ name: "Charlie", email: "c@example.com" }); // ✅
// ② 名前的振る舞いが必要な場合(Branded Types)
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }
const userId = "user_123" as UserId;
const orderId = "order_456" as OrderId;
getUser(userId); // ✅ OK
getOrder(orderId); // ✅ OK
// getUser(orderId); // ❌ コメントを外してエラーを確認しよう
// ③ オブジェクトの互換性
interface Point2D { x: number; y: number; }
interface Point3D { x: number; y: number; z: number; }
const p3: Point3D = { x: 1, y: 2, z: 3 };
const p2: Point2D = p3; // ✅ 余計なプロパティは無視される
ホバーすると何が見える?
p2 にホバーすると Point2D 型として認識されていることが確認できます。❌ のコメントを外すと、Branded Types によって UserId と OrderId が区別されていることが視覚的に分かります。
5. 課題 – シニア向けのチャレンジ問題
課題1: 構造的部分型の挙動予測
以下のコードの型チェック結果を予想してください。
interface A { a: number; }
interface B { a: number; b: string; }
interface C { a: number; c: boolean; }
let a: A;
let b: B;
let c: C;
a = b; // ケース1
b = a; // ケース2
a = c; // ケース3
c = a; // ケース4
💡 ヒント: 代入先の型が必要とするプロパティを、代入元が「すべて持っているか」で判断します。
✅ 解答を見る(クリック)
a = b; // ✅ OK — bはaのすべてのプロパティ(a)を持つ
b = a; // ❌ エラー — aはbのプロパティbを持たない
a = c; // ✅ OK — cはaのすべてのプロパティ(a)を持つ
c = a; // ❌ エラー — aはcのプロパティcを持たない
課題2: 名前的振る舞いの実装
UserId と ProductId を区別するBranded Typeを実装し、以下の呼び出しのうちエラーになるべきものを型安全に検出してください。
function getUser(id: UserId): any { /* ... */ }
function getProduct(id: ProductId): any { /* ... */ }
// どれがエラーになるべきか?
getUser("123");
getProduct("456");
getUser(productId);
getProduct(userId);
💡 ヒント:
Brand<T, B>ユーティリティ型を定義してから各ID型を作ります。
✅ 解答を見る(クリック)
type Brand<T, B extends string> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
function getUser(id: UserId): any { return { id: id as string }; }
function getProduct(id: ProductId): any { return { id: id as string }; }
const userId = "123" as UserId;
const productId = "456" as ProductId;
getUser(userId); // ✅ OK
getProduct(productId); // ✅ OK
// getUser("123"); // ❌ エラー: stringはUserIdに代入できない
// getUser(productId); // ❌ エラー: ProductIdはUserIdに代入できない
// getProduct(userId); // ❌ エラー: UserIdはProductIdに代入できない
課題3: 関数の型互換性
以下の関数型の互換性を答えてください。
⚠️ 前提:
tsconfig.jsonで"strictFunctionTypes": trueが有効な場合の挙動です。
type Handler1 = (value: string) => string;
type Handler2 = (value: string | number) => string;
type Handler3 = (value: string) => string | number;
let h1: Handler1;
let h2: Handler2;
let h3: Handler3;
h1 = h2; // ケース1
h2 = h1; // ケース2
h1 = h3; // ケース3
h3 = h1; // ケース4
💡 ヒント: 引数は反変(より広い型を受け取れる)、戻り値は共変(より狭い型を返せる)です。詳細はPart 4 Varianceを参照してください。
✅ 解答を見る(クリック)
h1 = h2; // ❌ エラー — 引数は反変。h2はstring|numberを受け取るが、
// h1の呼び出し元はstringしか渡さない保証がないため危険
h2 = h1; // ✅ OK — h1はstringのみ受け取る。h2の呼び出し元がstring|numberを
// 渡しても、h1はstringの部分しか使わないので安全
h1 = h3; // ❌ エラー — 戻り値は共変。h3はstring|numberを返す可能性があるが、
// h1の呼び出し元はstringを期待しているため危険
h3 = h1; // ✅ OK — h1はstringを返す。h3の呼び出し元はstring|numberを受け入れるので安全
課題4(ボーナス): 構造的部分型の活用
log メソッドを持つ任意のオブジェクトを受け取り、メッセージを記録する logMessage 関数を実装してください。余計なプロパティがあっても動作することを確認してください。
💡 ヒント:
interface Loggerを定義し、そのインターフェースを引数の型として使います。
✅ 解答を見る(クリック)
interface Logger {
log(message: string): void;
}
function logMessage(logger: Logger, message: string) {
logger.log(message);
}
// 様々な実装
class ConsoleLogger {
log(message: string) { console.log(`[CONSOLE] ${message}`); }
}
class FileLogger {
log(message: string) { console.log(`[FILE] ${message}`); }
flush() { /* 余計なメソッドがあってもOK */ }
}
const customLogger = {
log: (msg: string) => console.log(`[CUSTOM] ${msg}`),
level: "info", // 余計なプロパティ
timestamp: Date.now() // 余計なプロパティ
};
// すべて動作する!
logMessage(new ConsoleLogger(), "Hello");
logMessage(new FileLogger(), "World");
logMessage(customLogger, "TypeScript!");
6. まとめ
今日学んだこと
| 概念 | 説明 | TypeScriptでの扱い |
|---|---|---|
| 構造的部分型 (Structural Typing) | 型の互換性を「構造」で判断 | ✅ 基本動作 |
| 名前的部分型 (Nominal Typing) | 型の互換性を「名前」で判断 | Branded Typesで実現可能 |
| 静的型付け (Static Typing) | コンパイル時に型チェック | ✅ TypeScriptの特徴 |
| 動的型付け (Dynamic Typing) | 実行時に型チェック | JavaScriptの特徴 |
| Duck Typing | 「振る舞い」が合えば互換 | TypeScriptで型安全に実現可能 |
| Branded Types | 名前的振る舞いをエミュレート | ✅ 必要な場合に実装 |
シニアへのアドバイス
構造的部分型のおかげで、TypeScriptは非常に柔軟で表現力豊かです。インターフェースを明示的に実装しなくても、構造が合えば自動的に互換性があります。これはテストのモッキングや段階的な型付けを容易にします。
しかし、同じ構造を持つ異なる概念(
UserとCustomer、UserIdとOrderId)を誤って混同するリスクもあります。そのような場合は Branded Types を使って名前的な振る舞いを追加することを検討しましょう。ルール: デフォルトでは構造的部分型の恩恵を受ける。本当に区別が必要な場所だけBranded Typesを使う。
Have a nice day! 🚀