A function composition approach to validation.
In the previous post, I offered some critique of the validation approaches I found. In his post I bash together an alternative solution.
Goals
Here are the things I was interested in.
- validation definitions are functions
- composable from smaller pieces
- no macros
- simple errors
5 functions
There are 5 core functions to compose validations.
fn
This creates a basic validation.
(require '[validation.core :as v])
(def validate
(v/fn :required not-empty))
(validate "asdf")
=> "asdf"
(validate "")
=> {:v/error :required}
(v/error (validate ""))
=> :required
Any function can be used for validation provided it returns nil if anything goes wrong. When that happens, :v/error
is returned with the provided trait (:required
here).
You provide a trait so you know exactly the errors to expect.
and
This combinator can be used to send a value through a series of transformations, each of which might fail. The end value will be returned if all steps are successful. Otherwise the failing trait will be returned as an error.
(def validate
(v/and (v/fn :required not-empty)
(v/fn :keyword keyword)))
(validate "asdf")
=> :asdf
(validate "")
=> {:v/error :required}
Note: This is not a strong validation because it does not deal well with non-string inputs or whitespace.
seq
, hmap
Validate a sequence.
(def validate
(v/seq
(v/and (v/fn :required not-empty)
(v/fn :keyword keyword))))
(validate ["a" "b"])
=> (:a :b)
(validate ["a" ""])
=> (:a {:v/error :required})
Validate a hash-map.
(def validate
(v/hmap
{:a (v/fn :required not-empty)
:b (v/and (v/fn :required not-empty)
(v/fn :keyword keyword))}))
(validate {:a "a" :b "b"})
=> {:a "a", :b :b}
(validate {:a "a" :b ""})
=> {:a "a", :b {:v/error :required}}
(map :b [{:a "a" :b ""}
(validate {:a "a" :b ""})])
=> ("" {:v/error :required})
or
This combinator returns the first passing validation.
(def validate
(v/or :date-or-int
(v/fn :date str->date)
(v/fn :int str->int)))
It takes an additional trait since returning every failing trait would not would not be entirely useful.
Validation functions
The 30 lines of code are just the definition of the 5 core functions and 2 error checks. Validation functions are still needed to plug into them. Clojure has some built-in functions that can be used but you are probably going to want more. Fortunately they are easy to create. Here are some examples.
(defn non-blank [s]
(when (string? s)
(not-empty (trim s))))
(defn in-range [min-val max-val]
(fn [v]
(when (and (>= v min-val)
(<= v max-val))
v)))
When a validation takes multiple parameters as in-range
does above, it should return a function to take the last parameter. This bakes in the necessary partial application.
(def validate
(v/fn :range (in-range 0 4)))
(validate 3)
=> 3
(validate 5)
=> {:v/error :range}
Conclusion
I really like the function composition approach to validation. It is composable and extensible. Its implementation is very small (see below). Which means I can easily change or add more functions. I find it works quite well for user input validation.
The 30 lines
(ns validation.core
(:refer-clojure :exclude [and or fn seq]))
(defn fn [trait f]
#(if-some [value (f %)]
value
{::error trait}))
(defn and [f & fs]
#(loop [v (f %) fs fs]
(if (contains? v ::error)
v
(let [f (first fs)]
(if (nil? f)
v
(recur (f v) (next fs)))))))
(defn or [trait f & fs]
#(loop [possible (f %) fs fs]
(if-not (contains? possible ::error)
possible
(let [f (first fs)]
(if (nil? f)
{::error trait}
(recur (f %) (next fs)))))))
(defn hmap [m]
#(reduce-kv update % m))
(defn seq [f]
#(map f %))
(defn error? [v]
(contains? v ::error))
(defn error [v]
(::error v))
Top comments (0)