DEV Community

Cover image for From dynamic to static typing in three steps
lucamug
lucamug

Posted on • Edited on

From dynamic to static typing in three steps

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

1. JavaScript as starter

Let's start from JavaScript. Here is our simple function:

const last = (arr) => arr[ arr.length - 1 ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 implied any type. Before we were only getting warnings, now we should get errors.
  • strictNullChecks - Will make null and undefined 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[]'.
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

It works, now boolean and possibly other types are accepted. the final type annotation is:

const last: <T>(arr: T[]) => T | undefined;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
lioness100 profile image
Lioness100

Note that the typescript function you suggested won't always be accurate if an array has more than one element type. For example:

// string | number
last([1, 2, '3']);
Enter fullscreen mode Exit fullscreen mode

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:

const last = <T extends readonly any[]>(arr: T): [undefined, ...T][T['length']] =>
  arr[arr.length - 1];
Enter fullscreen mode Exit fullscreen mode

And when calling it, use the as const cast:

// '3'
last([1, 2, '3'] as const);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
flaviocorpa profile image
Functor Flavius 𝝺

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 πŸ˜‰)