Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
October 12, 2021 10:08 pm GMT

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 2last([ "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 allowedlast(42)           // Should not be allowedlast("42")         // Should not be allowedlast(null)         // Should not be allowedlast(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: 2last(["1","2"]) // PASS: "2"last([])        // PASS: undefinedlast()          // FAIL: Crashlast(42)        // FAIL: undefinedlast("42")      // FAIL: "2"last(null)      // FAIL: Crashlast(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 propertiesof undefined (reading 'length')Uncaught TypeError: Cannot read propertiesof 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.

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: 2last(["1","2"]) // PASS: "2"last([])        // PASS: undefinedlast()          // PASS: Not allowedlast(42)        // PASS: Not allowedlast("42")      // PASS: Not allowedlast(null)      // FAIL: Crashlast(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 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 assignableto 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])   // undefinedlast([])            // 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 2last (fromList [ "1", "2" ]) -- PASS: Just "2" last (fromList [ True ])     -- PASS: Just True last (fromList [])           -- PASS: Nothinglast ()                      -- PASS: Not allowedlast 42                      -- PASS: Not allowedlast "42"                    -- PASS: Not allowedlast 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 [])           -- Nothinglast (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 cant 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.

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.

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.


Original Link: https://dev.to/lucamug/three-steps-4n7

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To