Using PureScript to Create a Domain-Specific Language for Building Forms with Validation

By Arthur Xavier · November 8, 2018

In a previous post, we have shown how we use PureScript and Haskell at Lumi to improve the correctness of our code. Using languages with such powerful type systems not only helps us write more correct code, but it also helps us think about domains and behavior, increasing our creativity and efficiency. However, the expressiveness of these languages is not only to be observed in their type system, but also in their syntax, which allows us to write and use   embedded, domain-specific languages with ease.

In this post, we explain the process of creating a small, type-safe, embedded, domain-specific language for specifying form components with validation in PureScript, explaining all the whys and hows.

Embedded, domain-specific languages

Ever wished you could use a new language that's perfectly tailored to solve the specific problem at hand? These are called domain-specific languages (DSLs) — computer languages specialized to a particular application domain. Some examples of DSLs are: HTML, CSS, Elm, SQL, GLSL, LaTeX, and Markdown, which is  used to write this very post.

However, creating a language for specific domains is highly impractical in modern software engineering. That’s why it's common practice to use embedded, domain-specific languages (EDSLs). These are domain-specific languages that are implemented as libraries for a general purpose “host language”, allowing the EDSL to exploit the host language’s infrastructure including its syntax, type system, modularity, etc., while enabling programmers to work on a much higher level of abstraction.

Languages like Haskell and PureScript, have such a rich syntax that using an EDSL in these languages can feel like writing in a different one. An example of this is what we will have by the end of this tutorial — an embedded, domain-specific language for specifying form components with validation. Here is what a form written in this EDSL can look like:

 Using PureScript to Create a Domain-Specific Language for Building Forms with Validation

To write this form, we start with simple form fields like textbox, passwordBox, numericInput or array, then add some focus to better see the parts of the complex data structure the form edits. Then we add some syntactic sugar to the mixture with ado, sprinkle in some labels, section separators and validation with the validated combinator, and voilà. We have a form! Here is the code that generates the form in the picture above:

userForm :: Form UserFormData User
userForm = ado
  section "User data"
  username <-
    label "Username" Required
    $ focus (prop (SProxy :: SProxy "username"))
    $ validated (nonEmpty "Username")
    $ textbox

  password <- wizard do
    password <- step
      $ label "Password" Required
      $ focus (prop (SProxy :: SProxy "password"))
      $ validated (nonEmpty "Password")
      $ passwordBox
    passwordConfirmation <- step
      $ label "Password confirmation" Required
      $ focus (prop (SProxy :: SProxy "passwordConfirmation"))
      $ validated
          (mustEqual "Passwords must match" (toString password))
      $ passwordBox
    pure password

  section "Personal data"
  age <-
    label "Age" Optional
    $ focus (prop (SProxy :: SProxy "age"))
    $ numericInput

  addresses <-
    label "Addresses" Neither
    $ focus (prop (SProxy :: SProxy "addresses"))
    $ array defaultAddress addressForm

  in User { username, password, age, addresses }

Return to this code snippet by the end of every section to understand how the developed concepts are used in a real example.

From reading the code above, you can already spot some advantages of using this approach (frequently called language-oriented programming). The most significant advantage is that this spproach allows us to exploit the host language’s syntax  —  in this case PureScript  —  to write form specifications in a clear, type-safe, and composable way. This is why EDSLs are also often called "combinator libraries", as we can use them to build complex structures out of simpler, composable ones.

A language for specifying forms

In a strict sense, a form is a user interface capable of modifying a data structure. It has an input and an output (or result). A translation of this definition to a PureScript type is:

newtype Form a = Form (a -> (a -> Effect Unit) -> UI)

Here, a is the type of the data structure which is manipulated by the form. a -> Effect Unit is a callback for when the input value is changed in the user interface (henceforth called the "change callback"), and UI is a visual representation of the interface (can be a Virtual DOM, React's JSX, GTK widgets, or even text).

A better definition of Form must include a differentiation between input and output. One reason for this is that different UI representations might yield different input types. For example, some user interface library might use a specific data type of a checkbox's checked state instead of a Boolean, the desired output for such an input field. Some larger forms might also have a more condensed output than there are fields in them. This is why we can rewrite our definition of the Form type as:

newtype Form i a =
  Form (i -> { ui :: (i -> Effect Unit) -> UI, result :: a })

We include the change callback in the ui field of the result record because its is considered a part of the user interface and should not be a requirement for obtaining an output from the form.

Combining forms

According to the definition of Form i a, a form can be any user interface capable of manipulating a data structure of type i and yielding a result of type a . In particular, single input fields such as text boxes and check boxes may be treated like simple forms themselves.

In order to build larger forms from many fields, we might try to build a Form by hand each time. However, we would prefer to find a way of combining these single-input forms into larger, more useful forms. In order to do this, we can make use of a few PureScript type classes, like the Applicative type class.

In order to instantiate the Applicative type class for Form, we must also have instances of the Functor and Apply type classes. When instantiated to Form, these three classes provide us the following three functions:

  • Functor: map :: (a -> b) -> Form i a -> Form i b
  • Apply: apply :: Form i (a -> b) -> Form i a -> Form i b
  • Applicative: pure :: a -> Form i a.

The map function from the Functor type class allows us to change the result type of a form, while the pure function from Applicative allows us to create an empty form that produces a specific, pre-defined result.

However in this case, the most important of the three functions is apply from the Apply type class. When used together with map, the apply function allows us to combine multiple forms (both their UIs and their result types), so that we can create a larger form out of multiple, individual ones.

This type class is often associated with side effects and in this context, we can think of the combination of form UIs as being a side effect of composing forms and their results, as with the Writer monad, which accumulates a monoid on the side.

Considering that <$> is the infix operator for map, and <*> is the one for apply, an example of this is:

personForm =
  (\firstName lastName { address, city } -> firstName <> " " <> lastName <> " lives at " <> address <> ", " <> city)
  <$> firstNameForm
  <*> lastNameForm
  <*> addressForm

Or using PureScript’s ado-notation, a syntactic sugar that translates expressions written in applicative syntax (such as the example above) to a do-notation inspired syntax, like below:

personForm = ado
  firstName <- firstNameForm
  lastName <- lastNameForm
  { address, city } <- addressForm
  in firstName <> " " <> lastName <> " lives at " <> address <> ", " <> city

This is the basis of our DSL for specifying form components: a Form data type with input and output types, a current value, and a user interface (comprised of a change callback and a UI representation); and (polymorphic) functions that let us combine multiple Forms.

Editing a data structure with a form

The goal of this DSL is to combine multiple small forms (as single input fields are also forms) to obtain a more complex one that can be used to edit a large data structure. Consider the case of the previous example, where we could have the following data structure:

type PersonFormData =
  { firstName :: String
  , lastName :: String
  , address ::
      { address :: String
      , city :: String

In this case, each single form field (a Form value itself) must have PersonFormData as its input type, and this means that we wouldn't be able to, for example, use a general text box for the firstName field, as this text box would have the type:

textbox :: Form String String

Fortunately, it’s possible to fix this problem by using lenses.

Also called functional references, lenses are composable functions that let us edit some part of a data structure. Lenses are a natural fit for forms, with the purpose of visually editing a large structure in terms of its smaller parts.

The purescript-profunctor-lenses library provides a comprehensive set of types and composable functions for updating, viewing, and setting values within nested data structures. Given that the type Lens' s a describes a way of viewing or transforming a value of type a that always exists somewhere inside a structure of type s, a function that changes a form to focus on a small part of a larger structure would have type:

focus :: forall i j a. Lens' i j -> Form j a -> Form i a
focus l (Form f) =
  Form \i ->
      { ui, result } = f (view l i)
      { ui: \onChange -> ui (onChange <<< flip (set l) i)
      , result

With focus, now we are able to edit complex data structures with Form. An example is the implementation of personForm below. Note that the propfunction creates a lens that focuses on a specific field of a record (given an SProxy to the field's label):

personForm :: Form PersonFormData String
personForm = ado
  firstName <- focus (prop (SProxy :: SProxy "firstName")) textbox
  lastName <- focus (prop (SProxy :: SProxy "lastName")) textbox
  { address, city } <- focus (prop (SProxy :: SProxy "address")) addressForm
  in firstName <> " " <> lastName <> " lives at " <> address <> ", " <> city
addressForm :: Form { address :: String, city :: String } String
addressForm = ado
  address <- focus (prop (SProxy :: SProxy "address")) textbox
  city <- focus (prop (SProxy :: SProxy "city")) textbox
  in { address, city }

Notice how the lenses used in the definition of addressForm compose with the one used in personForm when applying focus to addressForm. This is the power of composable lenses. They allow us to edit a large, complex data structure with our (so far) simple DSL.

Adding validation to the language

Validated forms are nothing more than forms which output depends on some validation criteria. Let’s recall the definition of our Form type:

newtype Form i a = Form (i -> { ui :: (i -> Effect Unit) -> UI, result :: a })

In this definition we can see that, if we provide an (unvalidated) input value to the form, we will always get back a user interface and an output value.

So in order to have a validated form, we simply change the return type of the function wrapped in the Form constructor. If a validated form does not always return a valid output, then it's natural to change this definition to:

newtype Form i a =
  Form (i -> { ui :: (i -> Effect Unit) -> UI, result :: Maybe a })

Note that, with this change, the Functor, Apply and Applicative instances of Form must also be changed to take the optional return value into account. If a form yields its return value as Nothing, this means it's invalid because we will not be able to retrieve a value of type a by running the form. If it yields a Just a case, however, then it's valid.

This, in turn, changes the way the forms behave. Although all the form UIs get concatenated in an applicative chain (with ado-notation or with <$> and <*>), the final result is only available if all the intermediary forms yield a valid result.

Handling validation errors

The choice of the type constructor used to wrap the form’s output value may be contested. Maybe was chosen because we don't want to handle validation errors outside the form. Other valid choices for this type constructor could be Either or the V type, from the purescript-validation library. The latter would allow us to accumulate validation errors if more than one form field is invalid.

With Maybe, however, we chose to encapsulate the handling of validation errors in the user interface itself. In this sense, we need a way of transforming a form in order to display validation errors. But first we must define what validation actually is.

In the context of validated forms, a value can be either valid or invalid. If invalid, we would like to know what happened — that is, we would like to have a validation error. If it is valid, however, we would probably transform it into an output value of a different type. For example, a required text field might have its input encoded as a String, whereas its output value should be a NonEmptyString.

Thus, we define a "validator" as:

type Validator i a = i -> Either String a

Some examples of validators are:

nonNull :: String -> Validator (Maybe a) a
nonNull name = maybe (Left (name <> " is required.")) Right
nonEmpty :: String -> Validator String NonEmptyString
nonEmpty name s =
  case NonEmptyString.fromString s of
    Just nes -> Right nes
    Nothing -> Left (name <> " is required.")
mustEqual :: forall a. Eq a => a -> String -> Validator a a
mustEqual value1 error value2 =
  if value1 == value2
    then Right value1
    else Left error

The one thing left to properly integrate validation into our DSL is a combinator that uses a Validator. Given a Validator, this combinator must change a Form to display validation errors if its result is invalid, and change the result type:

validated :: forall i a b. Validator i b -> Form i a -> Form i b
validated validator (Form f) =
  Form \v ->
      { ui } = f v.value
      err = validator v.value
      { ui: \onChange -> displayValidationError err (ui onChange)
      , result: hush err

That’s it! Given a function displayValidationError that appends a validation error to the form, we can use a function as simple validated to validate our forms and display errors.

The validated function as defined above doesn't take into account, however, the state of the form fields in order to display the validation errors. That is, if a form field is unmodified, but is validated as a non empty string, the error message will be displayed anyway. This is not a good user experience To fix it, we track the state of a form field when it is being validated.

Tracking form field state for validation messages

Consider a simple change to the behavior of our form: validation errors should only be displayed if a form field has been modified.

As with all other combinators in our DSL, if we want to change the behavior of a form based on its input, we should create a new combinator that acts on it, possibly changing the input type. This is exactly what we want in this case — to embellish the input of a form field with an additional state: whether or not it has been modified. This can be achieved with a simple type:

type Validated a = { value :: a, modified :: Boolean }

The Validated type defines the state of a form field on which validation is performed. It contains a value and can be either a fresh or a modified field. Another possibility for this is to change the definition of Form to encapsulate the input type in Validated, but we chose the first option for the sake of simplicity.

The only change left now is to effectively track this state on validated fields. For this, we need to change the result of the validated function to be a Form whose input is of type Validated i. Also notice how modified is set to true when building the resulting user interface:

  :: forall i a b
   . Validator i b
  -> Form i a
  -> Form (Validated i) b
validated validator (Form f) =
  Form \v ->
      { ui } = f v.value
      err = validator v.value
      { ui: \onChange ->
            (ui (onChange <<< { value: _, modified: true }))
      , result: hush err

With this simple change, every time the value of a form field is changed, its modified flag is set to true. As a consequence, in order for this mechanism to work as expected, we must initialize all form fields of type Validated i with the modified flag set to false.

We can include all sorts of different states in the Validated type, some interesting possibilities include: asynchronous validation, debouncing, validation based on the focus state and much more.

Monadic validation and wizards

One interesting fact about the DSL we have so far is that it admits a Monad instance. This is another advantage of choosing Maybe for the form's output value (over V, for example), as it has a lawful Monad instance.

In terms of behavior, this means that in a monadic chain, subsequent form fields are only displayed if all the previous ones produce valid results, just as in a wizard.

The problem is that this Monad instance is incompatible with the Apply instance of Form. In order to remedy this situation, we can define a newtype that simply encapsulates a Form and adds this wizard-like behavior in a compatible way:

newtype Wizard i a = Wizard (Form i a)
wizard :: forall i. Wizard i ~> Form i
wizard (Wizard form) = form
step :: forall i. Form i ~> Wizard i
step = Wizard

The Functor and Applicative instances for Wizard can be newtype-derived. But the Apply instance must be defined in terms of ap so that it is compatible with the Monad instance, which is left as an exercise for the reader.

Besides providing us the wizard-like behavior, the greatest advantage of having a Monad instance is that we're now able to have sequential dependencies on validated values.

One good illustration of this is a form containing fields for password and password confirmation, where the password confirmation is validated with mustEqual over the valid password:

passwordForm :: Form _ NonEmptyString
passwordForm = wizard do
  password <- step
    $ focus (prop (SProxy :: SProxy "password"))
    $ validated (nonEmpty "Password")
    $ passwordBox
  passwordConfirmation <- step
    $ focus (prop (SProxy :: SProxy "passwordConfirmation"))
    $ validated
        (mustEqual "Passwords must match" (toString password))
    $ passwordBox
  pure password

Here’s what it looks like in action (the field labels are a different combinator, not defined here as it depends on the choice for UI):

 Using PureScript to Create a Domain-Specific Language for Building Forms with Validation

Fetching remote data with Wizard

The convenience introduced by the Monad instance of Wizard begets its exploitation. For example, if the UI representation allows for side effects to be performed when it is rendered, then it is possible to have a dummy form field that fetches some remote data that is used within the form:

fetch :: Aff a -> Form (Maybe a) a
fetch = ...
countryStateForm :: Form _ Address
countryStateForm = wizard do
  countries <- step
    $ focus (prop (SProxy :: SProxy "countries"))
    $ fetch loadAllCountries

  country <- step
    $ focus (prop (SProxy :: SProxy "country"))
    $ validated (nonNull "Country")
    $ select countries

  states <- step
    $ focus (prop (SProxy :: SProxy "states"))
    $ fetch (loadStatesForCountry country)

  step ado
    state <-
      focus (prop (SProxy :: SProxy "state"))
      $ validated (nonNull "State")
      $ select states
    postalCode <-
      focus (prop (SProxy :: SProxy "postalCode"))
      $ validated (nonEmpty "Postal code")
      $ textbox
    in Address { country, state, postalCode }

The step combinator transforms a Form into a Wizard and, due to Wizard's Monad instance, steps that follow each other in a monadic chain are sequential That is, the next step is only available if all the previous steps are valid and have produced an output value. This happens because Monad introduces sequential dependency on values. This sequential dependency in the example above, as the country field depends on the value countries produced by the first form field.

The code above generates this simple, yet interesting form. Using PureScript to Create a Domain-Specific Language for Building Forms with Validation

The code above generates this simple, yet interesting form.


The embedded, domain-specific language described in this post to specify and generate type-safe form UIs with validation has proven itself a successful experiment at Lumi. It has allowed us to, not only build our forms in a type-safe way, avoiding errors and enforcing constraints on the data structures and on the behavior of the forms, but it has also enabled the team to build new forms much faster and more consistently with a small language that can produce complex and reusable forms that are yet easy to understand and maintain.

The types and functions described here are also a simplification of what we actually use at Lumi. Since we use purescript-react-basic to render our user interfaces, the UI type used throughout this post is different. Based on our UI guidelines for forms, we have also decided to use a more specific UI type that takes into account the input fields' labels, validation errors, and nested fields. Other improvements worth mentioning are the inclusion of a props parameter to the Form type which is used as an argument to the function wrapped by form (besides the input value of type i), and a type class that generates default data for empty forms.

Some alternative approaches to the problem are worth mentioning, most notably David Peter’s purescript-flare, and Formlets, as described in the paper The Essence of Form Abstraction by Cooper et al., which served as inspiration for the approach presented here.

If you have any questions, ideas, or any other kind of feedback, please leave a comment below.

And if you’re interested in using PureScript to solve real-world problems like this one, then get in touch .  We’re hiring at Lumi!

Lumi is accelerating the world's transition to responsible manufacturing. Want to work with us? We're hiring.

RSS · Github