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:
- Enumerate the possible states of my program
- See if I can represent them with a custom type
- Find invalid states and eliminate them
- 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.
EmailForm ""
EmailForm "notanemail"
EmailForm "valid@email.com"
PasswordForm "" ""
PasswordForm "" "password"
PasswordForm "notanemail" ""
PasswordForm "notanemail" "password"
PasswordForm "valid@email.com" ""
PasswordForm "valid@email.com" "password"
LoggedInScreen "notanemail"
LoggedInScreen ""
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!
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.