🎱

TypeScriptによるcreate関数を使わないドメインモデルの関数型アプローチ

に公開

はじめに

TypeScriptでドメイン駆動設計(DDD)を実践する際、ドメインモデルの表現方法はプロジェクトの保守性や開発体験に大きな影響を与えます。一般的に、モデルの型定義と、そのインスタンスを生成するファクトリ関数は別々に定義されることが多いのではないでしょうか。

例えば、Userという型と、それを生成するためのcreateUserという関数。このアプローチは直感的ですが、モデルに関する知識が「型定義」と「ファクトリ関数」という二つの場所に分散してしまい、ドメインモデルの凝集度を下げてしまう一因になり得ます。

この記事では、classを使わずにTypeScriptの言語機能を活用した関数型アプローチを紹介します。型とそのファクトリ関数を一つの名前に統合することで、ドメインモデルの凝集度を高め、より直感的で堅牢な設計を実現するプラクティスです。

従来のアプローチとその課題

まずは、多くのプロジェクトで見られるであろう従来のアプローチを見てみましょう。ここでは後述するreadonly修飾子をあらかじめ適用し、プロパティが不変である(書き換えられない)ことを前提とします。

従来コード例

// /domain/user.ts

// ユーザーの型定義
type User = {
  readonly id: string;
  readonly name: string;
};

// ユーザーを生成するファクトリ関数
const createUser = (id: string, name: string): User => {
  validateId(id);
  validatedName(name);
  return { id, name };
};

// --- 利用側のコード ---
// /application/userService.ts
import { User, createUser } from "@/domain/user";

// Userを利用した処理
const process = (user: User): User => { /* ... */ };

const someUseCase = (input: { id: string, name: string }): User => {
  // User型を生成するために、`createUser`という別の関数を知っている必要がある
  const user: User = createUser(input.id, input.name);

  // `User`を「型」として利用
  const processUser = process(user);

  // 返り値は、User型のオブジェクト
  return processUser;
};

このコードには、いくつかの課題が潜んでいます。

  1. 知識の分散と低い凝集度: Userモデルを利用するためには、Userという型だけでなく、createUserというファクトリ関数の存在を知っていなければなりません。
  2. 低い発見可能性: 開発者がuser.tsファイルからUser型をインポートしたとしても、それだけではUserオブジェクトをどうやって安全に生成すれば良いのか分かりません。
  3. 冗長なimport: 利用側では、UsercreateUserの両方をimport文で明示的に指定する必要があり、少し冗長です。

解決策: 型と関数を同じ名前で統合するアプローチ

そこで提案したいのが、TypeScriptでは型と値(関数など)で、「型空間」と「値空間」が分離されているという性質を利用したパターンです。つまり、typeで定義される「型」とconstで定義される「値(関数)」に、同じ名前を付けることができます。

このパターンを先ほどのUserモデルに適用してみましょう。

改善後のコード例

// /domain/user.ts

// ユーザーの型定義(readonlyを適用)
export type User = {
  readonly id: string;
  readonly name: string;
};

// ユーザーを生成するファクトリ関数
// ★ 型と同じ `User` という名前で関数を定義する
export const User = (id: string, name: string): User => {
  validateId(id);
  validatedName(name);
  return { id, name };
};

// --- 利用側のコード ---
// /application/userService.ts

// ★ import文が一つにまとまる!
import { User } from "@/domain/user";

// Userを利用した処理
const process = (user: User): User => { /* ... */ };

const someUseCase = (input: { id: string, name: string }): User => {
  // `User`を「関数」として利用
  const user = User(input.id, input.name);

  // `User`を「型」として利用
  const processUser = process(user);

  // 返り値は、User型のオブジェクト
  return processUser;
};

この変更によって、先ほどの課題は見事に解決されます。

  • 高い凝集度: Userという一つの名前のもとに、「型定義」と「生成ロジック」が集約されました。
  • 優れた開発体験: 利用者はimport { User }と書くだけで、型とファクトリ関数の両方を利用できます。
  • シンプルでクリーンなコード: import文がスッキリし、コードの見通しが良くなります。

Tips: モデルをより堅牢にする不変性(Immutability)の活用

本記事で紹介したコード例では、すべての型定義のプロパティにreadonly修飾子を付けています。

type User = {
  readonly id: string;
  readonly name: string;
};

これは、一度生成されたドメインオブジェクトの状態が、意図せず外部から変更されることを防ぐ「不変性(Immutability)」を確保するためです。readonlyを付けておけば、プロパティへの再代入はコンパイルエラーとなります。

const user = User("1", "test-user");
// user.name = "new-name"; // Error: Cannot assign to 'name' because it is a read-only property.

これにより、オブジェクトがアプリケーションの様々な場所で予期せず書き換えられることを防ぎ、バグの温床となる「副作用」を減らすことができます。データフローが予測可能になり、コードの保守性が大きく向上します。

本記事で提案した関数型アプローチとreadonlyによる不変性の確保は非常に相性が良く、組み合わせることで、より信頼性の高いドメインモデルを構築できます。

Tips:ブランド型(Nominal Typing)で生成ロジックを強制する

TypeScript は構造的型付け(duck typing)なので、ファクトリ関数を経由せずとも次のようにリテラルでオブジェクトを作成できます。

// ファクトリを通さずに作れてしまう例
const user: User = { id: "1", name: "hoge" };  // ← これもコンパイルは通る

検証ロジック(validateId など)を必ず通した 正規オブジェクト だけを扱いたい場合は、“ブランド型” を付けて 名義的型付け(nominal typing) を導入すると安全性が高まります。

// /domain/user.ts
// -----------------

/**
 * User 型のブランド用シンボル
 */
declare const UserBrand: unique symbol;

export type User = {
  readonly id: string;
  readonly name: string;
  // Brand Type
  readonly [UserBrand]: typeof UserBrand;
};

// ファクトリ関数: ここでしか User を生成できない
export const User = (id: string, name: string): User => {
  validateId(id);
  validatedName(name);
  return { id, name, [UserBrand]: UserBrand };
};

使い方

import { User } from "@/domain/user";

// ✅ 正規ルート
const u1 = User("1", "alice");

// ❌ コンパイルエラー: ブランドが無い
const u2: User = { id: "2", name: "bob" };
// Property '[UserBrand]' is missing in type '{ id: string; name: string; }' but required in type 'User'

こうすることで 「検証を必ず通したオブジェクトしか存在しない」 という保証が得られ、ドメインモデルの整合性がさらに高まります。

まとめ

本記事では、createのような接頭辞を持つ関数を個別に用意するのではなく、モデルの型定義とファクトリ関数を同じ名前で提供する、TypeScriptにおける関数型アプローチを紹介しました。

このアプローチは、classを使わずにドメインの関心事を一箇所にまとめたい場合に特に有効です。

従来の方法が抱えていた「知識の分散」や「発見可能性の低さ」といった課題を解決し、より直感的でメンテナンスしやすいコードベースの構築に貢献するこのアプローチを、ぜひ次のプロジェクトで試してみてはいかがでしょうか。

Happy Hacking😎

Discussion