Currently writing a function spec is a bit like verbose destructing - it can require as much code as the function itself.
Reduced effort in spec'ing functions would be nice.
Spec encourages namespaced keywords to have one "type". That means within your program any map with that key can be assumed to have a predictable type.
A similar convention is to have one meaning for a variable name in a specific namespace. Nothing in spec about that... it's just less confusing.
With those reference points, here's the genius idea:
Use the arglist to generate a function spec. It gets more interesting if we translate arglist destructing into specs too.
e.g. That means (defn x [a b c])
will check:
- [0] conforms to
::a
- [1] conforms to
::b
- [2] conforms to
::c
e.g. That means (defn x [{:keys [url x] :or {x 1} [_ request limit count]])
will check:
- [0] conforms to
(s/keys :req-un [::url] :opt-un [::x])
- [1] conforms to
coll?
- [1 1] conforms to
::request
- [1 2] conforms to
::limit
- [1 3] conforms to
::count
Destructing keys involves a lot of nil punning. e.g. if key is missing, it's okay. We need to pick between using :opt-un or :req-un. Simple unambiguous logic would be:
- If there's an :or clause we use :opt-un
- Else it is a required key and we use :req-un.
Perhaps the key spec can guide this. A more flexible/relaxed approach would be "if the spec is s/nilable then we could use :opt-un and know our fn is getting predictable arg data". That wouldn't pick up expected but missing keys so let's be conservative for now and consider relaxing this later.
The function var has :arglists metadata. That would allow functions to be spec'd without replacing the defn macro.
(defn myfn [a & bs] a)
=> #'cljs.user/myfn
(:arglists (meta (var myfn)))
=> ([a & bs])
Ultimately, you might typically put specs at the top of each namespace like this...
(ns example ...)
(s/def ::base-url string?)
(s/def ::request (s/keys :req-un [::url ::params]))
(s/def ::page-limit pos-int?)
(s/def ::page-count pos-int?)
(defn load-options
[{:keys [base-url]} [_ request page-limit page-count]]
...)
; Instrumenting these might look like this
(instrument-from-arglists *ns*)
In 1.9, clojure.core.specs includes an ::arg-list spec which includes destructuring in the binding-form. That should ease parsing arg-lists into a useful AST to transform.
https://github.com/clojure/clojure/blob/master/src/clj/clojure/core/specs.clj#L67
Wouldn't this limit all of your functions to only work with specs that are defined within the same namespace as the functions themselves?
I've been spending hammock time on the "alternative defn macro" approach, and here's what I've got so far:
Use a superset of the regular destructuring syntax that allows you to optionally introduce specs for symbols by wrapping both the spec and the symbol inside parentheses. For example:
(::foo a-foo)
,(#{1 2 3} n)
or(string? x)
. These are valid to use whenever a symbol can occur in in the destructuring syntax. The order of the spec / symbol could go both ways, but I quite like how(string? x)
reads as a part of the parameter declaration.Add additional map destructuring keyword directive that maps to the
s/keys
keyword arguments::req
/:req-un
=>:keys-req
:opt
:opt-un
=>:keys
The choice between qualified and unqualified keywords can be made based on the vector that follows the directive.
I find that a lot of the verbosity of defining function specs via
s/fdef
comes from having to always wrap:args
insides/cat
and giving names to positional arguments, which we are already doing by naming the parameters of our function via symbols.Some examples of what an implementation of these ideas might look like:
Use
any?
as the default spec for symbols without specs:[(string? x) y (int? z)]
=>(s/cat :x string? :y any? :z int?)
Variable arguments:
[(string? x) & ((s/cat :xs (s/+ int?)) rs)]
=>
(s/cat :x string? :rs (s/cat :xs (s/+ int?)))
Regular map destructuring gives you some default names for things, but won't try to infer anything more than what's promised (so anything could be
nil
and therefore optional):[{:keys [foo]}]
=>(s/cat :m (s/keys :opt-un [::foo]))
[{:keys [foo] :as foo-map}]
=>(s/cat :foo-map (s/keys :opt-un [::foo]))
[{:foo/keys [bar]}]
=>(s/cat :m (s/keys :opt [:foo/bar]))
By assigning a spec to a symbol, that symbol now becomes required. The following implementation would have the downside that your destructuring syntax may end up introducing specs that shadow already defined value-specs:
[{:keys [(string? foo)]}]
=>
(s/def ::foo string?)
(s/cat :m (s/keys :req-un [::foo]))
Therefore it's probably better if new value level specs are only defined when used with
:keys-req
, in which case every symbol must come with a spec (this makes implementation a bit simpler as well):[{:keys [(string? foo)]}]
=> error[{:keys-req [foo]}]
=> error[{:keys-req [(string? foo)] :keys [:bar/x y]}]
=>
(s/def ::foo string?)
(s/cat :m (s/keys :req [::foo] :opt [:bar/x] :opt-un [::y]))
We also want a way to easily define the
:ret
and:fn
of a function within its definition, so right now I'm leaning towards including it as a part of the meta-map of thedefn
declaration, and then removing it from there when expanding the actual function body:An interesting feature for the future might be to add a third keyword such as
:mode
based on which the passed in specs also generate:pre
and:post
conditionals for the expanded function body.