Thursday, April 13, 2017

What is wrong with static typing in JavaScript and how clojure.spec solves the problem (Part 2)

The problem


The main problem with dynamic typing seems to be fear that the wrong type of data will end up in the wrong place (function input for instance).

However it turns out static typing is pretty useless.

Example 1: Simple types Age


Let's say you have to record an age for a person in a variable, or have it as a parameter in a function.

You would do something like:

int age = 25;

or

function something(int age...)

Someone pretended even that static typing shows intent. Now the only thing that is in here, is that it will be an int. There is nothing protecting the age from being either negative (-2) or too big (4000, it might work if you're talking about the age of the pyramids). So it is not intent, it is just int, not further protection, so pretty much useless.

Solution 1

In Clojure REPL using spec (require '[clojure.spec :as s]) we define a spec saying we want a natural int (positive int) and it should be smaller then let's say 150:

(s/def ::age (s/and nat-int? (fn [x] (< x 150))))

Now:

user=> (s/valid? ::age "a")
false
user=> (s/valid? ::age -12)
false
user=> (s/valid? ::age true)
false
user=> (s/valid? ::age 1.21)
false
user=> (s/valid? ::age 4000)
false
user=> (s/valid? ::age -2)
false
user=> (s/valid? ::age 0)
true
user=> (s/valid? ::age 12)
true
user=> (s/valid? ::age 29)
true
user=> (s/valid? ::age 99)

true


Example 2: Composed types: Person


Let's say we get through a web call a json like:

{
  "id":6,
  "name":"Dan",
  "age":28
}

Usually people would create a class

class Person
{
    int id;
    string name;
    int age;
}

So we have the same problems, for instance name might be null, or age might be negative.

Then in modern apps, you get json and you send json, so you need to be able to serialize and deserialize to json this class. What happens if one of the parameters is not comform or missing?

Solution 2

(s/def ::id nat-int?)
(s/def ::name string?)
(s/def ::person (s/keys :req-un [::id ::name ::age]))

Now:

user=> (s/valid? ::person {:id 1, :name "Adi"})
false
user=> (s/valid? ::person {:id 1, :name "Adi" :age -1})
false
user=> (s/valid? ::person {:id 1, :name "Adi" :age 40})
true

What's even cooler, is that if it isn't valid, you can get an explanation:

user=> (s/explain ::person {:id 1, :name "Adi" :age -1})
In: [:age] val: -1 fails spec: :user/age at: [:age] predicate: nat-int?

user=> (s/explain ::person {:id 1})
val: {:id 1} fails spec: :user/person predicate: (contains? % :name)
val: {:id 1} fails spec: :user/person predicate: (contains? % :age)

Example 3: Hierarchies 


What if you have:

{
    "id": 6,
    "name": "Dan",
    "age": 28,
    "children": [{
            "id": 7,
            "name": "Alex",
            "age": 5
        }
    ]
}

When the first object is a parent, in a school and he must have at least one child? You can enforce the relationship by writing a function and in the constructor, but then you also need to change the serialization/deserialization from json to enforce the rules, and of course you will write more code and you will forget to check it once, and there will be a bug.

And the most common problem of our times. The json is like:

{
    "id": 11,
    "name": "Maria",
    "age": 95,
    "children": [{
                "id": 5,
                "name": "Elena",
                "age": 28,
                "children": [{
                    "id": 6,
                    "name": "Dan",
                    "age": 60,
                    "children": [{
                        "id": 7,
                        "name": "Alex",
                        "age": 5
                    }],
                    {
                        "id": 9,
                        "name": "Alina",
                        "age": 32,
                        "children": [{
                            "id": 121,
                            "name": "Luiza",
                            "age": 0
                        }]
                    }
                }],

                {
                    "id": 23,
                    "name": "Petru",
                    "age": 70,
                    "children": [{
                            "id": 4,
                            "name": "Adrian",
                            "children": [{
                                "id": 45,
                                "name": "Denis",
                                "age": 12
                            }],
                        ]
                    }]
            }]
}

You have a single error but where? (Maria / Petru / Adrian - missing "age"). It is not only hard to validate it but it is hard to show explicitly where the error occurred.

Solutions

(s/+ says that there will be a collection of person's with a minimum of 1:

(s/def ::children (s/+ ::person))
(s/def ::parent (s/keys :req-un [::id ::name ::age ::children]))

Now:

user=> (s/valid? ::parent {:id 1, :name "Adi" :age 40 :children []})
false
user=> (s/valid? ::parent {:id 1, :name "Adi" :age 40 :children [{:id 1, :name "Adi" :age 40}]})
true
user=> (s/valid? ::parent {:id 1, :name "Adi" :age 40 :children [{:id 1, :name "Adi" :age 40}, {:id 2, :name "Dan" :age 20}]})
true

and if we don't have children:

(s/explain ::parent {:id 1, :name "Adi" :age 40 :children []})
In: [:children] val: () fails spec: :user/person at: [:children] predicate: :user/person,  Insufficient input


Even cooler is that you can check relations between data, like if the children are younger then their parents: 

(defn parent-older-than-children? [parent] (reduce #(or %1 %2) (map #(> (:age parent) (:age %)) (:children parent))))

we redefine the ::parent

(s/def ::parent (s/and (s/keys :req-un [::id ::name ::age ::children]) parent-older-than-children?))

user=> (s/valid? ::parent {:id 1, :name "Adi" :age 40 :children [{:id 1, :name "Adi" :age 50}]})
false
user=> (s/explain ::parent {:id 1, :name "Adi" :age 40 :children [{:id 1, :name "Adi" :age 50}]})
val: {:id 1, :name "Adi", :age 40, :children [{:id 1, :name "Adi", :age 50}]} fails spec: :user/parent predicate: parent-older-than-children?

user=> (s/valid? ::parent {:id 1, :name "Adi" :age 45 :children [{:id 1, :name "Adi" :age 25}, {:id 2, :name "Dan" :age 20}]})
true


In Part 3 we will look at functions and one more thing ...