Help us understand the problem. What is going on with this article?

バリデーションをメソッドチェーンで書く方法

More than 1 year has passed since last update.

アプリ開発で、ユーザ入力が伴う画面では必ずと言っていいほど入力内容のバリデーションが必要です。ところが、もしかしてそのバリデーションのコード、読みにくすぎ?

例えばこんな要件があるとします:ユーザのメンバー ID を入力する画面があります。この ID は 10 桁以内の数字です。さてこれのバリデーションを書きましょうか:

func isIDValid(_ id: String) -> Bool {

    guard !id.isEmpty else {
        return false
    }

    guard Int(id) != nil else {
        return false
    }

    guard id.count < 10 else {
        return false
    }

    return true

}

validate("123") // true
validate("abc") // false

うーん読めなくはないですが、guard 文多いですね。今回のような単純な要件ならまあ読めなくはないですが…
あと書くのだるい…

もちろん、どうせ Bool 返してるだけなので、いっそのことこんな感じで書くのもいいですね:

func validate(_ id: String) -> Bool {

    return !id.isEmpty
        && Int(id) != nil
        && id.count <= 10

}

validate("123")
validate("abc")

これならだいぶ書きやすくなります。ただやっぱ補完効きにくいのでまだ改善の余地はあるし、何よりそもそもの話、バリデーション結果を Bool で返すのはなんかな…ですよね。

と言うわけで、これをこんな風に書きたいのです:

func validate(_ id: String) -> ValidationStatus<InvalidID> {

    IDValidator.validate(id) { $0
        .isNotEmpty()
        .isInt()
        .lessThan10Digits()
    }

}


validate("123") // .valid
validate("abc") // .invalid(.notInt)

これではメソッドチェーンが使えるのでだいぶ書きやすいし、何よりなんのバリデーションか一目瞭然ですね!

では、これをどうやって作っているのかと言うと、簡単です。まずバリデーションの型を作ります:

protocol InvalidStatus: Equatable {

}

enum ValidationStatus<Invalid: InvalidStatus> {
    case valid
    case invalid(Invalid)
}

バリデーション状態は .valid.invalid のみにし、具体的な .invalid 状態はさらに Invalid で定義してもらいます。これのメリットは、大きなスコープで見た時は有効と無効だけで分けられるのでいろんな柔軟な対応がしやすい1し、具体的になぜ無効なのかが知りたいときのその無効状態を Invalid: InvalidStatus から取り出せます。

次に、このバリデーションを実際にかける型を作ります。とは言え、どっちかと言うとこれはラッパーオブジェクトに近いですかね:

struct ValidationContainer<Target, Invalid: InvalidStatus> {

    private let target: Target
    private let invalid: Invalid?

    private func finish() -> ValidationStatus<Invalid> {

        if let invalid = invalid {
            return .invalid(invalid)

        } else {
            return .valid
        }

    }

    static func validate(_ target: Target, with validation: (Self) -> Self) -> ValidationStatus<Invalid> {

        let container = Self.init(target: target, invalid: nil)
        let result = validation(container).finish()

        return result

    }

    func guarantee(_ condition: (Target) -> Bool, otherwise invalidStatus: Invalid) -> Self {

        // If the container already has an invalid status, skip the condition check.
        guard invalid == nil else {
            return self
        }

        if condition(target) == true {
            return self

        } else {
            return ValidationContainer(target: target, invalid: invalidStatus)
        }

    }

}

このコンテナは何かと言うと、現在のバリデーション対象と無効状態を保存して、そして次のバリデーションに回すためのものです。まずは無効状態なしで作って、そして次々と guarantee を回して、もしどこか無効状態が途中であったらそれをそのまま返して、最後まで問題なかったら finish().valid を返せばいいです。

あれ? validate メソッドに finish は登場したけど guarantee 登場してなくない?と思うかもしれませんが、guarantee は実際の利用する側に公開したメソッドで、それは validatevalidation: (Self) -> Self と言うクロージャ内で利用してもらう予定だからです。

ちなみに struct にも関わらず Self が書けるのは、Swift 5.1 の新機能です2

さて、ここまでで下準備ができました。ではどう利用すればいいでしょうか?まずは InvalidStatus を作ります。今回は ID のバリデーションなので、InvalidID を作りましょう:

enum InvalidID: InvalidStatus {
    case empty
    case notInt
    case tooLong(maxCount: Int)
}

見ての通り、ID が空か、数字じゃないか、もしくは文字数が多すぎるの 3 パターンの無効判定があります。そしてその次、それぞれの判定を guarantee を使って組み込みます:

extension ValidationContainer where Target == String, Invalid == InvalidID {

    func isNotEmpty() -> Self {

        return guarantee({ !$0.isEmpty }, otherwise: .empty)

    }

    func isInt() -> Self {

        return guarantee({ Int($0) != nil }, otherwise: .notInt)

    }

    func lessThan10Digits() -> Self {

        let maxDigits = 10
        return guarantee({ $0.count <= maxDigits }, otherwise: .tooLong(maxCount: maxDigits))

    }

}

これで 文字列は空文字列じゃない文字列は数字である文字列は最大 10 文字以内 のバリデーションが書けました。最後は typealias を定義してこれらを繋げば、読みやすいバリデーションが書けますね!

typealias IDValidator = ValidationContainer<String, InvalidID>

func validate(_ id: String) -> ValidationStatus<InvalidID> {

    IDValidator.validate(id) { $0
        .isNotEmpty()
        .isInt()
        .lessThan10Digits()
    }

}


validate("123") // .valid
validate("abc") // .invalid(.notInt)

めでたしめでたし。


p.s. この書き方なんか見覚えあるぞ?と思ってくれた方もしいらっしゃったら、ありがとうございます!!そうですあの NotAutoLayout でも使ってたクロージャ×メソッドチェーンの組み方です :laughing:

lovee
Swift 信者。 Auto Layout 絶対殺すマン。 今日も 1 日がんばるぞい。 彼女?何それ都市伝説?(と言ってる間彼女ができて結婚しちゃった
http://crazism.net
yumemi
みんなが知ってるあのサービス、実はゆめみが作ってます。スマホアプリ/Webサービスの企画・UX/UI設計、開発運用。Swift, Kotlin, PHP, Vue.js, React.js, Node.js, AWS等エンジニア・クリエイターの会社です。Twitterで情報配信中https://twitter.com/yumemiinc
http://www.yumemi.co.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away