11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[TypeScriptシリーズ - Part 8] Structural vs Nominal Typing

11
Last updated at Posted at 2026-04-22

📝 注記
私は日本語が得意ではありません。この記事はAIの翻訳サポートを受けて書いています。ご了承ください。

📖 目次

  1. 問題の提示 – どんな時にこのテクニックが必要か
  2. 悪い例 – まずはダメなコードを見せる
  3. 良い例 – TypeScriptの高度機能で解決する
  4. Playgroundリンク – その場で試せる
  5. 課題 – シニア向けのチャレンジ問題
  6. まとめ

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); // 🤔 これは許可されるべき?されないべき?

問題点:

  • UserCustomer構造がまったく同じだが、意味は異なる
  • 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 によって UserIdOrderId が区別されていることが視覚的に分かります。


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: 名前的振る舞いの実装

UserIdProductId を区別する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は非常に柔軟で表現力豊かです。インターフェースを明示的に実装しなくても、構造が合えば自動的に互換性があります。これはテストのモッキングや段階的な型付けを容易にします。

しかし、同じ構造を持つ異なる概念(UserCustomerUserIdOrderId)を誤って混同するリスクもあります。そのような場合は Branded Types を使って名前的な振る舞いを追加することを検討しましょう。

ルール: デフォルトでは構造的部分型の恩恵を受ける。本当に区別が必要な場所だけBranded Typesを使う。

Have a nice day! 🚀

11
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?