May 13, 2021

Building a library IV - Validation, errors & i18n

In part III of building a library, we ended with a field called :percent had an integer-adapter added to it. Let’s add some validation to it.

A big problem with validation, is that there are oh so many different ways you might want to validate a value. In Clojurescript there are easily over a dozen alternatives, all with pros and cons. For ez-form I picked vlad, which was great for my purposes, but it also meant that anyone wanting to use ez-form had to buy into my choices. And so the end result was a more brittle and stiff implementation, where some things were set in stone.

Protocols

We can avoid all of this if we use protocols. The protocol is defined below, and is fairly simple. We want to check if something is valid?, we want to get any error message and for branching we want to get affected fields (ignore this one for now).

(defprotocol IValidate
  :extend-via-metadata true
  (valid? [validation value form] "Evaluate if this validation is true given the value")
  (get-error-message [validation value form] "Get any error messages for this validation")
  (get-affected-fields [validation] "Get any fields that might be affected by this validation. This can include the same field as the validation is attached to."))

An additional consideration, is that you might want to do more than one validation per value. That also means you might get more than one error message back. You also have another problem where all the values are actually correct, but they are still wrong. In those instances, you need to be able to add external error messages. Examples of this are passwords, which are valid according to your validation, but does not match the password saved in the database.

Errors

We also need to store the errors somewhere. Since ez-wire is a frontend library only, which only ever runs in a local environment, errors have been implemented in the easiest manner possible with a local atom. This is something that could be expanded on in the future by adding a protocol, should the need arise.

(defonce errors (atom {}))

Notice that this is only about the storage, and does not care about rendering.

There is a second reason we want to store errors separately. While errors have to be defined together with the validation, we wish to keep the implementation and the specification of them separate. This allows us switch out the validation, but keep the error message.

PS

The error message functionality cheat a bit at the moment, with regards to
implementation. It should be expanded to use protocols as well.

Default implementations

Some default implementations for validation are supplied for ez-wire, of which we can focus on keywords and vectors.

(extend-protocol protocols/IValidate
  cljs.core/Keyword
  (valid? [validation value form]
    (spec/valid? validation value))
  (get-error-message [validation value form]
    (get @errors validation nil))
  (get-affected-fields [validation]
    #{})

  cljs.core/PersistentVector
  (valid? [validation value form]
    (every? #(protocols/valid? % value form) validation))
  (get-error-message [validation value form]
    (map #(get @errors % nil) validation))
  (get-affected-fields [validation]
    (apply set/union (map #(protocols/get-affected-fields %) validation))))

Keywords by default are implemented using clojure.spec. This allows for a reasonable default, that is always available, and works pretty well for validation.[O

Vectors by default also implement the IValidate protocol, but in this case we use a clever litte trick where we run the corresponding function of the protocol against each value in the vector. This is how we support multiple validations, and here we have a really good demonstration of the power of protocols. The vector does not want to care about the implementation details of each element in the vector, and with the usage of protocols it does not need to.

For convenience, a macro exists for registering the validations together with the error message.

(defmacro defvalidation
  ([spec-k t-fn-or-keyword]
   `(swap! errors assoc ~spec-k ~t-fn-or-keyword))
  ([spec-k spec-v t-fn-or-keyword]
   `(do (spec/def ~spec-k ~spec-v)
        (swap! errors assoc ~spec-k ~t-fn-or-keyword))))

Tying validation together with fields

For our previous field :percent we now want to specifiy that it cannot go below 0% or above 100%. We specify this with our macro.

(defvalidation :percent-above-0
  #(>= % 0)
  "The percent cannot go lower than 0%")
(defvalidation :percent-below-100
  #(<= % 100)
  "The percent cannot go higher than 100%")

We have our validations. We now need to tie it together with our :percent field, and we do this by adding support for validation in ez-wire to fields by adding a key called :validation.

Integer adapter with validation
(defform my-form-name
  {}
  [{:name :percent
    :element Input ;; from some popular React component library
    :adapter integer-adapter
    :validation [:percent-above-0 :percent-below-100]}])

Notice that by adding support for this, we don’t have to change anything in the syntax. I.e., we don’t break anything by adding this.

i18n

Internationalization face the same problem as validation, in that there are a ton of libraries available to use. Not wishing to tie ez-wire down to a specific implementation we use protocols for i18n as well.

And just like validation we supply default implementations.

(defprotocol Ii18n
  :extend-via-metadata true
  (t [k] [k args]))


;; default implementations

(extend-protocol Ii18n
  string
  (t
    ([k] k)
    ([k args] k))
  nil
  (t
    ([k] k)
    ([k args] k))
  cljs.core/Keyword
  (t
    ([k] (name k))
    ([k args] (name k))))

So we give back string as is, nil as is and keyword as the name. Keyword giving back only the name could be argued vs giving back the full keyword in cases of qualified keywords. However, a string is needed to be given back unless you want any React component to choke on the keyword implementation from clojurescript.

i18n with tongue

tongue is a neat little clojure(script) library for i18n that works quite well. Below follows an example implementation extending the i18n protocol to use tongue for i18n.

(def dictionary
  {:en {:percent-0 "The percent cannot go lower than 0%"
        :percent-100 "The percent cannot go higher than 100%"}
   :sv {:percent-0 "Procenten kan inte gå lägre än 0%"
        :percent-100 "Procenten kan inte gå högre än 100%"}
   :tongue/fallback :nb-NO})


(def translate (tongue/build-translate data))
(def locale (atom :en))

(extend-protocol Ii18n
  cljs.core/Keyword
  (t
    ([k] (translate @locale k))
    ([k args] (apply translate @locale k args))))

And we can now specify our validations like this. If we switch the locale atom, any new renderings of the form will switch to the new language.

(defvalidation :percent-above-0
  #(>= % 0)
  :percent-0)
(defvalidation :percent-below-100
  #(<= % 100)
  :percent-100)

Next up…​

We will continue by looking at the implementation details for the watcher.

Tags: programming