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.
What do Dynamic and Static mean?
- 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.
Scenario
Let's imagine that our code needs a simple function that returns the last element of an array (let's call it "last
").
Goal π
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
1. JavaScript as starter
Let's start from JavaScript. Here is our simple function:
const last = (arr) => arr[ arr.length - 1 ]
These are the results of calling it. PASS
and 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
We got 3 PASSES and 5 FAILS. JavaScript does its best to keep our script running even when we send values that are not arrays, like 42
and "42"
. After all, both of them yield some kind of result, so why not? But for more drastic types, like null
or undefined
, also the weakly typed JavaScript fails, throwing a couple of errors:
Uncaught TypeError: Cannot read properties
of undefined (reading 'length')
Uncaught TypeError: Cannot read properties
of null (reading 'length')
JavaScript is lacking a mechanism to warn us about a possible failure before executing the script itself. So our scripts, if not properly tested, may crash directly in our users' browsers... in production at 2 a.m.
2. TypeScript to the rescue
TypeScript is a superset of JavaScript so we can recycle the same function written before and see what TypeScript has to offer, out of the box, starting with a loose setting.
The difference that we see at this point is that the result of calling last
without arguments changed from crashing our application in JavaScript to this error in TypeScript:
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, any
means that everything goes, no checking is done, similar to JavaScript.
This are the types inferred by TypeScript:
last: (arr: any) => any
Let's instruct the type checker that we want this function to only accepts 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 null
and undefined
. Time to give TypeScript more power! Let's activate these flags
-
noImplicitAny
- Enable error reporting for expressions and declarations with an impliedany
type. Before we were only getting warnings, now we should get errors. -
strictNullChecks
- Will makenull
andundefined
to 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 null
or 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 any
).
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 none
in TS Config > JSX
.
To be pedantic, it seems that we still have a small problem here. If we call last
like this:
last([]) // undefined
last([undefined]) // undefined
We get back the same value even though the arguments we used to call the function were different. This means that if last
returns 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! π
To learn more about TypeScript, you can find excellent material on the official documentation website, or you can check the example of this post in the online playground.
3. Elm for the typed-FP experience
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 last
with []
and [ 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
is:
last : Array a -> Maybe a
To learn more about Elm, the official guide is the perfect place to start, or you can check the example of this post in the online playground.
Conclusions
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.
JavaScript
Plain JavaScript is lacking any capability of warning us if something is wrong before being executed. It is great for building prototypes when we only care for the happy paths, but if we need reliability better not to use it plain.
TypeScript
TypeScript is a powerful tool designed to allow us to work seamlessly with the idiosyncrasies of the highly dynamic language that is JavaScript.
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.
This also means that sometimes is necessary to fight with the TypeScript compiler to get things right.
Elm
Elm took a different approach from its inception, breaking free from JavaScript. This allowed building a language with an ergonomic and coherent type system that is baked in the language itself.
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 null
, undefined
, throw
and 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.
So if the Elm compiler complains, it means that objectively there is a problem in the types.
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.
Top comments (2)
Note that the typescript function you suggested won't always be accurate if an array has more than one element type. For example:
If you wanted to make it strictly and absolutely correct, you'd have to make the array readonly and create a function such as this:
And when calling it, use the
as const
cast:Great post Luca! I think this is a topic that could last a very long series of posts, but this is definitely a good start! π
(One small side note: on the naive type given primarily to TypeScript, I think
Array<number | string>
reads a bit nicer π)