DEV Community

Seiya Izumi
Seiya Izumi

Posted on • Edited on

Designing Opaque Type for form fields in Elm: Part 1

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 }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        }
Enter fullscreen mode Exit fullscreen mode

One-file demo on Ellie is here

Top comments (0)