Front end development changed a lot in recent years. I remember writing my first js
code where there was only es3
. Not many people cared about scoping. It was easy to find for loops at the top level (so that you ended up having global variables i
, j
, k
and so on). But it was OK because the amount of code was tiny. Then came es5
(which is still the target for many languages compiled/transpiled
to js
) and, in terms of safety, it brought us the 'use strict';
directive. That gave us some run-time warnings about using uninitialized variables to fight the problem of implied globals
. Of course it had limitations, and was working only when all the code was using it, but it showed that there was a problem. While the apps were getting bigger it was getting pretty hard to maintain them and add new features to existing code bases without introducing bugs. Folks started to see that js
, though overall a pretty nice language, may not be that well suited for big projects.
Couple of years forward and the hottest things on the market are React, Redux and Immutable.js. What these libraries bring us is basically a functional, data driven design for UIs based on immutable state. This is very clean in terms of architecture but what about the more fundamental thing? The language itself?
Well almost everybody who use React not necessarily uses es5
. The most popular setup is of course es6
+ babel
, but many people are facing the question If I have to transpile why not use x?. X can be many things: CoffeeScript
, TypeScript
but also ClojureScript
, Elm
or PureScript
. All of them differ in some ways and bring us different features. What I would like to take a look into is safety. Type safety to be precise. We’ll be looking at es6
, TypeScript
and Elm
.
The setup
We will take a piece of Redux code to serve us as an example. The way you structure your app in Redux is that you have a store (which is named like that to differ from component state), actions, and reducers.
We’ll focus on reducers. Their job is simple. They receive an action and the store. They do a case/switch branching on actions type and return a new version of the store. But can we be sure that this is what they are actually doing? Do we have any guarantees given to us by the language or the tooling that we handled all types of actions that can be dispatched from the view. Can we be sure that our new version of the store has the same shape as the old one but only different values? The answer is it depends on the language.
Let’s imagine that we have a message box that we are using to show messages and keep track of how many times it was already shown. It could be coded like this:
Now let’s see how each language can handle various bugs. The worst thing that can happen is a run-time error in production.
Let’s start with es6
The above code is already written in es6
and it will not run in the browser
nor node
. It needs to be transpiled via babel
with additional presets(env
, es2015
and stage-3
).
In order to see the many ways in which the above code can misbehave we will add some extra lines that would mimic the actual use of the reducer.
When we save the above code into `index.js` file and run:
$ ./node_modules/.bin/babel index.js -o bundle.js
we will get an es5
transpiled file which we can execute in node
. So let’s do this.
$ node bundle.js
step1: showing “hello world”
step1 visible: false
step1 times shown: NaN
step1 message: HELLO WORLD
step2: hiding “hello world”
step2 visible: false
step3: showing “hello again”
step3 visible: false
step3 times shown: NaN
step3 message: HELLO AGAIN
step4: resetting
step4 times shown: NaN
step5: showing 5
step5 visible: false
step5 times shown: NaN
/Users/pawelgorzelany/Projects/elm-ts-js-post/js/bundle.js:69
console.log(‘step5 message:’, state.message.toUpperCase());
^TypeError: state.message.toUpperCase is not a function
at Object.<anonymous> (/Users/pawelgorzelany/Projects/elm-ts-js-post/js/bundle.js:69:45)
at Module._compile (module.js:571:32)
at Object.Module._extensions..js (module.js:580:10)
at Module.load (module.js:488:32)
at tryModuleLoad (module.js:447:12)
at Function.Module._load (module.js:439:3)
at Module.runMain (module.js:605:10)
at run (bootstrap_node.js:418:7)
at startup (bootstrap_node.js:139:9)
at bootstrap_node.js:533:3
Oops! It’s not what you expected right? Well I cheated a bit. The code I’ve actually transpiled was this:
OK, so babel did not warn us about anything. It transpiled the code and, since it had bugs in it, we ended up with a couple of errors (we will refer to them by numbers later).
- we did not show the message box at all
- instead of a meaningful number for how many times the box was displayed we got
NaN
- resetting the box did not happen
- we got a nasty run-time error
What happened? Well, I made a silly mistake and didn’t get consistent naming on two properties. We did not see the box because in our initialState
I had visible
and was using that in my UI, but in the reducer I used visibility
and was setting that to true
.
Nobody likes to see NaN
s and the reason they popped up was again caused by naming. This time the initialState
had timesDisplayed
and in the reducer
I was using timesShown
. Easy mistake to make during say, refactoring.
One more bug, that can easily be introduced, is to skip one possible branch in the switch
statement. We did not get a warning that we did not handle all the possible types of actions and so we forgot to write a case
for RESET
action.
The last is the most severe case. We asked the box to display a message saying 5
. Unfortunately we passed in a number
instead of a string
and since our box shows the message in all upper case… it blew up.
This kinda sucks. If this was a huge code base I’m pretty sure that these kinds of bugs would get through code reviews and ended up in production. We can write tests – you say. Sure! We should! Reality is that sometimes we do not have time or will to do it. It would be nice if we could care about and test only our business logic while leaving all the rest to our tools.
Onto TypeScript
Some time ago Microsoft decided to add type system to javascript. This project ended up as TypeScript. And it is really cool. Now we can add type declarations to our js
code and have the compiler catch bugs for us. Well, most of the time. First of all let’s be clear that ts
is really close to js
. In fact they are so close that without setting some compiler flags our es6
code could be compiled by tsc
without any modifications. Let’s see how that goes.
$ ./node_modules/.bin/tsc index.ts
index.ts(22,35): error TS2339: Property ‘timesShown’ does not exist on type ‘{ visible: boolean; message: string; timesDisplayed: number; }’.
OK! We have one bug caught by the compiler. That’s nice. Let’s fix it and see what happens.
$ ./node_modules/.bin/tsc index.ts
It compiled without errors but what happens when we run it?
$ node index.js
step1: showing “hello world”
step1 visible: false
step1 times shown: 1
step1 message: HELLO WORLD
step2: hiding “hello world”
step2 visible: false
step3: showing “hello again”
step3 visible: false
step3 times shown: 2
step3 message: HELLO AGAIN
step4: resetting
step4 times shown: 2
step5: showing 5
step5 visible: false
step5 times shown: 3
/Users/pawelgorzelany/Projects/elm-ts-js-post/ts/index.js:54
console.log(‘step5 message:’, state.message.toUpperCase());
^TypeError: state.message.toUpperCase is not a function
at Object.<anonymous> (/Users/pawelgorzelany/Projects/elm-ts-js-post/ts/index.js:54:45)
at Module._compile (module.js:571:32)
at Object.Module._extensions..js (module.js:580:10)
at Module.load (module.js:488:32)
at tryModuleLoad (module.js:447:12)
at Function.Module._load (module.js:439:3)
at Module.runMain (module.js:605:10)
at run (bootstrap_node.js:418:7)
at startup (bootstrap_node.js:139:9)
at bootstrap_node.js:533:3
We did not get NaN
‘s. That’s good. We can cross out number 2
from our bug list. Other than that the code is still not working correctly.
OK. Let’s be fair and help the compiler a bit. First of all we will switch on some flags in our tsconfig.json
{
“compilerOptions”: {
“module”: “commonjs”,
“target”: “es5”,
“noImplicitAny”: true,
“strictNullChecks”: true,
“noImplicitReturns”: true,
“noImplicitThis”: true,
“noEmitOnError”: true,
“noFallthroughCasesInSwitch”: true,
“noUnusedLocals”: true,
“noUnusedParameters”: true,
“sourceMap”: false
},
“include”: [
“./*.ts”
]
}
and try to compile.
$ ./node_modules/.bin/tsc
index.ts(7,35): error TS7006: Parameter ‘message’ implicitly has an ‘any’ type.
index.ts(15,47): error TS7006: Parameter ‘action’ implicitly has an ‘any’ type.
This improves things, since we are getting new compile time errors. This is good. If those errors point us to bugs they will not end up in production.
Unfortunately what this also tells us is that the compiler is not that smart and could not infer many of the types. The errors are simply saying that the type any
was inferred for message
and action
. We could slap any
on those values but this is not good since it’s basically going back to js
. So let’s add types and see if that can repel some more bugs. Our type definitions for this example looks like this:
and the annotated index.ts
like this:
Let’s try to compile it.
$ ./node_modules/.bin/tsc
index.ts(54,42): error TS2345: Argument of type ‘5’ is not assignable to parameter of type ‘string’.
Nice, the compiler found that we are trying to create the ShowMessageAction
with wrong type of argument. It needs a string
but we give it a number
. Good. We can cross out number 4
. No more run-time errors. The error message is kinda misleading though. Arguments of type '5' is not assignable to parameter of type 'string'
, hmm. What’s the type of '5'
? We don’t get that information. In this message it looks like a string?!?…
Quick fix to the code and it’s compiling again. How does it work now?
$ node index.js
step1: showing “hello world”
step1 visible: false
step1 times shown: 1
step1 message: HELLO WORLD
step2: hiding “hello world”
step2 visible: false
step3: showing “hello again”
step3 visible: false
step3 times shown: 2
step3 message: HELLO AGAIN
step4: resetting
step4 times shown: 2
step5: showing 5
step5 visible: false
step5 times shown: 3
step5 message: 5
We still can’t cross out bugs 1
and 3
. Those we would have to find by ourselves. What’s still wrong and why?
We still miss a case
for ResetAction
. This felt a bit weird to me since I thought that adding the noFallthroughCasesInSwitch
should help. However how this flag works is that it just enforce we have the default
case by the end of our switch
. One thing we get is that we cannot handle case
s that are not in the AvailableActions
union type. This guards us against typos like case 'HIDES':
because then we get this error
$ ./node_modules/.bin/tsc
index.ts(24,14): error TS2678: Type ‘”HIDES”‘ is not comparable to type ‘”SHOW” | “HIDE” | “RESET”‘.
The weirdness however does not end here. Why have we not seen the box? The compiler does not complain about returning an object that has an additional property to what the type suggests. We end up having a state like this:
{
visible: false,
visibility: true,
message: ‘5’,
timesShown: 3
}
Why it works like this is that interface
is a mechanism of structural typing
a.k.a. duck typing
. TypeScript enforces that an object of a given type has all the properties defined in the type, and that these properties have the correct types themselves. However adding an extra property to such object just goes unnoticed by the compiler. I’ve tried changing interface
to type
but it also does not work.
When using TypeScript we gain quite a lot in comparison with es6
. Unfortunately bolting a type system onto a dynamic language is not a trivial task. Having the compiler catch many kind of bugs is really hard in that case. If not impossible.
Elm
There is yet another option. We can go into languages that are not based on js
but compile to it. One of these languages is Elm. Others in this category are for example ClojureScript and PureScript. The difference here is that Elm introduces things at the language level which in js
world are implemented as libraries. This in turn makes them not optional but enforced in Elm.
Elm’s type system is a whole different kind of beast than TypeScript’s. Plus when writing Elm code we do not use React, Redux nor Immutable.js but still get the same clean architecture as if we did.
So how does an equivalent of our broken piece of code looks like in Elm:
First of all there is a difference in syntax and the way that we represent actions. Redux insist on keeping actions as plain objects with the obligatory type
property. This is because they’re supposed to be serializable to json. In Elm the best way to represent actions is through a union type. We could do it in ts
and we kind of did but since ts
lacks pattern matching on types we couldn’t use them in our switch
. Bummer. So here we’ve created a union type and our type constructors acts both as types and, kind of like data structures in a way that they take place of Redux’s action objects. In that sense Action
union type is not the same as our AvailableActions
annotation in ts
but something we can use in our code. Other than that it’s worth pointing out that this code is not annotated with types whatsoever. Let’s see if it compiles.
$ ./node_modules/.bin/elm make –output bundle.js Main.elm
— TYPE MISMATCH —————————————————— Main.elmThe argument to function `ShowMessage` is causing a mismatch.
43| ShowMessage 5
^
Function `ShowMessage` is expecting the argument to be:String
But it is:
number
— TYPE MISMATCH —————————————————— Main.elm
The argument to function `reducer` is causing a mismatch.
26| reducer initialState
^^^^^^^^^^^^
Function `reducer` is expecting the argument to be:{ a | …, timesShown : …, visibility : … }
But it is:
{ …, timesDisplayed : … }
Hint: The record fields do not match up. One has timesShown and visibility. The
other has timesDisplayed.Detected errors in 1 module.
OK, so errors `4`, `1` and `2` were caught. Nice! Let’s fix them and move on.
$ ./node_modules/.bin/elm make –output bundle.js Main.elm
==================================== ERRORS ====================================— MISSING PATTERNS ————————————————— Main.elm
This `case` does not have branches for all possibilities.
16|> case action of
17|> ShowMessage msg ->
18|> { state |
19|> visible = True,
20|> message = msg,
21|> timesShown = state.timesShown + 1 }
22|>
23|> HideMessage ->
24|> { state | visible = False }You need to account for the following values:
Main.Reset
Add a branch to cover this pattern!
If you are seeing this error for the first time, check out these hints:
<https://github.com/elm-lang/elm-compiler/blob/0.18.0/hints/missing-patterns.md>
The recommendations about wildcard patterns and `Debug.crash` are important!Detected errors in 1 module.
I mean wow! Not only did the compiler catch bug number 3
but the error message just gave it to us on a silver plate. Great job. Let’s fix that one as well.
$ ./node_modules/.bin/elm make –output bundle.js Main.elm
Success! Compiled 1 module.
Successfully generated bundle.js
Now it compiles. That is simply great. We were not able to compile our example until it was working as expected.
$ node bundle.js
step1 visible: True
step1 times shown: 1
step1 message: “HELLO WORLD”
step2 visible: False
step3 visible: True
step3 times shown: 2
step3 message: “HELLO AGAIN”
step4 visible: False
step4 times shown: 0
step5 visible: True
step5 times shown: 1
step5 message: “5”
Good job Elm!
Though Elm is relatively new, it already had substantial impact. Redux was influenced by the elm architecture. Compiler error messages are known to be one of the best in class, and they influenced Rust’s and Haskell’s compiler devs. There are some things missing in Elm which we can find in let’s say PureScript, like user defined type classes, but I will not go into that.
You can find further examples how Elm can improve safety of your programs in this talk.