DEV Community

loading...

Clojure validation in 30 lines

Kasey Speakman
collector of ideas. no one of consequence.
・3 min read

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Discussion (0)

Forem Open with the Forem app