Pragmatic Functional Programming
in TypeScript

Yuichi Goto | TSKaigi 2025 on May 24, 2025

発表者について

  • Yuichi Goto
  • @yasaichi
  • パーフェクトRuby on Rails共著者
  • 株式会社EARTHBRAIN

[PR] EARTHBRAINとプロダクトについて

  • 2021年7月創業のテクノロジースタートアップ
  • 建設生産プロセスの生産性・安全性の向上を実現するデジタル
    ソリューション「Smart Construction®」を開発・保守
  • 相互連携する複数のソフトウェア・ハードウェアの形で提供
  • プロダクトの一部は世界20カ国以上で利用

本セッションについて

関数型プログラミング(FP)を学んでも、実務での活用方法に悩む方は少なくありません。純粋関数、イミュータブルな値、モナドなどの概念を、具体的なコードにどう落とし込んでいくかが明確でない場合が多いからです。

そこで本セッションでは、Dmitrii Kovanikov氏の提唱する5つの原則を「契約による設計」の視点で再構成し、TypeScriptによる実装例と利点を解説します。

加えて、当社が開発しているAPIゲートウェイを題材に、これらの原則を戦術的DDDに基づくレイヤードアーキテクチャと統合する例もご紹介します。バックエンド開発におけるコードの信頼性と保守性の両立を目指すだけでなく、状態管理の複雑なフロントエンド開発でも応用可能な設計手法をお話しします。

Agenda

  1. FPの理論と実務のギャップ 👈
  2. ギャップを埋める5原則
  3. クラスベース主流のアーキテクチャへの統合

みなさんが本発表を聞きに来たのはきっと…

  • 書籍のトレンド: 『なっとく!関数型プログラミング』(2023)、
    『関数型ドメインモデリング』(2024)などの出版が続いている
  • 言語のトレンド: 大御所のOOP言語でも、FPの思想を取り入れた
    機能(例: C# 9.0、JavaのProject Amber)が登場している*
  • フレームワークのトレンド: Webフロントエンドでは、FPの思想を
    取り入れたReactが台頭している
* C# 9.0はrecordとswitch式、Project AmberはRecordやSealed Class、パターンマッチングを含む

そうだ、FPを勉強しよう 📕

FP関連書籍で出会う定番コンセプト

  • 副作用の分離: 純粋関数、イミュータブルな値
  • 関数構成: 高階関数、カリー化/部分適用、関数合成/コンビネータ
  • データ表現: 代数的データ型(ADT)、パターンマッチング
  • 文脈付き計算: 関手(Functor)、アプリカティブ、モナド

理屈はわかった🤯 しかし…

実務での活用にあたっての課題

  1. 新しいプログラミングパラダイムの導入に見合う成果が得られるか
  2. チームメンバー全員がコードベースを継続的に理解・保守できるか
  3. 特にバックエンド開発の場合、実務ではクラスベースのOOPによる実装が主流の一般的なアークテクチャに組み込めるか
特に3のギャップが大きい

提案: 5つの原則から始めてみては?

関数型プログラミング(FP)を学んでも、実務での活用方法に悩む方は少なくありません。純粋関数、イミュータブルな値、モナドなどの概念を、具体的なコードにどう落とし込んでいくかが明確でない場合が多いからです。

そこで本セッションでは、Dmitrii Kovanikov氏の提唱する5つの原則を「契約による設計」の視点で再構成し、TypeScriptによる実装例と利点を解説します。

加えて、当社が開発しているAPIゲートウェイを題材に、これらの原則を戦術的DDDに基づくレイヤードアーキテクチャと統合する例もご紹介します。バックエンド開発におけるコードの信頼性と保守性の両立を目指すだけでなく、状態管理の複雑なフロントエンド開発でも応用可能な設計手法をお話しします。

なぜこの原則なのか

  • 理論と実務のギャップを埋めるPragmatic(実践的)な内容である
  • クラスベースのOOPで実装された既存のコードベースと統合可能
    → 多くの現場で小さく始めて効果検証できることを示す
  • 各原則が短い標語形式でまとまっており、記憶に残りやすい

Agenda

  1. FPの理論と実務のギャップ
  2. ギャップを埋める5原則 👈
  3. クラスベース主流のアーキテクチャへの統合

5つの原則

  1. Parse, don’t validate
  2. Make illegal states unrepresentable
  3. Errors as values
  4. Functional core, imperative shell
  5. Smart constructor
原則1と4、2と5の訳はそれぞれ『脳に収まるコードの書き方』『関数型ドメインモデリング』[2][3]のもの

本パートのスコープ

  1. Parse, don’t validate
  2. Make illegal states unrepresentable
  3. Errors as values
  4. Functional core, imperative shell
  5. Smart constructor
原則4はアークテクチャの話なので次のパートで扱う

最初に結論: 4つの原則の概観

原則 事後
条件
不変
表明
信頼性 保守性
Parse, don’t validate ×
Make illegal states unrepresentable ×
Errors as values × ×
Smart constructor ×

分類軸: 事後条件・不変表明

Bertrand Meyerが提唱した「契約による設計」の3要素のうち、事後条件・不変表明*を型でより厳密に表現できるかで分類する。

  • 事後条件: あるルーチンの実行から生じた状態の特性
  • 不変表明: あるインスタンスのルーチン全てで維持される共通の特性
*これらの改善に伴い、後続のルーチンの事前条件の型表現が厳密になるため、本分類では除外

評価軸: 信頼性・保守性

ISO/IEC 25010の製品品質モデル*を構成する品質特性のうち、バックエンド開発で特に重視したい2要素を改善するかで評価する。

  • 信頼性: システムが、期待どおり動かないといった不具合から脱却
    できているのはどの程度(頻度、時間など)か
  • 保守性: システム保守・修正が容易か
* 2011年に制定されたシステム・ソフトウェアの品質モデル。詳細はIPAの書籍[5]などを参照のこと

各原則の概要と利点

原則1: Parse, don’t validate

  • 概要: 入力値を検証し、booleanvoidではなく検証済みデータを
    表す専用型で結果を返す
  • 分類: 事後条件(検証済みであることを型に反映しているため)
  • 利点: 後続処理での不正な値の検出や分岐処理が不要になり、信頼性・保守性が向上する
Alexis Kingのブログポストの内容[7]を前述の分類・評価軸で再構成したもの

例: メールアドレスの形式の検証

// やりがちな実装: 
// 値の検証結果がプリミティブ型
const email = z.string()
  .email().parse(req.body.email);

// 不変表明の保証のため、再度検証
class User {
  #email: string;

  set email(value: string) {
    if (!validate(value)) throw ...;
    /* 追加のルールがあればここで検証 */

    this.#email = value;
  }
}
// FP的な実装: 
// 検証済みの値をブランド型にする
export const EmailSchema = z.string()
  .email().brand<'Email'>();

export type Email =
  z.infer<typeof EmailSchema>;

class User {
  constructor(
    public readonly email: Email
  ) {}
}

readonlyにした専用型を使えば、emailに関するカプセル化が不要

 
 



 
class User {
  #email: string;

  set email(value: string) {
    if (!validate(value)) throw ...;
    /* 追加のルールがあればここで検証 */

    this.#email = value;
  }
}
 
 






class User {
  constructor(
    public readonly email: Email
  ) {}
}

原則2: Make illegal states unrepresentable

  • 概要: 型システムを活用し、不正なデータや状態をそもそも表現できないようにする
  • 分類: 不変表明(不正な状態を型で排除しているため)
  • 利点: 後続処理での不正な状態の検出や分岐処理が不要になり、信頼性・保守性が向上する
Scott Wlaschinのブログポスト[8]の内容を前述の分類・評価軸で再構成したもの

例: 「どちらか一方は必須」な項目を含むお問い合わせフォーム

// やりがちな実装: どちらもoptionalにする
const schema = z.object({
  contactMethod:
    z.enum(['email', 'phone']),
  email: EmailSchema.optional(),
  phone: PhoneNumberSchema.optional(),
});

// エラーにならない
schema.parse({ contactMethod: 'email' });
// FP的な実装: 判別可能なユニオン型を使う
const schema = z.discriminatedUnion(
  'contactMethod',
  [
    z.object({
      contactMethod: z.literal('email'),
      email: EmailSchema,
    }),
    z.object({
      contactMethod: z.literal('phone'),
      phone: PhoneNumberSchema,
    })
  ]
);

// エラーになる
schema.parse({ contactMethod: 'email' });

判別可能なユニオン型を使えば、後続処理で両項目が空の状態の考慮が不要

// FP的な実装: 判別可能なユニオン型を使う
const schema = z.discriminatedUnion(
  'contactMethod',
  [
    z.object({
      contactMethod: z.literal('email'),
      email: 
    }),
    z.object({
      contactMethod: z.literal('phone'),
      phone: 
    })
  ]
);

// エラーになる
schema.parse({ contactMethod: 'email' });

原則5: Smart constructor

  • 概要: ある型の値を、制約を満たした場合にのみ生成可能にする
  • 分類: 不変表明(実行時検証で不正な値を排除し、有効な値のみ
    生成するため)
  • 利点: 値の検証と生成ロジックが一箇所に集約され、保守性が向上
    する(値を不変にすれば、後続処理の信頼性も向上する)
説明の都合上、順番が前後します
HaskellWikiのあるページ[9]を前述の分類・評価軸で再構成したもの

例: Userのメールアドレスでフリーメールドメインを禁止

import { validate } from 'email-validator';

export class UserEmail {
  readonly #value: string;

  private constructor(value: string) {
    this.#value = value;
  }

  static create(value: string): UserEmail {
    if (!validate(value)) throw new InvalidEmailError(value);
    if (FREE_DOMAINS.has(value.split('@')[1]))
      throw new FreeMailDomainUsedError(value);

    return new UserEmail(value);
  }
}
クラスによるzod相当の処理の実装例で、値は不変

create内の実行時検証に成功すれば、以降は常に安全に扱える

import { validate } from 'email-validator';

export class UserEmail {






  static create(value: string): UserEmail {
    if (!validate(value)) throw new InvalidEmailError(value);
    if (FREE_DOMAINS.has(value.split('@')[1]))
      throw new FreeMailDomainUsedError(value);


  }
}
zodでブランド型を使った場合のparseメソッドに(ほぼ)相当

原則3: Errors as values

  • 概要: 例外をスローするのではなく、値として返す
  • 分類: 事後条件(事後条件が満たされない可能性を型に反映しているため)
  • 利点: 事後条件が満たされない状態への対処を後続処理に強制する
    ことで、信頼性が向上する
Jesse Wardenのブログポスト[10]を前述の分類・評価軸で再構成したもの

例: Userのメールアドレスでフリーメールドメインを禁止(改良版)

import { validate } from 'email-validator';
import { err, ok, type Result } from 'neverthrow';

export class UserEmail {  // プロパティ・コンストラクタ定義略
  static create(
    value: string
  ): Result<UserEmail, InvalidEmailError | FreeMailDomainUsedError> {
    if (!validate(value)) return err(new InvalidEmailError(value));
    if (FREE_DOMAINS.has(value.split('@')[1]))
      return err(new FreeMailDomainUsedError(value));

    return ok(new UserEmail(value));
  }
}

Result型を使い、後続処理に実行時検証に失敗した場合の対処を強制




export class UserEmail {  // プロパティ・コンストラクタ定義略
  static create(
    value: string
  ): Result<         , InvalidEmailError | FreeMailDomainUsedError> {





  }
}

本パートのまとめ

再掲: 4つの原則の分類と評価

原則 事後
条件
不変
表明
信頼性 保守性
Parse, don’t validate ×
Make illegal states unrepresentable ×
Errors as values × ×
Smart constructor ×

本パートの要点

  • 紹介した4原則はいずれも「型」に着目している
  • 4原則は、値・関数の不変表明・事後条件を型で厳密に表現する
  • これらの厳密な型により後続処理の設計を改善し、コードベースの
    信頼性・保守性を向上させる
  • 本手法は、クラスベースのOOPにも統合できる(例: UserEmail

Agenda

  1. FPの理論と実務のギャップ
  2. ギャップを埋める5原則
  3. クラスベース主流のアーキテクチャへの統合 👈

本パートの背景

  • 本パートの内容は、APIゲートウェイのリライトプロジェクトでの
    実践経験に基づく
  • リライト後のAPIゲートウェイではDeno、NestJS、Effectを採用
    しており、以降の実装例も同様
  • 実装例はあくまで概念検証用のサンプルで、実プロジェクトへは
    そのまま適用していない(例: Effect型は未使用)

題材とする機能の紹介

施工現場へのユーザー招待を再送する機能

ユーザーの招待状態の遷移

アーキテクチャと実装例

戦術的DDDに基づくレイヤードアーキテクチャを採用

ドメイン層

Value Object: Parse, don’t validate + Smart constructor

import { Schema as S } from 'effect';

// domain/values/corporation-email.value.ts
export const CorporationEmail = Email.pipe(
  S.filter((email) => !FREE_DOMAINS.has(email.split('@')[1])),
  S.brand('CorporationEmail'),
);

// domain/entities/site-member.entity.ts
export const SiteMemberAccessLevel = S.Literal(
  'Manager', 'Worker', 'Viewer',
).pipe(S.brand('SiteMemberAccessLevel'));

実行時検証で不正な値を排除し、ブランド型の値を返す



// domain/values/corporation-email.value.ts
export const CorporationEmail = Email.pipe(
  S.filter((email) =>                                       ),
  S.brand('CorporationEmail'),
);

// domain/entities/site-member.entity.ts
export const SiteMemberAccessLevel = S.Literal(

).pipe(S.brand('SiteMemberAccessLevel'));

Entityではブランド型のプロパティに関するカプセル化が不要

export const SiteInvitationId = UUID.pipe(S.brand('SiteInvitationId'));
const siteInvitationFields = S.Struct({
  id: SiteInvitationId,
  siteId: SiteId,
  email: CorporationEmail,
  accessLevel: SiteMemberAccessLevel,
  roles: S.optionalWith(
    S.NonEmptyString, { as: 'Option' }),
}).fields;

export class PendingSiteInvitation extends BaseEntity<PendingSiteInvitation>()(
  'PendingSiteInvitation',
  { ...siteInvitationFields, isResent: S.Boolean },
) {
  resend(): PendingSiteInvitation {
    return new PendingSiteInvitation({ ...this, isResent: true });
  }
}

Entity: Make illegal states unrepresentable

export class ExpiredSiteInvitation extends BaseEntity<ExpiredSiteInvitation>()(
  'ExpiredSiteInvitation',
  siteInvitationFields,
) {
  resend(): PendingSiteInvitation { /* ほぼ同様の実装のため略 */ }
}

export class AcceptedSiteInvitation extends BaseEntity<AcceptedSiteInvitation>()(
  'AcceptedSiteInvitation',
  siteInvitationFields,
) {
}

export type SiteInvitation =
  | PendingSiteInvitation
  | AcceptedSiteInvitation
  | ExpiredSiteInvitation;

1. 承諾状態ではresendを定義しないことで再送できないようにする

export class ExpiredSiteInvitation extends BaseEntity<ExpiredSiteInvitation>()(

  siteInvitationFields,
) {
  resend(): PendingSiteInvitation { /* ほぼ同様の実装のため略 */ }
}

export class AcceptedSiteInvitation extends BaseEntity<AcceptedSiteInvitation>()(

  siteInvitationFields,
) {
}

export type SiteInvitation =
  | PendingSiteInvitation
  | AcceptedSiteInvitation
  | ExpiredSiteInvitation;

2. 判別可能なユニオン型を使い、3つの状態以外になりえないようにする

export class ExpiredSiteInvitation extends BaseEntity<ExpiredSiteInvitation>()(


) {
 
}

export class AcceptedSiteInvitation extends BaseEntity<AcceptedSiteInvitation>()(


) {
}

export type SiteInvitation =
  | PendingSiteInvitation
  | AcceptedSiteInvitation
  | ExpiredSiteInvitation;

Repositoryインターフェイス: Errors as values

import { Effect } from 'effect';
import type { SiteInvitation } from '../../entities/site-invitation.entity.ts';

export interface SiteInvitationRepository {
  findUnique(entityId: SiteInvitation['id']): Effect.Effect<
    SiteInvitation,
    EntityNotFoundError | UnexpectedError
  >;

  save(entity: SiteInvitation): Effect.Effect<void, UnexpectedError>;
}

Entityを見つけられない場合の対処を後続処理に強制




export interface SiteInvitationRepository {
  findUnique(entityId: SiteInvitation['id']): Effect.Effect<

    EntityNotFoundError | UnexpectedError
  >;

  save(entity: SiteInvitation): Effect.Effect<    , UnexpectedError>;
}

ユースケース層

Application Service: Errors as values

export class SiteReinvitationService { // Constructor定義略
  create({ siteInvitationId }: CreateSiteReinvitationInputDto) {
    return Effect.gen(this, function* () {
      const siteInvitation = yield* this.siteInvitationRepository.findUnique(
        yield* S.decode(SiteInvitationId)(siteInvitationId),
      );

      if (siteInvitation._tag === 'AcceptedSiteInvitation') {
        return yield* Effect.fail(new SiteInvitationAlreadyAcceptedError(...));
      }

      const resentOne = siteInvitation.resend();
      yield* this.siteInvitationRepository.save(resentOne);

      return yield* S.encode(CreateSiteReinvitationOutputDto)(resentOne);
    });
  }
}

承認済みの招待を再送しようとした場合の対処を後続処理に強制

export class SiteReinvitationService { // Constructor定義略
  create({ siteInvitationId }: CreateSiteReinvitationInputDto) {
    return Effect.gen(this, function* () {




      if (siteInvitation._tag === 'AcceptedSiteInvitation') {
        return yield* Effect.fail(new SiteInvitationAlreadyAcceptedError(...));
      }

      const resentOne = siteInvitation.resend();
      yield* this.siteInvitationRepository.save(resentOne);


    });
  }
}

ここで原則4: Functional core, imperative shell

  • 概要: アプリケーションを純粋関数で構成されたCoreと、副作用を
    担う最小限のShellに分離するアーキテクチャパターン
  • 利点:
    • 純粋関数の増加により、テスト容易性(≒保守性)が向上する
    • 副作用を伴う処理から分岐や状態遷移が減り、保守性が向上する
Gary Bernhardのブログポストの内容[11]を前述の分類・評価軸で再構成したもの

前述の例: 副作用のある処理とない処理が混在してしまっている

// 副作用あり(参照透過ではない)
const siteInvitation = yield* this.siteInvitationRepository.findUnique(
  yield* S.decode(SiteInvitationId)(siteInvitationId),
);

// 副作用なし
if (siteInvitation._tag === 'AcceptedSiteInvitation') {
  return yield* Effect.fail(new SiteInvitationAlreadyAcceptedError(...));
}

const resentOne = siteInvitation.resend();  // 副作用なし
yield* this.siteInvitationRepository.save(resentOne); // 副作用あり

// 副作用なしだが、これはApplication Service責務なので後述の切り出し対象外
return yield* S.encode(CreateSiteReinvitationOutputDto)(resentOne);

解決策の一例: 副作用のない処理をDomain Serviceに切り出す

// domain/services/site-invitation.service.ts
import { Effect, Match } from 'effect';

export class SiteInvitationService {  // Decorator略
  resend(siteInvitation: SiteInvitation) {
    return Match.value(siteInvitation).pipe(
      Match.tag(
        'AcceptedSiteInvitation',
        (acceptedOne) =>
          Effect.fail(new SiteInvitationAlreadyAcceptedError(...)),
      ),
      Match.orElse((unacceptedOne) => Effect.succeed(unacceptedOne.resend())),
    );
  }
}
注: 型で保証した再送可否を実行時検証している。あくまで副作用のない処理の切り出し例として参照のこと

Domain Serviceへの切り出しにより、Functional Core の領域が拡大

クラスベースのアーキテクチャでどこまで従うか

  • ドメイン層の関数はなるべく純粋にして高いテスト容易性を保つべき
  • 原則4の遵守「だけ」を目的として、ユースケース層の処理の一部をDomain Serviceに切り出すことは推奨しない
    • ドメインモデル貧血症*を招き、保守性がかえって低下するため
    • DI可能な設計であれば、一定のテスト容易性は確保できるため
* 本アーキテクチャであれば、EntityやValue Objectがメソッドをほとんど持たず、データだけ持つ状態

プレゼンテーション層

Controller: Imperative shell

export class SiteReinvitationController { // Constructor定義, Decorator略
  create(@Body() inputDto: CreateSiteReinvitationInputDto) {
    return this.siteReinvitationService.create(inputDto).pipe(
      Effect.mapError((error) =>
        Match.value(error).pipe(
          Match.tag('EntityNotFoundError', () => new NotFoundException()),
          Match.tag('SiteInvitationAlreadyAcceptedError', () =>
            new UnprocessableEntityException()),
          Match.tag('ParseError', 'UnexpectedError', (cause) =>
            new InternalServerErrorException(undefined, { cause })),
          Match.exhaustive,
        )
      ),
      Effect.runPromiseExit,
    );
  }
}

1. Error as Valuesにより型で表現された異常系をHTTPエラーに変換

export class SiteReinvitationController { // Constructor定義, Decorator略
  create(@Body() inputDto: CreateSiteReinvitationInputDto) {

      Effect.mapError((error) =>
        Match.value(error).pipe(
          Match.tag('EntityNotFoundError', () => new NotFoundException()),
          Match.tag('SiteInvitationAlreadyAcceptedError', () =>
            new UnprocessableEntityException()),
          Match.tag('ParseError', 'UnexpectedError', (cause) =>
            new InternalServerErrorException(undefined, { cause })),
          Match.exhaustive,
        )
      ),


  }
}
errorは起こりうる全てのエラーのユニオン型

2. 5原則を適用したユースケース層以下の処理を実行し、NestJSと統合

export class SiteReinvitationController { // Constructor定義, Decorator略
  create(@Body() inputDto: CreateSiteReinvitationInputDto) {
    return this.siteReinvitationService.create(inputDto).pipe(









      ),
      Effect.runPromiseExit,
    );
  }
}

本パートのまとめ

再掲: 戦術的DDDに基づくレイヤードアーキテクチャ

本パートの要点

FPの5原則は、クラスベースのアーキテクチャに無理なく統合できる

  • Controller以外: 不変表明・事後条件を型で厳密に表現する
  • Controller: 型で表現された異常系に対処し、副作用を実行する
  • 全体: 副作用のある処理とない処理を分離し、保守性を向上する

全体のまとめ & ご清聴ありがとうございました

  • Dmitrii氏が提唱するFPの5原則は、理論と実務のギャップを埋める“実践的”なガイドライン
  • 値・関数の不変表明・事後条件を型で厳密に表現し、後続処理の設計を改善
    することでコードの信頼性・保守性を向上
  • 副作用のある処理とない処理を分離し、コードベースの保守性を向上
  • 5原則は、クラスベースのOOPで実装された既存のコードベースにも統合可能
  • 実務でFPを活用する第一歩として、この5原則をぜひ試してみましょう

参考文献

  1. Mastering JavaScript Functional Programming: Write clean, robust, and maintainable web and server code using functional JavaScript and TypeScript , Third Edition↩
  2. 脳に収まるコードの書き方―複雑さを避け持続可能にするための経験則とテクニック↩
  3. 関数型ドメインモデリング ドメイン駆動設計とF#でソフトウェアの複雑さに立ち向かおう↩
  4. オブジェクト指向入門 第2版 原則・コンセプト↩
  5. つながる世界のソフトウェア品質ガイド あたらしい価値提供のための品質モデル活用のすすめ↩
  6. The Clean ArchitectureがWebフロントエンドでしっくりこないのは何故か↩
  7. Parse, don’t validate↩
  8. Designing with types: Making illegal states unrepresentable↩
  9. Smart constructors↩
  10. Errors as Values: Free Yourself From Unexpected Runtime Exceptions↩
  11. FUNCTIONAL CORE, IMPERATIVE SHELL↩

https://excalidraw.com/#json=1cUIS7LT3HDCZxxaZsbnY,O0nk-ycFCzDeL5-fUUmqKA

https://app.diagrams.net/?src=about#G1KRhMt9Yw1l4E2XwYa3zS1lF_Sj8rmy6J#%7B%22pageId%22%3A%22coy9R9sIgS2ketNfYNBZ%22%7D

元データ: https://app.diagrams.net/?src=about#G1KRhMt9Yw1l4E2XwYa3zS1lF_Sj8rmy6J#%7B%22pageId%22%3A%22TUuh0wMWGVfsI77-jAWv%22%7D