This is an article that shares my knowledge on designing Opaque Type for form fields.
Let's say, we have this kind of a simple form.
-- A simple String value form
type alias Model =
{ username : String }
init : Model
init =
{ username = "" }
type Msg
= UsernameInputted String
update : Msg -> Model -> Model
update msg =
case msg of
UsernameInputted value ->
{ username = value }
This is pretty much fine for now, but one day we feel like to add validation to username
that it must be more than 5 characters and less than 20 characters.
Opaque Type for validations
Now, this is the time to use Opaque Type to encapsulate validation logic into the separated module. We introduce Username
type which wraps String
as its internal data.
type Username =
Username String
new : String -> Maybe Username
new value =
if String.length value > 20 then
Nothing
else if String.length value < 5 then
Nothing
else
Just (Username value)
new
function now here is a point that does validation of inputted String
. If the inputted data is valid, it returns Just
.
Okay, now it seems that we can start using Username
as a field value like below.
type alias Model =
{ username : Username }
However, it is impossible actually.
Design failure
The main reason is time to run validation. In current design, we have validation mechanism in new
function so that it always works when Username
data is initialized with String
value. The time to initialize it is that update
function handling message attached to onInput
event coming from views, so Username
initialization is going to be triggered every single time when users type their keyboard to fill the field.
It means that, even though the users don't finish typing their value on the field, validation errors will pop up, because the value they are typing partially to fill the field is always invalid. Errors will appear from the beginning. This is so annoying!
So, in case of this, we need to change timing to trigger off validation. The thing to keep in mind is that, Opaque Type for input fields will have "partial" state which describes "users don't need to get validation at this moment". Don't start validation from the very beginning, but do it right once users finished their typing on the field.
Let's see how we can make it better!
Improvement
Now, Username
type has three patterns. Every variant has String that describes the current value of it. When the data is Partial
, it means that the data conveyed on it does not get validated.
module Username exposing (Username, Error(..), empty, error)
type Username
= Partial String
| Valid String
| Invalid String Error
type Error
= LengthTooLong
| LengthTooShort
empty : Username
empty =
Partial ""
error : Username -> Maybe Error
error username =
case username of
Invalid _ error ->
Just error
_ ->
Nothing
The intitial state of Username
is always Partial
as empty
functions shows. It will not be changed until users finished filling value of it.
Then, when is Partial
changed into other two variants? That's exactly what view
and blur
function do.
The moment to start validation is when users removed focus from input fields, so this module provides blur
function for it. blur
function is expected to be used in update
function handling onBlur
event. It triggers off validation at once.
On the contrary, the handler for onInput
event does not trigger validation during Partial
is given to it. It always is just waiting that blur
function gets called. Once validation has started by blur
function, the handler for onInput
triggers off validation every time!
module Username exposing (Username, Error(..), empty, error, input, blur)
import Browser
import Html exposing (Html)
import Html.Attributes exposing (type_, value)
import Html.Events exposing (onBlur, onInput)
-- ...
input : (Username -> msg) -> msg -> Username -> Html msg
input onInputMsg onBlurMsg username =
let
onInputHandler =
\value ->
onInputMsg <|
case username of
Partial _ ->
Partial value
_ ->
validate value
in
Html.input
[ type_ "text"
, value <| toString username
, onBlur onBlurMsg
, onInput onInputHandler
]
[]
blur : Username -> Username
blur username =
case username of
Partial value ->
validate value
_ ->
username
-- internals
validate : String -> Username
validate value =
if String.length value > 20 then
Invalid value LengthTooLong
else if String.length value < 5 then
Invalid value LengthTooShort
else
Valid value
toString : Username -> String
toString username =
case username of
Partial value ->
value
Valid value ->
value
Invalid value _ ->
value
validate
function and toString
function are pretty simple. They two probably have no need to be described. One is just to do validation, and the other is to extract String
from Username
type.
Wire it up!
Now, we don't have to care timing or anything in converting a primitive value of String
into Username
. It all is completely handled by Username
module itself. Gee!
module App exposing (main)
import Html exposing (Html, div)
import Username
type alias Model =
{ username : Username.Username }
init : Model
init =
{ username = Username.empty }
type Msg
= UsernameInputted Username.Username
| UsernameBlurred
update : Msg -> Model -> Model
update msg model =
case msg of
UsernameInputted username ->
{ username = username }
UsernameBlurred ->
{ username = Username.blur model.username }
view : Model -> Html Msg
view model =
let
error =
model.username
|> Username.error
|> Maybe.withDefault ""
in
div
[]
[ Username.input UsernameInputted UsernameBlurred model.username
, div [] [ text error ]
]
main : Program () Model Msg
main =
Browser.sandbox
{ init = init
, view = view
, update = update
}
One-file demo on Ellie is here
Top comments (0)