TLDR; Jump to the conclusions.
We have been told that a robust static type system can reduce the number of bugs in our applications, transforming a 2 a.m. production issue into a red squiggly in our text editor. This is an appealing proposition.
In this post, we will set the stage with some definition, a scenario, and a goal and see how this little adventure goes. We will then try to draw some conclusions.
- A dynamic type system is a system where types are checked at runtime.
- A static type system is a system where types are checked at compile time.
Let’s imagine that our code needs a simple function that returns the last element of an array (let’s call it “
Our goal is to have a system that would warn us if we try to call this function with anything other than an array and also ensures that our functions accept arrays as input and return one element (or error, in case the array is empty) as output.
This is the behavior we would like to get:
last([ 1, 2 ]) // Should return 2 last([ "1", "2" ]) // Should return "2" last() // Should return some kind // of error, because an // empty array does not // have a last element
These calls instead should not be allowed by the type system:
last() // Should not be allowed last(42) // Should not be allowed last("42") // Should not be allowed last(null) // Should not be allowed last(undefined) // Should not be allowed
const last = (arr) => arr[ arr.length - 1 ]
These are the results of calling it.
FAIL refer to our goal requirement stated above.
last([1,2]) // PASS: 2 last(["1","2"]) // PASS: "2" last() // PASS: undefined last() // FAIL: Crash last(42) // FAIL: undefined last("42") // FAIL: "2" last(null) // FAIL: Crash last(undefined) // FAIL: Crash
"42". After all, both of them yield some kind of result, so why not? But for more drastic types, like
Uncaught TypeError: Cannot read properties of undefined (reading 'length') Uncaught TypeError: Cannot read properties of null (reading 'length')
The difference that we see at this point is that the result of calling
Expected 1 arguments, but got 0.
This is an improvement! All other behaviors remain the same, but we get a new warning:
Parameter 'arr' implicitly has an 'any' type, but a better type may be inferred from usage.
It seems that TypeScript tried to infer the type of this function but was not able to do it, so it defaulted to
any. In TypeScript,
Let’s instruct the type checker that we want this function to only accpets arrays of number or arrays of strings. In TypeScript we can do this by adding a type annotation with
number | string:
const last = (arr: number | string) => arr[ arr.length - 1 ]
We could also have used
Array<number> | Array<string> instead of
number | string, they are the same thing.
This is the behaviour now:
last([1,2]) // PASS: 2 last(["1","2"]) // PASS: "2" last() // PASS: undefined last() // PASS: Not allowed last(42) // PASS: Not allowed last("42") // PASS: Not allowed last(null) // FAIL: Crash last(undefined) // FAIL: Crash
It is a substantial improvement! 6 PASSES and 2 FAILS.
We are still getting issues with
undefined. Time to give TypeScript more power! Let’s activate these flags
noImplicitAny– Enable error reporting for expressions and declarations with an implied
anytype. Before we were only getting warnings, now we should get errors.
strictNullChecks– Will make
undefinedto have their distinct types so that we will get a type error if we try to use them where a concrete value is expected.
And boom! Our last two conditions are now met. Calling the function with either
undefined generate the error
Argument of type 'null' is not assignable to parameter of type 'number | string'. Argument of type 'undefined' is not assignable to parameter of type 'number | string'.
Let’s look at the type annotation (you can usually see it when you mouse-hover the function name or looking at the
.D.TS tab if you use the online playground).
const last: (arr: number | string) => string | number;
This seems slightly off as we know that the function can also return
undefined when we call
last with an empty array, as empty arrays don’t have the last element. But the inferred type annotation says that only strings or numbers are returned.
This can create issues if we call this function ignoring the fact that it can return undefined values, making our application vulnerable to crashes, exactly what we were trying to avoid.
We can rectify the problem by providing an explicit type annotation also for the returned values
const last = (arr: number | string): string | number | undefined => arr[ arr.length - 1 ]
I eventually find out that there is also a flag for this, it is called
noUncheckedIndexedAccess. With this flag set to true, the type
undefined will be inferred automatically so we can roll back our latest addition.
One extra thing. What if we want to use this function with a list of booleans? Is there a way to tell this function that any type of array is fine? (“any” is intended here as the English word “any” and not the TypeScript type
Let’s try with Generics:
const last = <T>(arr: T) => arr[arr.length - 1]
It works, now
boolean and possibly other types are accepted. the final type annotation is:
const last: <T>(arr: T) => T | undefined;
Note: If you get some error while using Generics like, for example,
Cannot find name 'T', is probably caused by the JSX interpreter. I think it gets confused thinking that
<T> is HTML. In the online playground, you can disable it by choosing
TS Config > JSX.
To be pedantic, it seems that we still have a small problem here. If we call
last like this:
last([undefined]) // undefined last() // undefined
We get back the same value even though the arguments we used to call the function were different. This means that if
undefined, we cannot be 100% confident that the input argument was an empty array, it could have been an array with an undefined value at the end.
But it is good enough for us, so let’s accept this as our final solution! 🎉
How is the experience of reaching the same goal using a functional language?
Let’s rewrite our function in Elm:
last arr = get (length arr - 1) arr
This is the outcome of calling the function, for all our cases:
last (fromList [ 1, 2 ]) -- PASS: Just 2 last (fromList [ "1", "2" ]) -- PASS: Just "2" last (fromList [ True ]) -- PASS: Just True last (fromList ) -- PASS: Nothing last () -- PASS: Not allowed last 42 -- PASS: Not allowed last "42" -- PASS: Not allowed last Nothing -- PASS: Not allowed
We got all PASS, all the code is correctly type-checked, everything works as expected out of the box. Elm could infer all the types correctly and we didn’t need to give any hint to the Elm compiler. The goal is reached! 🎉
How about the “pedantic” problem mentioned above? These are the results of calling
[ Nothing ].
last (fromList ) -- Nothing last (fromList [ Nothing ]) -- Just Nothing
Nice! We got two different values so we can now discriminate between these two cases.
Out of curiosity, the inferred type annotation of
last : Array a -> Maybe a
This example covers only certain aspects of a type system, so it is far from being an exhaustive analysis but I think we can already extrapolate some conclusions.
Adding static types on top of a weakly typed dynamic language, while remaining a superset of it, is not a simple task and comes with trade-offs.
TypeScript allows certain operations that can’t be known to be safe at compile-time. When a type system has this property, it is said to be “not sound”. TypeScript requires us to write type annotations to help to infer the correct types. TypeScript cannot prove correctness.
The Elm type system is “sound”, all types are proved correct in the entire code base, including all external dependencies (The concept of
any does not exist in Elm).
The type system of Elm also does extra things like handling missing values and errors so the concepts of
try/catch are not needed. Elm also comes with immutability and purity built-in.
This is how Elm guarantees the absence of runtime exceptions, exonerating us from the responsibility of finding all cases where things can go wrong so that we can concentrate on other aspects of coding.
In Elm, type annotations are completely optional and the inferred types are always correct. We don’t need to give hints to the Elm inference engine.
Elm is like a good assistant that does their job without asking questions but doesn’t hesitate to tell us when we are wrong.
The header illustration is derived from a work by Pikisuperstar.