33
4

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と歩む関数型ドメインモデリング

Last updated at Posted at 2025-12-23

はじめに

SapeetでSWEをやっている久保田です。
弊社では、複数のサービスを提供しており一部のサービスではDDDを実践して開発を行っています。
今回、プロダクトの立ち上げに際しても同様にDDDを実践しつつ、関数型ドメインモデリングを取り入れたTypeScriptによる実装でアプリケーションを構成しています。
ただ、TypeScriptにおいては Scala などの関数型言語と比較すると言語標準での関数型の表現力は不足している側面があると言わざるを得ません。周知の事実としてOptionEitherのようなモナドは備わっていないし、いい感じのパターンマッチングも提供されていません。そのため、TypeScriptで関数型のアプローチをとる場合、どこまでサードパーティライブラリに依存するかは常に考慮事項として付きまとうことになります。(fp-ts,neverthrow,effect...)

今回はそのトレードオフが一定発生することを認識しつつ、手軽にそのプラクティスを取り入れるために neverthrowによる Result,ResultAsyncを活用した実践となっています。

ドメイン駆動設計

ドメイン駆動設計に関しては色々な書籍を通して紹介されているので、エヴァンス本や、「ドメイン駆動設計をはじめよう」で、よりDDDの世界へディープダイブしてみることをお勧めします。

ドメイン駆動設計では、特定の課題領域・業務要件を理解しシステムに反映します。
また、ドメインエキスパート・開発チーム・ステークホルダー・ソースコード自体が、ユビキタス言語を通じて、同じメンタルモデルを共有し、開発を円滑に進め、ソフトウェアとしての価値を高めていく状態を目指します。

ddd_team-min.png

今回の実装を進めていくために参考としている書籍 「関数型ドメインモデリング」では、F# でそのプラクティスが紹介されていますが、TypeScriptのプロジェクトに適用していきます。

この書籍で紹介されているように、

  • ドメインモデルを型で表現する
  • 成否を明示的な型で表現するResult型
  • 代数的データ型
  • Railway Oriented Programming
    などを積極的に使用し、関数型のアプローチでソフトウェアを構築していきます

型で向き合うドメインモデル

Gemini_Generated_Image_dy8s7ydy8s7ydy8s-min.png

ドメインモデル

TypeScriptではclassを使った定義も可能ですが、関数型ドメインモデリングの例に沿ってclass非依存で、ドメインモデルを表現します。
class依存にすることによる構造的部分型での意図しない代入を許容してしまうケースや、classのメンバーメソッドを高階関数の引数に渡すことによるコンテキストの喪失などを考慮すると、classで実装するメリットはあまりない(と個人的には思っている)ので、関数型ドメインモデリングの文脈に準拠して型で表現していきます。

※今回登場するモデルは業務上表現されているものでなく仮のものです

type Brand<T, BrandName> = T & { readonly __brand: BrandName };

type ReservationId = Brand<string, 'ReservationId'>;
type GuestId = Brand<string, 'GuestId'>;
type RoomNumber = Brand<number, 'RoomNumber'>;
type ReservationDate = Brand<Date, "ReservationDate">

type ReservationBase = {
 readonly id: ReservationId;
 readonly date: ReservationDate;
 readonly guestId: GuestId;
 readonly roomNumber: RoomNumber;
 readonly description: string | null;
 readonly allergies: readonly string[] | null;
};
// 仮予約
type TentativeReservation = ReservationBase & {
 readonly status: 'tentative';
 readonly expiresAt: Date;
};
// 本予約
type ConfirmedReservation = ReservationBase & {
 readonly status: 'confirmed';
 readonly confirmedAt: Date;
};
// キャンセル予約
type CancelledReservation = ReservationBase & {
 readonly status: 'cancelled';
 readonly cancelledAt: Date;
 readonly cancelReason: string | null;
};

export type Reservation =
 | TentativeReservation
 | ConfirmedReservation
 | CancelledReservation;

代数的データ型による表現

上述のように、代数的データ型(ADT) を活用して取り得るデータを型レベルで表現します。
ドメインモデルに、不正な状態を表現させないために型で制約を実装することで、ビルドの時点で不正な実装を弾くことが可能で、タイプセーフな実装に寄与してくれます。
これはTypeScriptの仕様上、status など共通のフィールドを用意して実現します。
判別可能なユニオン型、TaggedUnionなどと呼ぶこともありますね。

簡易Either

少しドメインモデリングの話からは脱線しますが、ここでの知識を使えば EitherResult のような文脈付き計算の実装をすることは可能です。
サードパーティのライブラリに依存せず、このような簡易実装を行い、拡張することで実現することも可能ですが neverthrowを使うことによる旨みは別であるので後述します。

type Right<T> = {
  tag: 'right';
  value: T;
}
type Left<E> = {
  tag: 'left';
  error: E;
}
type Either<E, T> = Right<T> | Left<E>;

// Utility function to match on Either
const match =
 <E, T>(either: Either<E, T>) =>
 <Response, FailedResponse = Response>(
  handleLeft: (error: E) => Response,
  handleRight: (value: T) => FailedResponse
 ): Response | FailedResponse => {
   switch (either.tag) {
    case 'left':
     return handleLeft(either.error);
    case 'right':
     return handleRight(either.value);
    default:
     return assertNever(either);
   }
};

スマートコンストラクタ

ドメインモデルのEntityを生成するとき、不変条件を満たさないものを許容しない仕組みとしてスマートコンストラクタを採用してモデルの整合性を担保していきます。
TypeScriptには篩型のような、値の性質を型レベルで表現するような言語機能は備わっていないので、 BrandedTypeを使用した実装で、構造は同じだが意味の異なる値を表現したりしますね。

BrandedType参考

neverthrowResultを活用して、スマートコンストラクタパターンで実装する場合、以下のような実装に寄せると簡潔に実装することも可能です。
このように複数の独立した計算を並列に評価して、結果をまとめて最終的な値を作成し返却する実装も neverthrow ではサポートされています。


const createTentativeReservation = (genId: () => string) => (
 input: ReservationInput,
 expiresAt: Date
): Result<TentativeReservation, readonly string[]> => {
 return Result.combineWithAllErrors([
  validateDate(input.date),
  validateGuestId(input.guestId),
  validateRoomNumber(input.roomNumber),
  validateDescription(input.description ?? null),
  validateAllergies(input.allergies ?? null),
  validateExpiresAt(expiresAt)
]).map(
 ([
  // ValidなTuppleが返却される
  validDate,
  validGuestId,
  validRoomNumber,
  validDescription,
  validAllergies,
  validExpiresAt
]) =>
 ({
  id: genId() as ReservationId,
  date: validDate,
  guestId: validGuestId,
  roomNumber: validRoomNumber,
  description: validDescription,
  allergies: validAllergies,
  expiresAt: validExpiresAt,
  status: 'tentative',
 }) satisfies TentativeReservation);
}

関数適用による状態の遷移

ドメインオブジェクトの状態遷移はイミュータブルに扱えるように実装します。この状態遷移の関数は、オブジェクトの振る舞いとしてドメインモデルの近所に配置してあげます。
DDDにおける集約内のオブジェクトに対するすべての変更は、集約ルートであるトップレベルエンティティを起点にする必要があり、集約内のすべてのデータが同時に正しく更新されることを保証する整合性の境界として機能させるように実装されるべきです。

export const confirmTentativeReservation = (
 reservation: TentativeReservation,
 confirmedAt: Date,
 now: () => Date = () => new Date()
): Result<ConfirmedReservation, readonly string[]> => {
 return validateExpiration(reservation, now).map(() => ({
   ...reservation,
   status: 'confirmed',
   confirmedAt
 }));
};

合成から成るUseCase | Railway Oriented Programming

「関数型ドメインモデリング」の書籍では、10章で Railway Oriented Programmingについて触れられています。

関数型プログラミングでは、エラーも型付けして値として扱うことは定石です。Railway Oriented Programmingでは、複数の関数を合成し、途中でエラーが発生した場合にはその値を返し、処理が成功すれば次に進んでいきます。
直近カミナシさんのアドカレ記事でも、ROPについて取り扱っていたのでそちらも参照してみてください。

Gemini_Generated_Image_ut6wacut6wacut6w.png

上記で定義している状態遷移の関数も使いつつ、UseCaseを Railway Oriented Programmingの形で実装してみます。
今回は、取得->検証->更新->戻り値を整形し返却のような一連の流れを実装します。
また neverthrow では、非同期処理をサポートするための ResultAsyncも提供されており、そのあたりも柔軟に使うことでPromise も合成することが可能になり、処理を全て関数の合成としてフラットに書くことができるようになるため見通しも良くなります。


type NotFoundError = { kind: 'NotFoundError' };
const findTentativeReservationById = (
 id: ReservationId
): ResultAsync<TentativeReservation, NotFoundError> => { ... }

type SaveError = { kind: 'SaveError'; messages: readonly string[] };
const saveConfirmedReservation = (
 reservation: ConfirmedReservation
): ResultAsync<{ id: string }, SaveError> => { ... }

const executeConfirmUseCase = async (input: {
 reservationId: ReservationId;
 confirmedAt: Date;
}): Promise<Result<{ reservationId: string }, UseCaseError>> => {
 return await findTentativeReservationById(input.reservationId)
  // 取得した仮予約を確定予約に変換
  .andThen((tr) => confirmTentativeReservation(tr, input.confirmedAt))
  // 確定予約を保存
  .andThen(saveConfirmedReservation)
  .map(handleSuccess)
  .mapErr(handleError);
};

制約と誓約 ~AI時代の恩恵を受けるために~

ハンター×ハンターでは 「制約と誓約」という、自らの能力にルールを課し(制約)、それを守ると誓う(誓約)ことで念能力の性能が大きく向上 させることのできる概念が登場します。

(2025/12 現在) TypeScript を コーディングエージェントに実装させる場合、anyに強引にキャストすることで型を放棄したり、意図しないタイミングで例外を throw しようとしたりするなど、もどかしい経験をした人は誰しもあるかもしれません。

コーディングエージェントによる実装の恩恵を受けるためには、一定の制約を課す必要があることは通説となっています。
ので、静的解析や自動テストなどを適切に設計してあげることでその力を最大限引き出してあげる必要があります。
そのため Resultのように成否を型レベルで制約し、型システムの力を最大限に活用しつつ、関数型での実装によるテスタビリティも獲得することはこのAI時代においてコーディングエージェントの恩恵を受けるために重要な意味を持つと考えています。
AIの力を最大限引き出すために、適切な 「制約と誓約」を与えるための関数型ドメインモデリングといった意味でもこの設計は意味を持ちます。

まとめ

ここまで記事を読んでいただきありがとうございます。
直近のAIの凄まじい進歩の中で、今後コーディングエージェントとの協業がスタンダートとなっていく中で、エンジニアは中長期的に具象の実装を意識することは無くなっていくのかもしれません。
Resultを中心とした表現はエラーを型レベルで表現することができるため、DIPなど適切に抽象に依存する必要があるケースを実践する際にもワークしてくれます。
人間プログラマーの仕事の再定義が走っている昨今ではありますが、構造に適切な制約を与えて、AIコーディングエージェントの力を最大限引き出しつつヒューマンリーダブルな形でソフトウェアを維持していくために、この関数型ドメインモデリングを実践するというのはAIも人間もハッピーにしてくれる一助になると筆者は考えています。
最後まで読んでいただきありがとうございました。

参考

33
4
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
33
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?