Handling Nested Conditional Logic

TL;DR

This turned out pretty long so let me summarize here. Read through the whole thing if you want to see the process of how I worked up to the final solution.

The answer to @DylanLester’s quesiton is yes. Using a custom type allows us to write flatter case statements that scale better as we add more fields. They also gives us more guarantees and allow us to have a richer data model. Here’s the final result:

type Model
  = EmailForm (Validated Email)
  | PasswordForm Email (Validated String)
  | LoggedInScreen Email

I used the techniques described below to build up a full working example on Ellie.

Process

When faced with a problem like this I like to:

  1. Enumerate the possible states of my program
  2. See if I can represent them with a custom type
  3. Find invalid states and eliminate them
  4. Repeat until satisfied

For your problem, it looks like there are three screens - email form, password form, logged in screen. These can be represented as a custom type:

type Model
  = EmailForm
  | PasswordForm
  | LoggedInScreen

Keeping track of user inputs

This is a good start but it’s incomplete. On the email form, we want to keep track of what the user is typing. Same thing on the password form. Let’s add parameters to for those to our type:

type Model
  = EmailForm String
  | PasswordForm String
  | LoggedInScreen

That’s better. However, we want to display the email from the email form on both the password form and logged in states. We’ll need to make sure we store it there too:

type Model
  = EmailForm String
  | PasswordForm String String
  | LoggedInScreen String

Making impossible states impossible

That’s starting to cover most of our use-cases. What about invalid states? The user input on the email and password forms is allowed to be invalid so all values are permitted. However, we’d like to assume that our email is valid once we’ve moved past the email form step.

:white_check_mark: EmailForm ""
:white_check_mark: EmailForm "notanemail"
:white_check_mark: EmailForm "valid@email.com"

:x: PasswordForm "" ""
:x: PasswordForm "" "password"
:x: PasswordForm "notanemail" ""
:x: PasswordForm "notanemail" "password"
:white_check_mark: PasswordForm "valid@email.com" ""
:white_check_mark: PasswordForm "valid@email.com" "password"

:x: LoggedInScreen "notanemail"
:x: LoggedInScreen ""
:white_check_mark: LoggedInScreen "valid@email.com"

Aside: Opaque types

To solve this problem, we can create an opaque type whose only exposed constructor forces validation. With this approach, we can be sure that an Email value has a valid email:

module Email exposing (Email, fromString, toString)

type Email = Email String

-- this is the only way for code outside this module
-- to construct an email value

fromString : String -> Result String Email
fromString string =
  if String.contains "@" string then
    Ok (Email string)
  else
    Err "Email must contain an @ sign"

toString : Email -> String
toString (Email email) =
  email

Putting it all together

Now that we have our opaque Email type, we can use it to eliminate invalid emails:

type Model
  = EmailForm String
  | PasswordForm Email String
  | LoggedInScreen Email

Aside: Validation type

Our type is starting to look really good. There’s one thing it doesn’t store though. The validation state and possible error messages. We can use the same process we’ve used for the Model type to define a type that models validated data. We need three states: an initial empty state (so fields aren’t marked invalid on first page load), an invalid state, and a valid state.

type Validated
  = Initial
  | Invalid
  | Valid

For the invalid and valid cases, we want to track the raw string that was in the input so that we can re-render the form correctly. The initial state is always empty.

type Validated
  = Initial
  | Invalid String
  | Valid String

If the data is invalid, we’ll also want a string for the error.

type Validated
  = Initial
  | Invalid String String
  | Valid String

Finally as a nice-to-have, we might want to store a transformed valid string into a type that enforces correctness (like our Email from before).

type Validated a
  = Initial
  | Invalid String String
  | Valid String a

We might define a function like:

validate : (String -> Result String a) -> String -> Validated a
validate validationFunction raw =
  case validationFunction raw of
    Ok value -> Validated raw value
    Err error -> Invalid raw error

We could use it like:

validate Email.fromString "notanemail"
-- Invalid "notanemail" "Email must contain an @ sign"

validate Email.fromString "valid@email.com"
-- Valid "valid@email.com" (Email "valid@email.com")

Putting it all together

Now that we have the validation type, we can wrap all user inputs in it to get validation status and errors:

type Model
  = EmailForm (Validated Email)
  | PasswordForm Email (Validated String)
  | LoggedInScreen Email

Looks like we now capture all the states we want and none of the states we don’t want! :tada:

Rendering the view

So does this make the view code any nicer? Let’s take a try:

view : Model -> Html Msg
view model =
  case model of
    EmailForm userInput -> emailForm userInput
    PasswordForm email userInput -> passwordForm email userInput
    LoggedInScreen email -> loggedIn email

emailForm : Validated Email -> Html Msg
emailForm userInput =
  div []
    [ hint userInput
    , input [ value (rawString userInput) ] []
    ]

hint : Validated a -> Html Msg
hint userInput =
  case userInput of
    Initial -> div [] []
    Invalid _ error -> div [ class "invalid" ] [ text error ]
    Valid _ _ -> div [ class "valid" [ text "Valid!" ]

Is this any better? Well we’ve been able to flatten the conditional to some case statements. This approach also doesn’t grow exponentially as new cases and fields are added. In my opinion, this is a nicer way to write something views with validation.

Finished product

I used the techniques described to build up a full working example on Ellie.

21 Likes