KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

他言語経験者が知っておきたいTypeScriptのクラスの注意点

はじめに

こんにちは、岩佐 幸翠(@kosui_me)です。カケハシで認証基盤・ライセンス基盤・組織階層基盤などのプラットフォームシステムを開発・運用する認証権限基盤チームのテックリードをしています。

TypeScriptのクラス構文は、一見するとJavaやC#などの言語と非常に似ていますが、その背景にあるJavaScriptの特性により、振る舞いに重要な違いが存在します。これらの違いを理解することは、これまでの経験を活かしつつ、TypeScriptで堅牢なアプリケーションを構築する上で非常に重要です。

本記事では、主にJavaやC#など、クラスベースの静的型付け言語に慣れ親しんだエンジニアの方々を対象に、TypeScriptでクラスを扱う際に特に留意すべきポイントを解説します。さらに、クラスを用いない関数型のアプローチについても触れ、TypeScriptにおけるドメインモデリングの多様な選択肢を提示します。

参考資料

なぜ振る舞いに違いが生まれるのか

他の静的型付け言語での豊富な開発経験を持つエンジニアがTypeScriptを使い始めると、時として直感的でない挙動に遭遇することがあります。

  • 「型定義と異なるオブジェクトが、エラーなく代入できてしまうのはなぜだろう」
  • 「クラスのメソッドをコールバックとして渡すと、thisが参照できなくなるのはなぜだろう」
  • privateで宣言したプロパティに、実行時にアクセスできてしまうのはなぜだろう」

これらの現象は、TypeScriptがJavaScriptへの漸近的な型付けを目指した言語であることに起因しています。

例えば、JavaScriptのクラスは、Javaのような言語のクラスを完全に模倣したものではなく、JavaScriptというプロトタイプベースのオブジェクト指向言語に導入されたシンタックスシュガーに近いといえます。

次の章では、TypeScriptのクラスを利用する上で押さえておきたい4つの注意点と、それぞれの実践パターンについて詳しく解説します。

押さえておきたい 4 つのポイントと実践パターン

1. 型システムの特性:構造的部分型

TypeScriptの型システムにおける最も大きな特徴の1つが、構造的部分型の採用です。これは、JavaやC#で採用されている、クラス名などの「名前」で型の互換性を判断する公称的部分型とは異なる考え方です。

構造的部分型では、オブジェクトの構造(プロパティやメソッドの型定義)が一致していれば、互換性があるとみなされます。

TypeScriptで構造的部分型を採用した背景は、公式ドキュメントにて次のように説明されています。

TypeScript’s structural type system was designed based on how JavaScript code is typically written. Because JavaScript widely uses anonymous objects like function expressions and object literals, it’s much more natural to represent the kinds of relationships found in JavaScript libraries with a structural type system instead of a nominal one.

TypeScriptの構造的型システムは、JavaScriptのコードが一般的にどのように書かれるかに基づいて設計されました。JavaScriptでは関数式やオブジェクトリテラルのような匿名のオブジェクトが広く使われているため、JavaScriptのライブラリに見られるような関係性を表現するには、公称的型システムではなく構造的型システムを用いる方がはるかに自然なのです。

TypeScript: Documentation - Type Compatibility

具体例:構造が同じであれば代入可能になるケース

例えば、UserProductという、ドメイン上は全く関連のない2つのクラスを考えてみましょう。

import { randomUUID } from "node:crypto";

class User {
  id: string;
  name: string;

  constructor(id: string, name: string) {
    this.id = id;
    this.name = name;
  }
}

class Product {
  id: string;
  name: string;

  constructor(id: string, name: string) {
    this.id = id;
    this.name = name;
  }
}

const sortByUserId = (users: ReadonlyArray<User>): ReadonlyArray<User> =>
  // 比較ロジックはこの例の本筋ではないため `localeCompare` を使用する
  [...users].sort((a, b) => a.id.localeCompare(b.id));

const user = new User(randomUUID(), "田中");
const product = new Product(randomUUID(), "商品A");

console.log(sortByUserId([user, product]));

sortByUserId関数はUserが持つ{id: string, name: string}という構造を持つオブジェクトを期待しており、UserProductは共にその条件を満たすため、型エラーは発生しません。

TypeScriptの公式ドキュメントでは、クラスの型の互換性について次のように説明しています。

Classes work similarly to object literal types and interfaces with one exception: they have both a static and an instance type. When comparing two objects of a class type, only members of the instance are compared. Static members and constructors do not affect compatibility.

クラスは、オブジェクトリテラル型やインターフェースと似たように動作しますが、1つだけ例外があります。それは、静的側とインスタンス側の両方の型を持つという点です。クラス型の2つのオブジェクトを比較する場合、インスタンスのメンバーのみが比較されます。静的メンバーとコンストラクターは、互換性に影響を与えません。

https://www.typescriptlang.org/docs/handbook/type-compatibility.html

構造的部分型をうまく活用すれば、データの詰め替えをせずに異なるドメインのオブジェクトを同じ関数に渡すことができるため、コードの再利用性が高まります。

しかし、ドメインの異なるIDや名前が意図せず混入するリスクも考えられます。上記の例では、Product を返すべきAPIから User の情報を返してしまうこともあり得ます。これは非常に深刻な情報漏洩のリスクを伴います。

実践パターン:Branded Typesで意図的に互換性をなくす

より厳密に型検査したい場合、Branded Typesと呼ばれるテクニックが有効です。これは、型レベルでユニークな「ブランド」を付与し、公称的部分型のような振る舞いを実現する手法です。

import { randomUUID } from "node:crypto";

// --- ユーティリティ型を定義 ---
type Brand<K, T extends symbol> = K & { [k in T]: true };

// --- ブランド付きの型を定義 ---
const UserIdSymbol = Symbol();
type UserId = Brand<string, typeof UserIdSymbol>;
type User = Readonly<{
  id: UserId;
  name: string;
}>;

const ProductIdSymbol = Symbol();
type ProductId = Brand<string, typeof ProductIdSymbol>;
type Product = Readonly<{
  id: ProductId;
  name: string;
}>;

const sortByUserId = (users: ReadonlyArray<User>): ReadonlyArray<User> =>
  [...users].sort((a, b) => a.id.localeCompare(b.id));

const user = {
  id: randomUUID() as UserId,
  name: "田中"
} as const satisfies User;

const product = {
  id: randomUUID() as ProductId,
  name: "商品A"
} as const satisfies Product;

sortByUserId([user]); // OK

// 型検査時にエラー! 型のブランドが異なるため代入できない
// Error: Type '{ readonly id: ProductId; readonly name: "商品A"; }' is not assignable to type 'Readonly<{ id: UserId; name: string; }>'.
sortByUserId([product]);

この手法は型定義を少し複雑にしますが、ドメインの境界を静的に保証し、システムの安全性を大幅に向上させることができます。

さらなる実践パターン: Zod によるスキーマの定義

Brand 型への変換をする場合、必ず事前条件を検証するべきです。しかし、上記の方法では as による型注釈に依存しているため、誤った型注釈を発生させるリスクを伴います。そこで、Zod などのスキーマ検証ライブラリを利用することで、「インスタンス化するための事前条件」と「インスタンス化後の Brand 型への変換」を同時に実行できます。

import { randomUUID } from "node:crypto";
import { z } from "zod";

const userIdSym = Symbol();
const UserId = z.uuid().brand(userIdSym);
type UserId = z.infer<typeof UserId>;

const User = z.object({
  id: UserId,
  name: z.string(),
}).readonly();
type User = z.infer<typeof User>;

const productIdSym = Symbol();
const ProductId = z.uuid().brand(productIdSym);
type ProductId = z.infer<typeof ProductId>;

const Product = z.object({
  id: ProductId,
  name: z.string(),
}).readonly();
type Product = z.infer<typeof Product>;

const sortByUserId = (users: ReadonlyArray<User>): ReadonlyArray<User> =>
  [...users].sort((a, b) => a.id.localeCompare(b.id));

const user = User.parse({
  id: randomUUID(),
  name: "田中",
});

const product = Product.parse({
  id: randomUUID(),
  name: "商品A"
});

sortByUserId([user]); // OK

// 型検査時エラー! 型のブランドが異なるため代入できない
// Error: Type 'Readonly<{ id: string & zod.$brand<typeof productIdSym>; name: string; }>' is not assignable
// to type 'Readonly<{ id: string & zod.$brand<typeof userIdSym>; name: string; }>'.
sortByUserId([product]);

特定のスキーマに従ってデータを生成するファクトリを定義し、option-tneverthrowなどのResult型を提供するライブラリと接続することで、既存コードとの相互運用も容易になります。

import assert from 'node:assert'
import {Result} from 'option-t/plain_result/namespace'
import z from 'zod'

export type ZodTypeFactory<T extends z.ZodType> = Readonly<{
  zodType: T
  new: (value: z.input<T>) => Result.Result<z.infer<T>, z.ZodError<z.input<T>>>
  unsafeNew: (value: z.input<T>) => z.infer<T>
}>

export const ZodTypeFactory = {
  new: <T extends z.ZodType>(zodType: T): ZodTypeFactory<T> => {
    const safeNew = (value: z.input<T>): Result.Result<z.infer<T>, z.ZodError<z.input<T>>> => {
      const res = zodType.safeParse(value)
      if (!res.success) {
        return Result.createErr(res.error)
      }
      return Result.createOk(res.data)
    }

    return {
      zodType,
      new: safeNew,
      unsafeNew: (value: z.input<T>): z.infer<T> => zodType.parse(value),
    } as const
  },
} as const

// 使用例

import {ulid} from 'ulidx'
import z from 'zod'
import {ZodTypeFactory} from '../zodTypeFactory'

const userIdSym = Symbol()
const zodType = z.string().ulid().brand(userIdSym).describe('ユーザーID')
const factory = ZodTypeFactory.new(zodType)

export type UserId = z.infer<typeof zodType>
export const UserId = {
  ...factory,
  generate: (): UserId => factory.unsafeNew(ulid()),
  compare: (a: UserId, b: UserId): number => a.localeCompare(b),
} as const

2. this の振る舞いとコンテキスト

JavaやC#では、thisは常にそのメソッドが属するインスタンスを指します。一方、JavaScript/TypeScriptのthisは、関数の呼び出され方(実行コンテキスト)によって束縛対象が動的に決まります。

問題が起こりやすい例:メソッドをコールバックとして渡す

class Uploader {
  private storage = "/tmp";

  // 通常のメソッドとして定義
  upload(fileName: string) {
    console.log(`Uploading ${fileName} to ${this.storage}...`);
  };
}

const uploader = new Uploader();

// どこかのAPIがコールバック関数を要求すると仮定
const api = { execute: (callback: (fileName: string) => void) => callback("document.pdf") };

// メソッドをそのまま渡すと、実行時に`this`のコンテキストが失われる
api.execute(uploader.upload);
// Uncaught TypeError: Cannot read properties of undefined (reading 'storage')

実践パターン:アロー関数で this を静的に束縛する

この問題に対する解決策の1つは、メソッドをアロー関数形式でクラスプロパティとして定義することです。アロー関数内のthisは、定義された場所のthis(この場合はクラスインスタンス)に静的に束縛されます。ちなみに、このような静的に決定されるスコープをレキシカルスコープと呼びます。クラス内でコールバックとして渡される可能性のあるメソッドは、アロー関数で定義することを検討しましょう。

ただし、この方法では呼び出し側が「呼び出されるクラスがどう実装しているか」を意識する必要しなければなりません。理想的には、それぞれの関数のシグニチャさえ合致していれば、どのように実装されているかは気にしなくて良い状態が望ましいです。

class Uploader {
  private storage = "/tmp";

  // アロー関数でメソッドをプロパティとして定義
  public upload = (fileName: string) => {
    console.log(`Uploading ${fileName} to ${this.storage}...`);
  };
}

const uploader = new Uploader();
const api = { execute: (callback: (fileName: string) => void) => callback("document.pdf") };

// `this`が束縛されているため、どこで実行されても問題ない
api.execute(uploader.upload); // 出力: Uploading document.pdf to /tmp...

注意が必要な実践パターン:呼び出し側でケアする

呼び出し側でthisのコンテキストを明示的に指定する方法もあります。具体的には、Function.prototype.bindメソッドを使用して、メソッドを呼び出す際にthisを固定できます。

ただし、この Function.prototype.bind は引数に any 型を受け取るため (TypeScript/src/lib/es5.d.ts at v5.9.2)、堅牢なアプリケーション開発では推奨されません。以下のコードは型検査を通過しますが、実行時にエラーが発生します。

class Uploader {
  private storage = "/tmp";

  upload(fileName: string) {
    console.log(`Uploading ${fileName} to ${this.storage}...`);
  };
}

const uploader = new Uploader();
const api = { execute: (callback: (fileName: string) => void) => callback("document.pdf") };

// `bind`を使って`this`を固定する
api.execute(uploader.upload.bind(uploader)); // 出力: Uploading document.pdf to /tmp...

// `bind`に`undefined`を渡す
// [ERR]: Cannot read properties of undefined (reading 'storage') 
api.execute(uploader.upload.bind(undefined));

または、単に呼び出し側でアロー関数を使ってthisを束縛する方法もあります。

class Uploader {
  private storage = "/tmp";

  upload(fileName: string) {
    console.log(`Uploading ${fileName} to ${this.storage}...`);
  };
}

const uploader = new Uploader();
const api = { execute: (callback: (fileName: string) => void) => callback("document.pdf") };

// アロー関数を使って`this`を束縛する
api.execute(() => uploader.upload());

さらなる実践パターン:データ型と振る舞いを分離する

他の方法として、クラスを利用せずにデータ型と振る舞いを分離する方法があります。これにより、this を経由した内部状態へのアクセスから脱却できます。なお、内部状態の隠蔽が容易ではないというトレードオフがあるものの、私の経験では実際に問題となることは多くありません。

type Uploader = Readonly<{
  storage: string;
}>;
const Uploader = {
  new: (storage: string): Uploader => ({ storage }),
  upload: (uploader: Uploader) => (fileName:string): void => {
    console.log(`Uploading ${fileName} to ${uploader.storage}...`);
  },
} as const;

const uploader = Uploader.new("/storage");
const api = { execute: (callback: (fileName: string) => void) => callback("document.pdf") };
api.execute(Uploader.upload(uploader));

ちなみに、このように型とオブジェクトリテラルへ同じ名前 Uploader を与える設計パターンをコンパニオンオブジェクトパターンと呼びます。型とオブジェクトリテラルを同じ名前にして export することで、Uploader を利用する側は、データ型と振る舞いを同じ名前で参照できるため、可読性が向上します。

import { Uploader } from './uploader';

type Dependencies = Readonly<{
  uploader: Uploader
}>;

type Input = Readonly<{
  fileName: string;
}>;

type UseCase = Readonly<{
  run: (input: Input) => void;
}>;

const UseCase = {
  from: ({ uploader }: Dependencies) => ({
    run: ({ fileName }: Input): void => {
      Uploader.upload(uploader)(fileName);
    }
  })
} as const;

3. アクセス修飾子 (private) の有効範囲

TypeScriptのprivate修飾子は、静的な型検査においてのみ有効です。トランスパイルされたJavaScriptコードには、アクセス制限を強制する仕組みは(デフォルトでは)含まれません。

それに加えて、TypeScriptでは型検査でエラーとなったとしても、JavaScriptコードの生成自体は行われます。

そのため、例えばCI/CDなどで型検査のエラーをハンドルせずに生成されたコードを実行してしまった場合、実行時にはprivateプロパティにアクセスできてしまうことがあります。

具体例:実行時にはアクセスが可能

class Account {
  private balance: number = 10000;
}

const myAccount = new Account();

// TypeScriptのコンパイラはここでエラーを出す
// console.log(myAccount.balance); // Error: Property 'balance' is private...

// しかし、型チェッカーをバイパスすると(例: `any`型を使う)、実行時にはアクセスできてしまう
const untypedAccount: any = myAccount;
console.log(untypedAccount.balance); // 出力: 10000

実践パターン:ECMAScript のプライベートフィールド (#) を使用する

ランタイムレベルで厳密なプライベート性を保証したい場合は、ECMAScriptの標準機能であるプライベートフィールド (#)を使用することが推奨されます。

class Account {
  // `#`を接頭辞につけることで、真のプライベートフィールドになる
  #balance: number = 10000;

  public getBalance() {
    return this.#balance;
  }
}

const myAccount = new Account();
console.log(myAccount.getBalance()); // 出力: 10000

// このコードは型検査時にも、実行時にもエラーとなる
// console.log(myAccount.#balance); // SyntaxError

クラスの不変条件を維持するために重要なプロパティなど、外部からのアクセスを厳格に禁止したい場合は、private修飾子よりも#の使用が適しています。

4. 実行時の型情報と型ガード

TypeScriptのinterfacetypeエイリアスなどの型情報は、トランスパイル時に消去されます(Type Erasure)。そのため、実行時にこれらの型情報を使ってinstanceofのような演算子でチェックできません。

具体例:interfaceに対するinstanceofは利用できない

interface Drawable {
  draw(): void;
}

class Circle implements Drawable {
  draw() {
    /* ... */
  }
}

function render(item: Drawable) {
  // `Drawable`は型情報であり、実行時には存在しないためエラーになる
  if (item instanceof Drawable) {
    // Error: 'Drawable' only refers to a type...
    // ...
  }
}

実践パターン:判別可能なユニオン型

instanceofが使えない代わりに、TypeScriptでは実行時に型を安全に絞り込むための強力なパターンがいくつか用意されています。その中でも特に重要で広く使われるのが判別可能なユニオン型(Discriminated Unions)です。

これは、複数の型をまとめたユニオン型に、それぞれの型を一意に識別するための共通プロパティ(判別子)を持たせる手法です。

まず、共通のkindプロパティを判別子として持つ型エイリアスをそれぞれ定義し、それらを合併させてShapeというユニオン型を作ります。

type Circle = Readonly<{
  kind: "circle"; // 判別子(リテラル型)
  radius: number;
}>

type Square = Readonly<{
  kind: "square"; // 判別子(リテラル型)
  sideLength: number;
}>;

type Triangle = Readonly<{
  kind: "triangle"; // 判別子(リテラル型)
  base: number;
  height: number;
}>;

type Shape = Circle | Square | Triangle;

この設計により、Shape型のオブジェクトは必ずkindプロパティを持ち、その値は"circle", "square", "triangle"のいずれかであることが型レベルで保証されます。

この判別子を使うと、switch文を使って極めて安全に型を絞り込むことができます。

const getArea = (shape: Shape): number => {
  switch (shape.kind) {
    case "circle":
      // このブロック内では、`shape`は`Circle`型だと推論される
      // そのため、`shape.radius`に安全にアクセスできる
      return Math.PI * shape.radius ** 2;

    case "square":
      // このブロック内では、`shape`は`Square`型だと推論される
      return shape.sideLength ** 2;

    case "triangle":
      // このブロック内では、`shape`は`Triangle`型だと推論される
      return (shape.base * shape.height) / 2;

    default:
      // すべてのケースを網羅している場合、`shape`は`never`型になる
      // これにより、将来`Shape`に新しい型が追加された際に
      // このswitch文が対応していないと、型検査時にエラーを発生させることができる(網羅性チェック)
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

また、網羅性チェックのために assertNever 関数を実装しておくと便利です。

export const assertNever = (value: never): never => {
  throw new Error(`Unexpected value: ${value}`);
};

// 使用例
const getArea = (shape: Shape): number => {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;

    case "square":
      return shape.sideLength ** 2;

    case "triangle":
      return (shape.base * shape.height) / 2;

    default:
      // 将来`Shape`に新しい型が追加された際に
      // このswitch文が対応していないと、型検査時にエラーを発生させることができる
      return assertNever(shape);
  }
}

このパターンを適用することで、修正漏れに気付きやすくなります。caseブロックの中では、TypeScriptのフロー解析によってオブジェクトの型が具体的に絞り込まれます。間違ったプロパティにアクセスしようとすると、型検査時にエラーが発生し修正漏れに気付きやすくなります。

また、網羅性チェックの観点からも有利です。switch文のdefault節でnever型の変数に代入するテクニックやassertNeverを使うことで、将来Shapeユニオンに新しい型(例: Rectangle)が追加された際に、switch文にそのcaseを書き忘れていると、型検査時にエラーが発生し修正漏れに気付きやすくなります。

注意が必要な実践パターン: ユーザー定義型ガード関数

より複雑な条件で型を判定したい場合や、ロジックを再利用したい場合には、ユーザー定義型ガード関数を作成することも有効です。これはisキーワード(型述語)を使って、関数の戻り値がtrueの場合に引数の型が何であるかをTypeScriptに伝えるものです。

// `shape is Circle`が型述語
function isCircle(shape: Shape): shape is Circle {
  return shape.kind === "circle";
}

function getRadiusIfCircle(shape: Shape): number | undefined {
  if (isCircle(shape)) {
    // このブロック内では、`shape`は`Circle`型だと推論される
    return shape.radius;
  }
  return undefined;
}

instanceofがクラスの継承関係(プロトタイプチェーン)をランタイムでチェックするのに対し、判別可能なユニオン型や型ガードは、オブジェクトの構造(プロパティの値や存在)に基づいて静的解析のヒントを与え、安全な型絞り込みを実現します。これはTypeScriptにおける非常に重要で強力な機能です。

ただし、ユーザー定義型ガードは強力である一方、型システムに守ってもらう機会を失い実行時エラーを引き起こす危険な諸刃の剣でもあります。

その理由は、型ガード関数 (arg): arg is Type のシグネチャが、TypeScriptコンパイラに対する一方的な「宣言」または「約束」に過ぎないからです。コンパイラは、関数の実装の中身がその宣言通りに正しいかどうかを検証しません。プログラマが書いた型述語を無条件に信頼します。

以上の理由により、ユーザー定義型ガード関数よりも、switch文と判別可能なユニオン型(kindプロパティなど)による型絞り込みを優先しましょう。この方法は、プログラマが任意のロジックを書く余地が少ないため、より安全です。

また、外部APIからのデータなど、実行時に値の構造を検証する必要がある場合は、Zodやio-tsといったバリデーションライブラリの使用を強く推奨します。これらのライブラリは、ランタイムの検証ロジックから静的な型を安全に導き出せるように設計されており、型ガードの実装ミスを防ぐことができます。

「関数型のアプローチによるドメインモデリング」という選択肢

ここまで、TypeScriptでクラスを使用する場合の注意点を解説してきました。TypeScriptの型システムは強力ですが、JavaScriptのプロトタイプベースのオブジェクト指向の上に成り立っているクラスを使用する場合、いくつかの落とし穴が存在します。特に、thisの挙動やアクセス修飾子の扱い、実行時の型情報の欠如などが問題となります。

一方で、TypeScriptの強力な型システムは、関数型プログラミングのスタイルによるドメインモデリング (関数型ドメインモデリング) において真価を発揮します。

なぜ関数型ドメインモデリングを採用するのか

関数型ドメインモデリングでは、データ(型)と振る舞い(関数)を明確に分離します。

  • データ
    状態は、不変(Immutable)なオブジェクトの型として定義します。
  • 振る舞い
    状態遷移は、「現在の状態のデータを受け取り、新しい状態のデータを返す」純粋な関数として実装します。

TypeScriptで関数型ドメインモデリングを実践するためには、クラスを使用せず、型エイリアスと関数を組み合わせてドメインを表現します。これにより、thisの複雑な挙動から解放され、参照透過性が高まる(同じ入力に対して常の同じ出力が返る)ため、コードの見通しが良く、テストも非常に容易になります。

具体例:型と関数による記事ドメインの表現

例として、記事(Article)の状態遷移を関数型スタイルでモデリングしてみましょう。記事には「下書き」「レビュー中」「公開済み」というステータスがあるとします。

データ(状態)を「型」で表現する

まず、クラスを使わずに、TypeScriptの型エイリアス(type)を使って記事の状態を定義します。ここでは判別可能なユニオン型を用いるのが定石です。

import { z } from 'zod';

const userIdSym = Symbol();
export const UserId = z.uuid().brand(typeof userIdSym);
export type UserId = z.infer<typeof UserId>;

export const articleIdSym = Symbol();
export const ArticleId = z.uuid().brand(typeof articleIdSym);
export type ArticleId = z.infer<typeof ArticleId>;

const titleSym = Symbol();
export const Title = z.string().max(100).brand(typeof titleSym);
export type Title = z.infer<typeof Title>;

// 記事の共通基盤となる型
type ArticleBase = Readonly<{
  id: ArticleId;
  title: Title;
  content: string;
}>;

// 「下書き」状態の記事の型
export type DraftArticle = ArticleBase &
  Readonly<{
    status: "DRAFT";
  }>;

// 「レビュー中」状態の記事の型
export type InReviewArticle = ArticleBase &
  Readonly<{
    status: "IN_REVIEW";
    reviewerId: UserId; // レビュー中ならレビュアーIDが必須
  }>;

// 「公開済み」状態の記事の型
export type PublishedArticle = ArticleBase &
  Readonly<{
    status: "PUBLISHED";
    reviewerId: UserId;
    publishedAt: Date; // 公開済みなら公開日時が必須
  }>;

// Article型は、あり得るすべての状態のUnion型
export type Article = DraftArticle | InReviewArticle | PublishedArticle;

このモデリングの強力な点は、「不正な状態を型レベルで表現不可能にする」ことです。例えば、「レビュー中なのにreviewerIdが存在しない」といった矛盾したデータは、Article型として存在できません。

振る舞いを「純粋な関数」で表現する

次に、記事の状態を遷移させる「振る舞い」を、クラスのメソッドではなく独立した関数として定義します。

// 「レビュー中の記事」を「公開する」という振る舞いを表現する関数
const publish = (
  article: InReviewArticle,
  publishDate: Date
): PublishedArticle => ({
  // 元のオブジェクトは変更せず、新しいオブジェクトを返す(不変性)
  ...article, // スプレッド構文で既存のプロパティをコピー
  status: "PUBLISHED", // 状態を遷移させる
  publishedAt: publishDate, // 新しいプロパティを追加
})

export const Article = {
  publish,
} as const;

// 関数の利用例
const articleInReview: InReviewArticle = {
  /* ... */
};
const publishedArticle: PublishedArticle = Article.publish(articleInReview, new Date());

このpublish関数は、thisに依存せず、副作用もありません。引数としてInReviewArticleを受け取り、必ずPublishedArticleを返すことが型で保証されており、非常に安全で予測可能です。

このように、データと振る舞いを分離することで、ドメインロジックの各部が疎結合で再利用しやすく、テストしやすいコンポーネントになります。

import { describe, test, expect } from 'vitest';

describe("レビュー中の記事を公開する", () => {
  test("公開日時を指定して公開できる", () => {
    // Given: レビュー中の記事がある
    const publishDate = new Date();

    // 😆 データ型は振る舞いから分離されているため、何らかのクラスのインスタンスである必要がない
    // よって、コンストラクタやインスタンスメソッドの実行を経由せずにテスト用のデータを作成できる
    const articleInReview = {
      id: ArticleId.parse("0a285ad7-6560-4b52-a94c-16e10824e589"),
      title: Title.parse("記事タイトル"),
      content: "記事の内容",
      status: "IN_REVIEW",
      reviewerId: UserId.parse("75527afc-ea1a-46b3-84db-f6ba3559ff07"),
    } as const satisfies InReviewArticle;
    
    // When: 記事を公開する
    const publishedArticle = Article.publish(articleInReview, publishDate);

    // Then: 記事が公開状態になる
    expect(publishedArticle.status).toBe("PUBLISHED");
    expect(publishedArticle.publishedAt).toBe(publishDate);
    expect(publishedArticle.reviewerId).toBe(articleInReview.reviewerId);
  });
});

まとめ

言語の特性を理解し、最適な設計アプローチを選択する

TypeScriptは、その柔軟で強力な型システムにより、単一のプログラミングパラダイムを強制しません。

長年クラスベースのOOPに親しんできた私たちにとって、TypeScriptのクラスはその経験を活かせる強力なツールです。本記事で解説したような言語の特性を深く理解し、適切なパターンを適用することで、その力を最大限に引き出すことができます。

同時に、TypeScriptは関数型のアプローチという、もう1つの強力な選択肢も提供してくれます。特に状態遷移が複雑なドメインでは、データの不変性と純粋な関数を基本とする関数型ドメインモデリングが、より見通しが良く、堅牢な設計をもたらすことがあります。

最終的にどちらのアプローチを選択するか、あるいは両者を組み合わせるかは、プロジェクトの要件やチームのスキルセットによります。最も重要なのは、それぞれのパラダイムの長所と短所、そしてJavaScriptおよびTypeScriptという言語の根本的な特性を理解した上で、意識的な技術選定をすることです。生成AIによって容易にプロトタイプを開発できる現代においてこそ、長期的に運用できる設計を選ぶことがビジネスの成功に繋がります。