Guardrails
NEW in 1.1.0: Asynchronous Checking!
Guardrails can now have a much smaller impact on your dev-time performance!
I realized that most of the time in dev mode you’re just running bits of code here and there in fits and starts. Most people rarely run super high numbers of iterations without some kind of pause. Even then: if you’re running such a computation there is still a high probability that you’ll see problems even if some of the checks are silently dropped.
As a result, guardrails now has an asynchronous checking mode (which mainly benefits Clojure)!
When this mode is enabled it pushes spec checking into a core.async
channel with a dropping buffer (size 10,000). The overhead
for the put
is just a few microseconds. This allows an alternate thread to run the checks, and as long as you
don’t have large sustained computations this can give you nearly full-production performance of your code, while
an alternate core in your computer handles the checks.
Benefits:
-
Much faster dev performance (Clojure only. The option works in CLJS, but there’s not a second thread so there is no benefit).
-
High performance algorithms can use guardrails with a tolerable cost.
Costs:
-
Checking results are queued. If a lot of slow checks get in the queue you might have to wait some time before you see the problems. This could cause confusion (you might be running your next expression in the REPL and see an error from the prior one).
-
Not all checks will run in a CPU-intensive task that queues checks rapidly.
-
Async mode is incompatible with the
:throw? true
option.
To enable the async mode, just add :async? true
in your guardrails.edn
file.
Quick Start
-
Add this library to your dependencies
-
Create a
guardrails.edn
with{}
in it in your project root. -
When you run a REPL or CLJS compiler, include the JVM option
-Dguardrails.enabled
.-
Optionally: If you’re using CLJS, set your compiler options to include
{:external-config {:guardrails {}}}
-
And code as follows:
(ns com.domain.app-ns
(:require
[com.fulcrologic.guardrails.core :refer [>defn >def | ? =>]]))
;; >def (and >fdef) can be used to remove specs from production builds. Use them to define
;; specs that you only need in development. See the docstring of
;; `com.fulcrologic.guardrails.noop` for details.
(>def ::thing (s/or :i int? :s string?))
;; When guardrails is disabled this will just be a normal `defn`, and no fspec overhead will
;; appear in cljs builds. When enabled it will check the inputs/outputs and *always* log
;; an error using `expound`, and then *optionally* throw an exception,
(>defn f [i]
[::thing => int?]
(if (string? i)
0
(inc i)))
then when the function is misused you’ll at least get a log error:
user=> (f 3.2)
ERROR /Users/user/project/src/com/domain/app_ns.clj:12 f's argument list
-- Spec failed --------------------
[3.2]
^^^
should satisfy
int?
or
string?
-- Relevant specs -------
:user/thing:
(clojure.spec.alpha/or :i clojure.core/int? :s clojure.core/string?)
You can control if spec failures are advisory or fatal by editing guardrails.edn
and setting the :throw?
option. See
Configuration for more details.
Clojurescript Considerations
I use shadow-cljs
as the build tool for all of my projects, and highly recommend it. Version 0.0.11 of Guardrails
checks the compiler optimizations and refuses to output guardrails checks except in development mode (no optimizations). This
prevents you from accidentally releasing a CLJS project with big runtime performance penalties due to spec checking
at every function call.
The recommended approach for using guardrails in your project is to make a separate :dev
and :release
section of your
shadow-cljs config, like so:
{:builds {:main {:target :browser
...
:dev {:compiler-options
{:closure-defines {'goog.DEBUG true}
:external-config {:guardrails {}}}}
:release {}}}
...}
Doing so will prevent you from accidentally generating a release build with guardrails enabled in case you had a shadow-cljs server running in dev mode (which would cache that guardrails was enabled) and built a release target:
# in one terminal:
$ shadow-cljs server
# later, in a different terminal
$ shadow-cljs release main
In this scenario Guardrails will detect that you have accidentally enabled it on a production build and will throw an exception. The only way to get guardrails to build into a CLJS release build is to explicitly set the JVM property "guardrails.enabled" to "production" (NOTE: any truthy value will enable it in CLJ).
You can set JVM options in shadow-cljs using the :jvm-opts
key:
:jvm-opts ["-Dguardrails.enabled=production"]
but this is highly discouraged.
Dead Code Elimination
There is a a noop namespace that can be used in your build settings to attempt to eliminate all traces of guardrails and dependent code. This will not remove spec dependencies unless you only use spec for guardrails, so do similar tricks for your inclusions of spec namespaces.
See noop.cljc.
Why?
Clojure spec’s instrument (and Orchestra’s outstrument) have a number of disadvantages when trying to use them for this purpose. Specifically, they are side-effecting after-calls that do not play particularly well with hot code reload, and always throw when there is a failed spec. Furthermore, management of the accidental inclusion of specs in your cljs builds (which increase build size) is a constant pain when writing separate specs for functions (the specs end up in a whole other file, inclusion needs to be via a development ns, and things easily get out of date).
This library is a middle ground between the features of raw Clojure spec and George Lipov’s Ghostwheel. Much of the source code in this library is directly from Ghostwheel.
This library’s goals are:
-
The ability to use a simple DSL to declare the spec with a function (taken from Ghostwheel). See that library’s docs for syntax of
>defn
,>defn
, etc. -
The ability to support dead-code elimination in cljs.
-
No reliance on generative testing facilities/checkers. No orchestra/instrument stuff.
-
Good output when a function receives or emits an incorrect value.
-
The ability to control if a spec failure causes a throw (instrument always throws), because a lot of the time during development your spec is just wrong, and crashing your program is very inconvenient. You just want a log message to make you aware.
without the extra overhead of Ghostwheel’s support for:
-
Automatic generative testing stuff.
-
Tracing.
-
Side-effect detection/warning.
The Gspec Syntax
[arg-specs* (| arg-preds+)? => ret-spec (| fn-preds+)? (<- generator-fn)?]
|
= :st
– such that
=>
= :ret
– return value, same as in fspec
Note
|
Throughout this guide the symbolic gspec operators => and | will be used instead of the equivalent
keyword-based :ret and :st . The two sets are perfectly interchangeable and can even be freely mixed within the same gspec.
|
The number of arg-specs
must match the number of function arguments, including a possible variadic argument – Guardrails will shout at you if it doesn’t.
Single/Multiple Arities
Write the function as normal, and put a gspec after the argument list:
(>defn myf
([x]
[int? => number?]
...)
([x y]
[int? int? => int?]
...))
Variadic Argument Lists
arg-specs
for variadic arguments are defined as one would expect from standard fspec:
(>fdef clojure.core/max
[x & more]
[number? (s/* number?) => number?])
Note
|
The The |
Such That
To add an additional condition add |
after either the argument specs (just before ⇒
) or return value spec
and supply a lambda that uses the symbol names from the argument list (and %
for return value).
(>defn f
[i]
[int? | #(< 0 i 10) => int? | #(pos-int? %)]
...)
Warning
|
Return value such-that clauses are syntactically supported, but are not currently checked. |
Nilable
The ?
macro can be used as a shorthand for s/nilable
:
(>fdef clojure.core/empty?
[coll]
[(? seqable?) => boolean?])
Nested Specs
Nested gspecs are defined using the exact same syntax:
(>fdef clojure.core/map-indexed
([f]
[[nat-int? any? => any?] => fn?])
([f coll]
[[nat-int? any? => any?] (? seqable?) => seq?]))
In the rare cases when a nilable gspec is needed ?
is put in a vector rather than a list:
(>fdef clojure.core/set-validator!
[a f]
[atom? [? [any? => any?]] => any?])
Tip
|
For nested gspecs there’s no way to reference the args in the arg-preds or fn-preds by symbol. The recommended
approach here is to register the required gspec separately by using >fdef with a keyword.
|
Note
|
Nested gspecs with one or more any? argspecs desugar to ifn? , so as not to mess up generative testing. This
can be overridden by passing a generator – even an empty one, that is simply adding <- or :gen to the gspec – in which case the gspec will desugar exactly as specified.
​
The assumption here is that any? does not imply that the function can in fact handle any type of argument.
​
You should still write out nested gspecs, even if they are as simple as [any? => any?] – this is useful as succinct
documentation that this particular function receives exactly one argument.
|
Note
|
The gspec syntax has a number of advantages:
|
Credit: The above documentation was largely taken from Ghostwheel’s documentation.
Enabling
The JVM option -Dguardrails.enabled=true
should be used to turn on
guardrails. When not defined >defn
will emit exactly what defn
would.
You may also enable it in cljs in your shadow-cljs config (see Configuration…​adding even an empty config map will enable it).
Configuration
The default config goes in top of project as guardrails.edn
:
{
; what to emit instead of defn, if you have another defn macro
:defn-macro nil
;; Nilable map of Expound configuration options.
:expound {:show-valid-values? true
:print-specs? true}
;; Check specs in parallel (CLJ only)
:async? true
;; should a spec failure on args or ret throw an execption?
;; (always logs an informative message)
:throw? false
;; should a spec failure be forwarded through tap> ?
:tap>? false}
You can override the config file name using JVM option
-Dguardrails.config=filename
.
In your shadow-cljs config file you can override settings via the [:compiler-options :external-config :guardrails]
config path of a build:
...
:app {:target :browser
:dev {:compiler-options
{:external-config {:guardrails {:throw? false}}
:closure-defines {'goog.DEBUG true}}}
...
Copyright and License
The code and documentation taken from Ghostwheel is by George Lipov and follows the ownership/copyright of that library. The modifications in this library are copyrighted by Fulcrologic, LLC.
This library follows Ghostwheel’s original license: Eclipse public license version 2.0.