1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#にScalaのエッセンスを持ち込む試み:OptionとEitherで例外とnull地獄から抜け出す

1
Last updated at Posted at 2025-11-29

1. はじめに:なぜC#でScalaの話をするのか

業務でC#を書いていると、こんなサービスメソッドに心当たりはないでしょうか。

public bool RegisterUser(UserDto dto)
{
    if (dto == null)
    {
        // ログ
        _logger.LogWarning("UserDto is null.");
        return false;
    }

    if (string.IsNullOrWhiteSpace(dto.Name))
    {
        _logger.LogWarning("Name is required.");
        return false;
    }

    try
    {
        var entity = new User(dto.Id, dto.Name);

        if (_db.Users.Any(u => u.Id == dto.Id))
        {
            _logger.LogWarning("User already exists.");
            return false;
        }

        _db.Users.Add(entity);
        _db.SaveChanges();

        return true;
    }
    catch (SqlException ex)
    {
        _logger.LogError(ex, "Database error.");
        return false;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error.");
        return false;
    }
}

条件分岐(if)と try-catch が増えてくると、「とりあえず動くけど読みたくないメソッド」になりがちです。

Scalaでは、こういった「成功/失敗」「ある/ない」の情報を型で表現して、
処理をつなげやすくする
ための道具として OptionEither がよく使われます。

この記事では、そのエッセンスを C# に持ち込んで、

  • Option<T> で null を減らす
  • Either<L, R> で成功/失敗を1つの値にまとめる
  • LINQメソッドチェーンと switch 式で読みやすく書く

という流れを紹介します。

1.1 条件分岐と例外だらけのメソッドがしんどい理由

先ほどのようなサービスメソッドが増えていくと、次のようなつらさが出てきます。

(1) 正常系より「エラー処理のためのノイズ」が目立つ

本当に読みたいのは「ユーザーをどう登録するか」なのに、

  • nullチェック
  • if の早期 return false
  • 例外ごとの catch

が行の大半を占めていて、正常系のフローが埋もれてしまいます。


(2) 分岐のパターンが頭に入りづらい

  • どの if で終了する可能性があるか
  • どの例外がどこで投げられうるか

を追っていく必要があり、制御フローを頭の中でシミュレーションしながら読む必要があります。

行数以上に、読む人の認知コストが高くなります。


(3) 情報がバラけていて再利用しづらい

「このメソッドの結果」を表現している情報は、例えばこんなふうに散らばっています。

  • 戻り値:bool(成功/失敗だけ)
  • ログメッセージ:_logger.LogWarning(...) などの文字列
  • 例外:SqlException / Exception

ひとつの操作の結果なのに、関数の外からは bool しか見えない状態です。

呼び出し側でエラーメッセージや種類を使いたくなっても、

  • 「失敗した理由」が false に潰されている
  • 例外は catch されてログで消費されてしまっている

ため、再利用しづらく・テストもしづらいという問題が出ます。


こういった問題は、「成功/失敗」と「結果/エラー」を
ひとつの値として扱っていないこと
が根っこにあります。

そこで出てくるのが、Scala でよく使われる Either<L, R> のような「成功か失敗かのどちらか一方を持つ型」です。

1.1.1 同じ処理を Either で書くとどうなるか

先ほどの登録処理を、Either<AppError, User> で書き直すと、こんなイメージになります。

public Either<AppError, User> RegisterUser(UserDto dto)
{
    if (dto is null)
    {
        return Either<AppError, User>.FromLeft(
            AppError.Validation("入力がnullです。")
        );
    }

    if (string.IsNullOrWhiteSpace(dto.Name))
    {
        return Either<AppError, User>.FromLeft(
            AppError.Validation("名前は必須です。")
        );
    }

    var user = new User(dto.Id, dto.Name);

    try
    {
        if (_db.Users.Any(u => u.Id == dto.Id))
        {
            return Either<AppError, User>.FromLeft(
                AppError.Business("同じIDのユーザーがすでに存在します。")
            );
        }

        _db.Users.Add(user);
        _db.SaveChanges();

        return Either<AppError, User>.FromRight(user);
    }
    catch (SqlException ex)
    {
        return Either<AppError, User>.FromLeft(
            AppError.Infrastructure("DBエラーが発生しました。", ex)
        );
    }
    catch (Exception ex)
    {
        return Either<AppError, User>.FromLeft(
            AppError.Unexpected(ex)
        );
    }
}

まだ iftry-catch は残っていますが、次の点で改善されています。

  • 戻り値1つで「成功/失敗」と「結果/エラー」の両方を表現できる
  • 呼び出し側は Either<AppError, User> という型を見ただけで
    • バリデーションや業務ルールで落ちる可能性がある
    • インフラエラーも返ってくるかもしれない
      といった情報を把握できる
  • ログやHTTPレスポンスへの変換は呼び出し側でまとめて書ける

例えば Web API 側では次のように扱えます。

public IActionResult Register(RegisterUserRequest request)
{
    var dto = new UserDto(request.Id, request.Name);

    var result = _service.RegisterUser(dto);

    return result switch
    {
        { IsRight: true } r =>
            Ok(new { id = r.Right.Id }),

        { IsLeft: true, Left.Kind: ErrorKind.Validation } e =>
            BadRequest(e.Left.Message),

        { IsLeft: true, Left.Kind: ErrorKind.Business } e =>
            Conflict(e.Left.Message),

        { IsLeft: true } e =>
            HandleServerSideError(e.Left),

        _ => StatusCode(500, "Unexpected state.")
    };
}

ここまで来ると、

  • サービス側:「結果かエラーか」を Either で返すことに専念
  • API側:AppError を見てステータスコードやレスポンスを決める

という役割分担ができ、条件分岐と例外が「どこに書いてあるのか」がはっきりするようになります。

この「結果とエラーを1つの値にまとめる」という発想を、
より小さなスコープ(null扱いや単純な失敗)に持ち込んだのが Option<T>Either<L, R> です。

1.2 Tryメソッドパターンの限界:bool + out のつらさ

C#ではおなじみの int.TryParse に代表される「Tryメソッドパターン」があります。

if (int.TryParse(input, out var age))
{
    // age が使える
}
else
{
    // パース失敗
}

標準ライブラリレベルの小さい変換にはとても便利ですが、
アプリケーションロジックにこのノリを持ち込むと、やはりつらさが出てきます。

たとえば、ユーザー登録の処理を「Tryメソッド」で書くとこうなりがちです。

public bool TryRegisterUser(UserDto dto, out User registeredUser, out string errorMessage)
{
    registeredUser = null!;
    errorMessage = "";

    if (dto == null)
    {
        errorMessage = "入力がnullです";
        return false;
    }

    if (string.IsNullOrWhiteSpace(dto.Name))
    {
        errorMessage = "名前は必須です";
        return false;
    }

    try
    {
        var user = new User(dto.Id, dto.Name);
        _repository.Save(user);
        registeredUser = user;
        return true;
    }
    catch (Exception ex)
    {
        errorMessage = ex.Message;
        return false;
    }
}

このスタイルには、ちょっとずつ効いてくるつらみがいくつかあります。

(1) 本当に欲しいものが out に追い出される

  • 戻り値の bool は「成功/失敗」しか表せない
  • 呼び出し側が一番欲しい User とエラー詳細は out に追い出される

「メインの結果」が return ではなく out にいるので、
コードを読んだときの直感とも少しズレます。


(2) 情報がバラけていて合成しづらい

「このメソッドの結果」を表している情報は、

  • 成功/失敗 … bool
  • 成功時の値 … registeredUser
  • 失敗時の詳細 … errorMessage

と、1つの操作の結果が3つの値に分散してしまいます。

この形式のメソッドを複数つなげようとすると、

User user;
Order order;
string error;

if (!TryCreateUser(dto, out user, out error))
{
    return false;
}

if (!TryCreateOrder(user, out order, out error))
{
    return false;
}

のように、if と早期 return だらけになりがちです。


(4) out 用の変数宣言で、地味にステップ数を踏む

もうひとつ地味につらいのが、「out のためだけの変数宣言」です。

User user;
string errorMessage;

if (!TryRegisterUser(dto, out user, out errorMessage))
{
    // エラー処理
}
  • 呼び出し側で 「使うかどうかも分からない変数」を先に宣言しないといけない
  • 変数のライフタイムが広がるので、スコープも少し読みづらくなる

C# 7以降なら out var user で短く書けますが、それでも

if (!TryRegisterUser(dto, out var user, out var errorMessage))
{
    // ...
}

のように、1つの操作の結果のために bool + out + out の3か所を触る必要があります。


(5)LINQ や async/await と相性がよくない

  • TryXxx + out は「ステートフルなメソッド呼び出し」に寄ってしまうので、
    LINQメソッドチェーンや Task との相性があまりよくありません。
  • 合成しづらく、「1個の大きなメソッド」にロジックがたまりがちです。

この記事で紹介する Option<T> / Either<L, R> は、

「成功/失敗 と 結果/エラー を 1つの値にまとめて返したい

というところから出発して、

  • 戻り値ひとつで結果とエラーを表す
  • LINQメソッドチェーンと合わせて、
    result.Map(...).Bind(...) のように自然につなげられる

ようにするための道具です。

2. Scalaのエッセンスをざっくり整理する

2.1 Option:null の代わりに「ある/ない」を型で表す

Scalaの Option[A] は「あるかもしれない A」を表す型です。

  • Some(value) … 値がある
  • None … 値がない
def findUser(id: UserId): Option[User] =
  // 見つかれば Some(user)、見つからなければ None

呼び出し側は、null を気にするのではなく、戻り値の型だけで「ない可能性」が分かります

val nameOrGuest =
  findUser(id)
    .map(_.name)         // User → String に変換
    .getOrElse("ゲスト") // なければデフォルト

ここでポイントになるのが map です。

  • map: Option[A] × (A => B) → Option[B]
  • 「中身があるときだけ関数を適用し、ないときはそのまま None を返す」

nullチェックを毎回書かなくても、「あるときだけ処理する」というパターンを再利用できます。

2.2 Either:成功と失敗を「どちらか一方の値」として表す

Scalaの Either[L, R] は「左か右、どちらか一方の値」を表します。

  • 慣習的に Left をエラー、Right を成功として使うことが多い
  • 例外を投げるのではなく、失敗理由を値として返したいときに便利
def parseAge(input: String): Either[String, Int] =
  Try(input.toInt).toEither.left.map(_ => "年齢は数値で入力してください")

呼び出し側は「例外が飛んでこない」代わりに、Either をパターンマッチで分解します。

parseAge(input) match
  case Right(age) => println(s"年齢は $age 歳です")
  case Left(msg)  => println(s"エラー: $msg")

2.3 パターンマッチと「式」として書くスタイル

Scalaでは match 式を使って、
「分岐しつつ、最後に値を返す」書き方がよく登場します。

val result: String =
  parseAge(input) match
    case Right(age) => s"年齢は $age 歳です"
    case Left(msg)  => s"エラー: $msg"

このスタイルは C# の

  • switch 式(C# 8 以降)
  • 式形式メンバー(expression-bodied)

と相性がよく、C#でもかなりScalaっぽい書き味に寄せることができます。

3. C#で簡易 Option<T> を実装してみる

ここからは、Scalaの Option を真似したシンプルな Option<T>
外部ライブラリなしで自作してみます。

3.1 Option<T> の型定義

まずは「値がある/ない」を表す型を1つ用意します。

ポイント:

  • readonly struct にしてイミュータブル(不変)にする
  • HasValueValue を持たせる
  • Some / None を静的メソッドで生成する
public readonly struct Option<T>
{
    private readonly T _value;

    public bool HasValue { get; }

    public T Value
    {
        get
        {
            if (!HasValue)
            {
                throw new InvalidOperationException("Option has no value.");
            }

            return _value;
        }
    }

    private Option(T value)
    {
        _value = value;
        HasValue = true;
    }

    public static Option<T> Some(T value)
    {
        if (value is null)
        {
            throw new ArgumentNullException(nameof(value));
        }

        return new Option<T>(value);
    }

    public static Option<T> None()
    {
        return default;
    }
}

3.2 Map / GetOrElse で「あるときだけ処理する」

次に、Scalaの map に相当する拡張メソッドを用意します。

public static class OptionExtensions
{
    public static Option<TResult> Map<T, TResult>(
        this Option<T> option,
        Func<T, TResult> selector)
    {
        if (!option.HasValue)
        {
            return Option<TResult>.None();
        }

        return Option<TResult>.Some(selector(option.Value));
    }

    public static T GetOrElse<T>(this Option<T> option, T defaultValue)
    {
        return option.HasValue ? option.Value : defaultValue;
    }
}

これを使うと、nullチェックを毎回書かずに

  • 「あるときだけ変換する」
  • 「なければデフォルト値で置き換える」

というコードが書けます。

Option<User> TryFindUser(UserId id)
{
    var user = _repository.Find(id);
    return user is null
        ? Option<User>.None()
        : Option<User>.Some(user);
}

string GetUserNameOrGuest(UserId id)
{
    return TryFindUser(id)
        .Map(user => user.Name)
        .GetOrElse("ゲスト");
}

3.3 LINQ(メソッドチェーン)とつなげる

C#でよく見かける

numbers.Select(item => item * 3);

のような書き方と同じ感覚で Option<T> をつなげられるように、
LINQ向けの Select / SelectMany を実装します。

public static class OptionLinqExtensions
{
    // Map と同じ
    public static Option<TResult> Select<T, TResult>(
        this Option<T> option,
        Func<T, TResult> selector)
    {
        return option.Map(selector);
    }

    // Bind(flatMap)に相当
    public static Option<TResult> SelectMany<T, TResult>(
        this Option<T> option,
        Func<T, Option<TResult>> selector)
    {
        if (!option.HasValue)
        {
            return Option<TResult>.None();
        }

        return selector(option.Value);
    }
}

これを使うと、Option 同士の処理を
全部メソッドチェーンで書けるようになります。

Option<string> GetUserEmail(UserId id)
{
    return TryFindUser(id)                     // Option<User>
        .SelectMany(user => user.PrimaryEmail) // Option<string> を返すプロパティ想定
        .Select(email => email);               // Option<string>
}

普段から IEnumerable<T> に対して

numbers
    .Where(x => x > 10)
    .Select(x => x * 3);

と書いているのと同じノリで、

  • 「存在しないかもしれない User
  • 「存在しないかもしれない Email

をつないでいけます。

(C#のクエリ式 from ... in ... select ... も内部的には
この Select / SelectMany に展開されますが、
この記事ではメソッドチェーンのほうをメインに使います)

4. C#で簡易 Either<L, R> を実装してみる

次は、成功・失敗を両方とも型で表す Either<L, R> を作ってみます。

4.1 Either<L, R> の型定義

ここでは

  • Right を成功
  • Left を失敗(エラー情報)

として扱います。

public readonly struct Either<L, R>
{
    private readonly L _left;
    private readonly R _right;

    public bool IsRight { get; }
    public bool IsLeft => !IsRight;

    public L Left
    {
        get
        {
            if (!IsLeft)
            {
                throw new InvalidOperationException("Either does not contain a Left value.");
            }

            return _left;
        }
    }

    public R Right
    {
        get
        {
            if (!IsRight)
            {
                throw new InvalidOperationException("Either does not contain a Right value.");
            }

            return _right;
        }
    }

    private Either(L left)
    {
        _left = left;
        _right = default!;
        IsRight = false;
    }

    private Either(R right)
    {
        _right = right;
        _left = default!;
        IsRight = true;
    }

    public static Either<L, R> FromLeft(L value) => new Either<L, R>(value);

    public static Either<L, R> FromRight(R value) => new Either<L, R>(value);
}

4.2 Map / Bind で処理をつなげる(メソッドチェーン)

Either を活かすには、「成功したときだけ処理をつなげる」メソッドが欲しくなります。

public static class EitherExtensions
{
    // Right を変換する(Left はそのまま)
    public static Either<L, RResult> Map<L, R, RResult>(
        this Either<L, R> either,
        Func<R, RResult> selector)
    {
        if (either.IsLeft)
        {
            return Either<L, RResult>.FromLeft(either.Left);
        }

        return Either<L, RResult>.FromRight(selector(either.Right));
    }

    // Right を返す処理をつなげる(flatMap / Bind)
    public static Either<L, RResult> Bind<L, R, RResult>(
        this Either<L, R> either,
        Func<R, Either<L, RResult>> selector)
    {
        if (either.IsLeft)
        {
            return Either<L, RResult>.FromLeft(either.Left);
        }

        return selector(either.Right);
    }
}

使う側のイメージはこんな感じです。

Either<string, int> ParseAge(string input)
{
    if (string.IsNullOrWhiteSpace(input))
    {
        return Either<string, int>.FromLeft("年齢は必須です。");
    }

    if (!int.TryParse(input, out var age))
    {
        return Either<string, int>.FromLeft("年齢は数値で入力してください。");
    }

    if (age < 0 || age > 150)
    {
        return Either<string, int>.FromLeft("年齢は0〜150の範囲で入力してください。");
    }

    return Either<string, int>.FromRight(age);
}

Either<string, string> DescribeAge(string input)
{
    return ParseAge(input)
        .Map(age => $"年齢は {age} 歳です。");
}

Bind を使えば、「失敗したらそこで打ち切り、成功したら次へ進む」という処理を
if や try-catch なしでメソッドチェーンできます。

4.3 Left に何を入れるか問題:string / Exception / AppError

サンプルではまず Either<string, T> のように Left にメッセージ文字列を置くのが分かりやすいです。

ただ、実務を考えると

  • ログ出力のために Exception を持っておきたい
  • 「バリデーションエラー」と「DBのタイムアウト」を区別したい

といったニーズが出てきます。

そこで、Left に入れる型としては主に次の3パターンが考えられます。

  1. Either<string, T>

    • メリット:シンプルで分かりやすい
    • デメリット:エラーの種別を表現しづらい、スタックトレースが失われる
  2. Either<Exception, T>

    • メリット:ログ出力しやすい(スタックトレース付き)、外部ライブラリの例外をそのまま運べる
      -デメリット:業務エラーとバグ(プログラミングミス)の区別がつきにくい
  3. Either<AppError, T>(おすすめ)

    • エラー情報を表す独自型を定義し、その中に必要に応じて Exception を含める
public enum ErrorKind
{
    Validation,      // 入力値などのバリデーションエラー
    Business,        // 業務ルール違反
    Infrastructure,  // DB, ネットワークなど
    Unexpected       // 想定外の例外
}

public sealed record AppError(
    ErrorKind Kind,
    string Message,
    Exception? Cause = null
)
{
    public static AppError Validation(string message) =>
        new(ErrorKind.Validation, message);

    public static AppError Business(string message) =>
        new(ErrorKind.Business, message);

    public static AppError Infrastructure(string message, Exception ex) =>
        new(ErrorKind.Infrastructure, message, ex);

    public static AppError Unexpected(Exception ex) =>
        new(ErrorKind.Unexpected, ex.Message, ex);
}

これを使って Either<AppError, T> にすると、例えば次のように書けます:

Either<AppError, User> FindUser(UserId id)
{
    try
    {
        var entity = _db.Users.Find(id.Value);

        if (entity is null)
        {
            return Either<AppError, User>.FromLeft(
                AppError.Business("ユーザーが存在しません。")
            );
        }

        return Either<AppError, User>.FromRight(
            User.FromEntity(entity)
        );
    }
    catch (SqlException ex)
    {
        return Either<AppError, User>.FromLeft(
            AppError.Infrastructure("ユーザー検索中にDBエラーが発生しました。", ex)
        );
    }
    catch (Exception ex)
    {
        return Either<AppError, User>.FromLeft(
            AppError.Unexpected(ex)
        );
    }
}
  • UI 層・API 層から見ると ErrorKind で分岐できる
  • ログを出すときは AppError.Cause から例外を参照できる

ので、「型安全なエラー種別」と「ログ用の例外情報」の両方をいいとこ取りできます。

5. 「実はこれ全部モナドです」という話

ここまでで

  • Option<T>
  • Either<L, R>
  • Map / BindSelect / SelectMany

を実装してきました。

この「何かしらの“文脈”を持った値を、MapBind でつなげていく」というパターンが、
関数型界隈でいう モナド(monad) です。

「モナド」という言葉だけ聞くと身構えますが、
やっていること自体はこの記事の Option / Either でほぼ完走しています。

5.1 「文脈付きの値」としての Option / Either

さっき作った型を「文脈付きの値」として見てみます。

  • Option<T>
    • 「値があるかもしれないし、ないかもしれない」という“文脈”付きの T
    • 文脈:見つからない(null)かもしれない
  • Either<L, R>
    • 「成功して R が得られるかもしれないし、L な理由で失敗するかもしれない」
    • 文脈:失敗(エラー)するかもしれない

普通の T ではなく、

  • 「存在しないかもしれない T」 (Option<T>)
  • 「エラーつきの T」 (Either<AppError, T> など)

という「T に何らかの条件・意味づけ(=文脈)がくっついたもの」として扱う、という見方です。

5.2 モナド三点セット(C#版)

モナドの説明はいろいろありますが、実装者目線に寄せると、
ざっくり次の「三点セット」がそろってるかどうか、くらいに見るのがわかりやすいです。

ある型コンストラクタ M<T> があったときに:

  1. 生の値を包む関数

    • C#でいうと SomeFromRight など
    • 型的には T -> M<T>
  2. 中身を取り出して次の計算につなげる関数

    • C#でいうと Bind / SelectMany
    • 型的には M<T> × (T -> M<U>) -> M<U>
  3. 中身だけ変える関数

    • C#でいうと Map / Select
    • 型的には M<T> × (T -> U) -> M<U>

この記事で作ったものに当てはめると:

// 1. 生の値を包む(Option)
public static Option<T> Some<T>(T value);

// 2. 中身を取り出して次の計算につなげる(Either.Bind)
public static Either<L, RResult> Bind<L, R, RResult>(
    this Either<L, R> either,
    Func<R, Either<L, RResult>> selector);

// 3. 中身だけ変える(Option.Map / Either.Map)
public static Option<TResult> Map<T, TResult>(
    this Option<T> option,
    Func<T, TResult> selector);

public static Either<L, RResult> Map<L, R, RResult>(
    this Either<L, R> either,
    Func<R, RResult> selector);

もうこの時点で、**Option も Either も「モナドっぽい動きをしている」**と言ってOKです。

5.3 LINQとモナド(SelectMany の正体:メソッドチェーン編)

普段 C# でこんなコードを書いているとします。

var result = numbers
    .Where(x => x > 10)
    .Select(x => x * 3);

LINQで IEnumerable<T> をメソッドチェーンしているだけですが、
これを「文脈付きの値」として見直すと、

  • 文脈:複数個あるかもしれない値
  • Select:中身の値を変換する
  • SelectMany:ネストした列をフラットにしつつ次へ進める

というモナドそのもののパターンになっています。

同じことを Option<T> でやると、こうなります。

Option<string> GetUserEmail(UserId id)
{
    return TryFindUser(id)                     // Option<User>
        .SelectMany(user => user.PrimaryEmail) // Option<string> を返すプロパティ想定
        .Select(email => email);               // Option<string>
}
  • 「ユーザーが存在しないかもしれない」
  • 「メールアドレスが設定されていないかもしれない」

という2つの「文脈付きの値」を、SelectMany / Select でつないでいるイメージです。

ポイントは:

  • LINQのメソッドチェーン(Select / SelectMany) = モナドの map / bind
  • IEnumerable<T> だけじゃなく、Option<T>Either<L, T> にも同じインターフェースを生やすことで、
    いつもの LINQ の書き味で「文脈付きの値」を扱えるようになる

というところです。

5.4 「モナドを知っていると何がうれしいか」

実務的なメリットに絞って書くと、次の2つです。

  1. パターンに名前がつく

    • 「文脈付きの値を Map / Bind でつなげていくあの感じ」を
      「モナド」と呼べるので、他の人のコードや記事が読みやすくなります。
    • 例:「この型、モナドっぽく扱えるように Bind 生やしとくか」みたいな会話ができる。
  2. LINQメソッドチェーンの“中身”が分かる

    • Select / SelectMany の意味」を意識できるので、
      独自の文脈型(Option, Either, Result など)を LINQ 対応させやすくなります。

逆にいうと、この記事でやってきた

  • Option<T> / Either<L, R> の実装
  • Map / Bind / Select / SelectMany
  • LINQメソッドチェーンによる合成

を理解していれば、モナドの“実務に効く部分”はほぼ踏破していると言ってよさそうです。

6. C#の switch 式でパターンマッチ風に書いてみる

C# 8 以降の switch 式を使うと、EitherOption
「式として」分解しながら値を返す書き方に寄せられます。

6.1 Either × switch 式

string DescribeAge(string input)
{
    var result = ParseAge(input); // Either<string, int> を返す前提

    return result switch
    {
        { IsRight: true }  => $"年齢は {result.Right} 歳です。",
        { IsLeft:  true }  => $"エラー: {result.Left}",
        _                  => "不正な状態です。"
    };
}

AppError を使う場合も同様です。

string DescribeAgeWithAppError(string input)
{
    var result = ParseAgeWithAppError(input); // Either<AppError, int>

    return result switch
    {
        { IsRight: true } r =>
            $"年齢は {r.Right} 歳です。",

        { IsLeft: true, Left.Kind: ErrorKind.Validation } e =>
            $"入力エラー: {e.Left.Message}",

        { IsLeft: true, Left.Kind: ErrorKind.Unexpected } e =>
            $"システムエラー: {e.Left.Message}",

        _ => "不正な状態です。"
    };
}

6.2 Option × switch 式

string GetUserNameOrGuest(UserId id)
{
    var userOpt = TryFindUser(id);

    return userOpt switch
    {
        { HasValue: true }  => userOpt.Value.Name,
        { HasValue: false } => "ゲスト",
        _                   => "不正な状態です。"
    };
}

7. 実務コードへの当てはめ例(DDD寄り)

7.1 リポジトリの戻り値を Option<T> にする

ドメイン層に近いところでは、「見つからない」という状況を例外にしたくないことが多いです。

public interface IUserRepository
{
    Option<User> Find(UserId id);

    void Save(User user);
}

アプリケーションサービス側は、存在しない可能性が型から分かるようになります。

public sealed class UserQueryService
{
    private readonly IUserRepository _repository;

    public UserQueryService(IUserRepository repository)
    {
        _repository = repository;
    }

    public string GetUserNameOrGuest(UserId id)
    {
        return _repository.Find(id)
            .Map(user => user.Name)
            .GetOrElse("ゲスト");
    }
}

7.2 アプリケーションサービスの戻り値を Either<AppError, T> にする

登録処理など、業務的な失敗が多いアプリケーションサービスでは
Either<AppError, T> で戻すのも有効です。

public sealed class RegisterUserService
{
    private readonly IUserRepository _repository;

    public RegisterUserService(IUserRepository repository)
    {
        _repository = repository;
    }

    public Either<AppError, RegisterUserResult> Handle(RegisterUserCommand command)
    {
        // バリデーション
        if (string.IsNullOrWhiteSpace(command.Name))
        {
            return Either<AppError, RegisterUserResult>.FromLeft(
                AppError.Validation("名前は必須です。")
            );
        }

        // 重複チェック
        var existing = _repository.Find(command.UserId);
        if (existing.HasValue)
        {
            return Either<AppError, RegisterUserResult>.FromLeft(
                AppError.Business("同じIDのユーザーがすでに存在します。")
            );
        }

        var user = new User(command.UserId, command.Name);

        try
        {
            _repository.Save(user);
        }
        catch (SqlException ex)
        {
            return Either<AppError, RegisterUserResult>.FromLeft(
                AppError.Infrastructure("ユーザー登録中にDBエラーが発生しました。", ex)
            );
        }
        catch (Exception ex)
        {
            return Either<AppError, RegisterUserResult>.FromLeft(
                AppError.Unexpected(ex)
            );
        }

        return Either<AppError, RegisterUserResult>.FromRight(
            new RegisterUserResult(user.Id)
        );
    }
}

public sealed record RegisterUserCommand(UserId UserId, string Name);

public sealed record RegisterUserResult(UserId UserId);

UI層から見ると、業務エラーとシステムエラーを見分けやすくなります。

public IActionResult Register(RegisterUserRequest request)
{
    var command = new RegisterUserCommand(
        new UserId(request.Id),
        request.Name);

    var result = _service.Handle(command);

    return result switch
    {
        { IsRight: true } r =>
            Ok(new { id = r.Right.UserId.Value }),

        { IsLeft: true, Left.Kind: ErrorKind.Validation } e =>
            BadRequest(e.Left.Message),

        { IsLeft: true, Left.Kind: ErrorKind.Business } e =>
            Conflict(e.Left.Message),

        { IsLeft: true } e =>
            HandleServerSideError(e.Left),

        _ => StatusCode(500, "Unexpected state.")
    };
}

private IActionResult HandleServerSideError(AppError error)
{
    _logger.LogError(error.Cause, "Server error: {Message}", error.Message);
    return StatusCode(500, "サーバーでエラーが発生しました");
}

8. 実は既にある

ここまで自作してきましたがすでにあります!
代表的なものとしては、例えば以下のようなライブラリがあります。

  • LanguageExt
    • Option<T> / Either<L, R> / Try<T> など、関数型プログラミング用の型がひと通り揃っている
    • LINQ やパターンマッチヘルパーなども充実

以上、車輪の再開発でした。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?