Meet Soothe: a small Clojure library for better Spec error messages
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.
- Installation
- Concepts
- TL;DR: Code Samples
- The API
- Pre-defined messages & Localization
- ClojureScript
- Best practices & Known cases
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 thevia
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
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter