Note: this post is an adjusted copy of the Readme file from the GitHub repo.

Soothe provides better error messages for Clojure.spec. It’s extremely simple and robust.

API Documentation

Installation

  • Leiningen/Boot
[com.github.igrishaev/soothe "0.1.0"]
  • clojure CLI/deps.edn
com.github.igrishaev/soothe {:mvn/version "0.1.0"}

Concepts

Clojure.spec is a piece of art yet misses some bits when dealing with error messages. The standard s/explain-data gives a raw machinery output that bearly can be shown to the end-user. This library is going to fix this.

The idea of Soothe is extremely simple. The library keeps its private registry of spec/pred => message pairs. The key is either a keyword referencing a spec or a fully-qualified symbol meaning a predicate. The value of this map is either a plain string or a function that takes the problem map of the raw explain spec data.

For example:

{:some.ns/user
 "This is a wrong user."

 'other.ns/data-valid?
 "The data is invalid."

 :my.project.spec/item
 (fn [{:as problem :keys [pred in]}] ;; other spec problem keys
   (format "Build a custom message for this spec in runtime"))}

Soothe provides its own version of explain-data. When called, it prepares the raw Spec explain data and then remaps it. For each problem, Soothe tries to find a message using the following algorithm.

  • When the pred field is a fully-qualified symbol, get the message from the registry. For example, clojure.core/int? resolves into something like "The value must be an integer".

  • When the pred is something different, try the via vector of specs. The algorithm iterates the vector in reverse order. The first spec which has a message in the registry will succeed.

  • A special case when an s/keys spec misses a required key.

  • Another special case when the spec is wrapped with s/conformer.

  • The default message gets resolved.

TL;DR: Code Samples


;;
;; Imports
;;

(ns ...
  (:require
   [soothe.core :as sth]
   [clojure.spec.alpha :as s]))


;;
;; Define a spec
;;
(s/def :user/name string?)
(s/def :user/age int?)

(s/def :user/email
  (s/and
    string?
    (partial re-matches #"(.+?)@(.+?)")))

(s/def :user/field-42
  (fn [x]
    (= x 42)))

(s/def ::user
  (s/keys :req-un [:user/name
                   :user/age]
          :opt-un [:user/email
                   :user/field-42]))


;;
;; Data sample
;;

(def user
  {:name "Test" :age 42})


;;
;; no errors
;;
(sth/explain-data ::user user) ;; nil

;;
;; wrong type
;;
(sth/explain-data
  ::user
  (assoc user :name 42))

{:problems
  [{:message "The value must be a string."  ;; <<<
    :path [:name]
    :val 42}]}

;;
;; Missing key
;;
(sth/explain-data
  ::user (dissoc user :age))

{:problems
  [{:message "The object misses the mandatory key 'age'."  ;; <<<
    :path []
    :val {:name "Test"}}]}

;;
;; Custom predicate fails, no custom message defined
;;
(sth/explain-data
  ::user (assoc user :email "wrong-string"))

{:problems
 [{:message "The value is incorrect."  ;; <<<
   :path [:email]
   :val "wrong-string"}]}


;;
;; Define a cumstom message
;;
(sth/def :user/email "Wrong email.")

(sth/explain-data
  ::user (assoc user :email "wrong-string"))

{:problems
  [{:message "Wrong email."  ;; <<<
   :path [:email]
   :val "wrong-string"}]}

;;
;; A message for a custom predicate
;;
(defn some-complidated-check [value]
  (= value 100500))

(sth/def `some-complidated-check
  "The value did't match that complicated check.")

(s/def ::data
  (s/and int? some-complidated-check))

(sth/explain ::data -1)

{:problems
 [{:message "The value did't match that complicated check."  ;; <<<
   :path []
   :val -1}]}

;;
;; The message can be a function
;;
(sth/def :user/email
  (fn [{:as problem
        :keys [path pred val via in]}]
    (format "Custom error message for email, pred: %s" pred)))

(sth/explain-data
  ::user (assoc user :email "wrong-string"))

{:problems
 [{:message
   "Custom error message for email, pred: (clojure.core/partial clojure.core/re-matches #\"(.+?)@(.+?)\")"  ;; <<<
   :path [:email]
   :val "wrong-string"}]}

;;
;; Formatted output:
;;
(sth/explain
  ::user (dissoc user :age))

;; Problems:
;;
;; - The object misses the mandatory key 'age'.
;;   path: []
;;   value: {:name Test}

(sth/explain-str ::user (dissoc user :age))
;; returns the same output as a string

;;
;; Handling conformers
;;
(sth/def `->int
  "Cannot coerce the value to an integer.")

(s/def ::config
  (s/keys :req-un [:config/port
                   :config/timeout]))

(s/def :config/port
  (s/conformer ->int))

(s/def :config/timeout
  (s/conformer ->int))

(sth/explain-data
  ::config {:port "five" :timeout "dunno"})

{:problems
  [{:message "Cannot coerce the value to an integer."  ;; <<<
    :path [:port]
    :val "five"}
   {:message "Cannot coerce the value to an integer."  ;; <<<
    :path [:timeout]
    :val "dunno"}]}

For more examples, see the unit tests.

The API

Define a message for a spec or a predicate using the soothe.core/def function:

(defn my-predicate [x]
  ...)

(sth/def `my-predicate "Some message")

Use fully-qualified symbols, not simple ones. In the example above, the backtick expands the symbol to the full form (with the current namespace).

Defining a message for a spec:

(s/def ::user (s/keys ...))
(sth/def ::user "Message for the user spec")

The message might be a function that takes a preblem map and returns a string:

(sth/def ::user
  (fn [problem]
    (format "A custom message ... %s" ...)))

The library handles the case when the predicate is wrapped into the s/conformer spec. Soothe tries to find a message for the nested predicate if possible:

(defn ->int
  [val]
  (cond
    (int? val)
    val
    (string? val)
    ;; (... try to parse the string ...)
    :else
    ::s/invalid))

(s/def ::port ->int)

(sth/def `->int "Cannot coerce the value to an integer.")

(sth/explain-data ::port "dunno")
;; you'll get "Cannot coerce the value to an integer."

Special messages

There are two special messages at the moment. The first one is the :soothe.core/missing-key keyword which is used when a map misses a key. The default implementation is:

:soothe.core/missing-key
(fn [{:keys [key]}]
  (format "The object misses the mandatory key '%s'."
          (-> key str (subs 1))))

The library adds the key field into the problem map when detecting this case.

The second special message is :soothe.core/default. The default implementation is just a string "The value is incorrect." You’re welcome to register a function for that key with a custom function.

Use (sth/def-many {...}) function to define several key/message pairs at once passing them as a map. The (sth/undef ...) function removes a message for the passed key. To wipe all the messages, use (sth/undef-all).

Pre-defined messages & Localization

The library ships predefined messages for all the clojure.core predicates: int?, string?, uuid? and so forth. They locate in the en.cljs module wich gets loaded automatically once you import soothe.core.

There is also a Russian version of the messages provided with the soothe.ru module. Once loaded, it overrides the messages in the registry. Just import it somewhere in your project:

(ns ...
  (:require
    [soothe.core :as sth]
    soothe.ru ;; RU messages for spec
    ...))

You’re welcome to submit your localized messages with a pull request.

ClojureScript

Soothe is fully compatible with ClojureScript and thus can be used on the frontend.

Best practices & Known cases

  • Declare the messages right after you’re declared the specs or predicates, for example:
(s/def ::my-spec ...) ;; your spec
(sth/def ::my-spec "...") ;; the message

;; or

(defn check-email [string]...)
(sth/def `check-email "...")

But don’t put them in another namespace.

  • Some specs spoil the predicates, for example, s/coll-of. Imagine you have a spec like this one:
(s/def ::my-items (s/coll-of int?))

Now, if one of the items fails, the predicate will be not 'clojure.core/int? but just a 'int? which leads to the default error message. To handle this, bind the predicate to a spec and pass the spec:

(s/def ::int? int?)
(s/def ::my-items (s/coll-of ::int?))
(sth/def ::int? "The value must be an integer.")

With this approach, the library will return the right error message.


Ivan Grishaev, 2021