1
\$\begingroup\$

I have the following form that creates a new "activity":

enter image description here

For which I want to enforce the following requirements:

  • Name => minimum # of chars: 1; maximum # of chars: 50
  • Description => maximum # of chars: 200

I have written the following code:

// ActivityDetailViewController.swift

import UIKit

private struct Constants {
    static let activityNameMaxCharacters = 50
    static let activityDescriptionMaxCharacters = 200
}

final class ActivityDetailViewController: NiblessViewController {

    /* ... */

    // This method is called when user taps on the "Add" bar button item
    // from the navigation bar.
    @objc
    func save(_ sender: UIBarButtonItem) {
        do {
            try validateInputs()
            activity.name = nameField.text ?? ""
            activity.activityDescription = descriptionTextView.text
            presentingViewController?.dismiss(animated: true, completion: onDismiss)
        } catch let error as ErrorMessage {
            present(errorMessage: error)
        } catch {
            // NO-OP
        }
    }

    /* ... */

    // This methods applies the input validation rules.
    private func validateInputs() throws {
        guard let activityName = nameField.text else {
            let errorMessage = ErrorMessage(
                title: "Activity Creation Error",
                message: "Activity name can't be empty."
            )
            throw errorMessage
        }
        
        let activityDescription = descriptionTextView.text ?? ""
        
        if activityName.count > Constants.activityNameMaxCharacters {
            let errorMessage = ErrorMessage(
                title: "Activity Creation Error",
                message: "Activity name exceeds max characters (50)."
            )
            throw errorMessage

        } else if activityDescription.count > Constants.activityDescriptionMaxCharacters {
            let errorMessage = ErrorMessage(
                title: "Activity Creation Error",
                message: "Activity description exceeds max characters (200)."
            )
            throw errorMessage
        }
    }
}

Some supporting code:

// ErrorMessage.swift

import Foundation

public struct ErrorMessage: Error {
    
    // MARK: - Properties
    public let id: UUID
    public let title: String
    public let message: String
    
    // MARK: - Methods
    public init(title: String, message: String) {
        self.id = UUID()
        self.title = title
        self.message = message
    }
}

extension ErrorMessage: Equatable {
    
    public static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.id == rhs.id
    }
}
// UIViewController+ErrorPresentation.swift

import UIKit

extension UIViewController {
    
    public func present(errorMessage: ErrorMessage) {
        let errorAlertController = UIAlertController(
            title: errorMessage.title,
            message: errorMessage.message,
            preferredStyle: .alert
        )
        let okAction = UIAlertAction(title: "OK", style: .default)
        errorAlertController.addAction(okAction)
        present(errorAlertController, animated: true, completion: nil)
    }
}

I'd appreciate any feedback on how to improve the current solution.

Things I don't like:

  • Having to add an empty catch at the end of the try-catch clause located in the save(_:) method. Otherwise the compiler complains with: "Errors thrown from here are not handled because the enclosing catch is not exhaustive."
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

One can make the error conform to LocalizedError:

// MARK: - ActivityCreationError

enum ActivityCreationError: Error {
    case empty
    case nameTooLong
    case descriptionTooLong
}

// MARK: LocalizedError conformance

extension ActivityCreationError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .empty:
            return NSLocalizedString("The name cannot be empty.", comment: "MyError.empty")

        case .nameTooLong:
            return String.localizedStringWithFormat(
                NSLocalizedString("The name is too long (max %d characters).", comment: "MyError.nameTooLong"),
                Constants.activityNameMaxCharacters
            )

        case .descriptionTooLong:
            return String.localizedStringWithFormat(
                NSLocalizedString("The description is too long (max %d characters).", comment: "MyError.descriptionTooLong"),
                Constants.activityDescriptionMaxCharacters
            )
        }
    }
}

// MARK: Public interface

extension ActivityCreationError {
    static let title = NSLocalizedString("Activity Creation Error", comment: "MyError.title")
}

Notice that in my error messages, in the spirit of keeping it DRY, I avoided hardcoding the max string lengths again in the error message, but instead used localizedStringWithFormat to pull the same constant that was used during the validation process.

Then you can just catch it and use its localizedDescription:

do {
    try validateInputs()
    ...
} catch {
    presentAlert(title: ActivityCreationError.title, message: error.localizedDescription)
}

Where:

extension UIViewController {
    public func presentAlert(title: String?, message: String?) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        let string = NSLocalizedString("OK", comment: "Alert Button")
        let action = UIAlertAction(title: string, style: .default)
        alert.addAction(action)

        present(alert, animated: true)
    }
}

By the way, before confirming validating the input and confirming string lengths, I would suggest sanitizing the input to trim whitespace. You do not want, for example, a user saying “oh, the name is required? ... well, I'll get around that by just entering a single space in that field.” You probably want the whitespace removed, anyway, as you do not want, for example, input with leading or trailing spaces.

E.g., rather than:

let activityDescription = descriptionTextView.text ?? ""

Use:

let activityDescription = descriptionTextView.text.?trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

If you are looking for more advanced counsel, one might adopt a better separation of responsibilities and pull the validation logic out of the view controller altogether. You might put it in what some people call the “view model” or the “presenter” or simply the “controller” (not to be confused with the “view controller”), a UIKit independent object in which you place your business logic (e.g. validation rules, initiate interactions with data stores or network managers, etc.).

For more information, see:

  • See Dave Delong’s A Better MVC for a perspective of embracing MVC but employing techniques to keep them small and manageable.

  • See Medium’s iOS Architecture Patterns for a review of other approaches ranging from MVP, MVVM, Viper, etc.

  • See Krzysztof Zabłocki’s iOS Application Architecture for another good discussion on the topic.

The common theme in all of these discussions is to avoid conflating the “view” (the configuration of and interaction with UIKit controls) and the business business rules.

\$\endgroup\$
4
  • \$\begingroup\$ Thank you!! This is a fantastic improvement to my code. May I ask what's the purpose of the "comment" parameter that NSLocalizedString(_:comment:) expects? The docs don't give any explanation whatsoever. \$\endgroup\$
    – Enrique
    Feb 9, 2022 at 16:58
  • \$\begingroup\$ Also, since the Constants enum is private to my view controller; I could not access it from ActivityCreationError.swift. So I decided to add a "maxCharacters" associated value to the "nameTooLong" and to the "descriptionTooLong" cases. That way, I can pass the maximum number of characters from the view controller to the enum cases when instantiating them. \$\endgroup\$
    – Enrique
    Feb 9, 2022 at 17:09
  • 1
    \$\begingroup\$ As an aside, to my last point in my answer, none of this validation code really belongs in a view controller. But setting that aside, as long as you don’t repeat the 50/200 values, that is the primary goal. \$\endgroup\$
    – Rob
    Feb 9, 2022 at 18:06
  • 1
    \$\begingroup\$ “what's the purpose of the ‘comment’ parameter?” … often, when you localize an app, the translator is not looking at your code, but just a list of English phrases and the “comment”. The comment gives them a hint about the context where their translation will be used. \$\endgroup\$
    – Rob
    Feb 9, 2022 at 23:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.