Strict Mode in TypeScript || Help Your Compiler Help You
Recently, at Hazelcast, we have migrated our Management Center to TypeScript. Not just TypeScript, but the strictest TypeScript there is. If you are interested in why we decided to enable all the strict flags and benefits they provide, welcome to this post.
Prelude
50k lines of JavaScript code of a React app. A small team of 2, later 3. 166 bugs filed from 2018-04-24 to 2019-04-23. On average, we produced 9.66 bugs per month.
Now, that low of a number might sound too good to be true, but we still wanted to do better. So how could we do better? We already had quite a good code coverage (%85+) with unit tests and our tight peer reviews did not leave much to chance. Yet we were not employing one thing: The holy grail of robust enterprise-grade frontend – TypeScript!
Act 1
Enabling TypeScript on a project was a breeze. You just put `any` here and there, the rest of the `any`s are inferred. As a result, from 2019-04-24 to 2020-01-29 we have observed 90 bugs filed. On average, it is 10 bugs per month. Wait. WHAT?!
It was a moment of revelation that TypeScript, being an awesome language, does not magically slay dragons for you especially, if you misuse it.
We have scratched our heads for a bit, thoroughly examined documentation, and concluded that what we need is “strict” TypeScript. After enabling strict flags one by one, our average SoC (Speed of Crapping all over the code base) went down to… 3.3 bugs per month! 3 times less!
Intermediate conclusion #1. “Loose” TypeScript did not make any difference for us, but “strict” TypeScript made our code significantly more robust.
Act 2
So, what flags exactly did we enable?
> TLDR; We have enabled all of them 😉
TypeScript compiler has over 90 various flags. 7 of them are strict (or more, depending on what you call a strict flag). 1 flag to rule them all. Let’s go over 7 original strict flags and see if they add any value.
Flags in question:
- noImplicitAny
- noImplicitThis
- alwaysStrict
- strictBindCallApply
- strictNullChecks
- strictPropertyInitialization
- strictFunctionTypes
noImplicitAny
Flag disabled
const fn = (val) => val + 1 console.log(fn('100'))
Result: compiles without a question.
Flag enabled
const fn = (val) => val + 1 // PARAMETER ‘VAL’ IMPLICITLY HAS AN ‘ANY’ TYPE console.log(fn('100'))
Result: fails on `val`.
Value
10/10
noImplicitThis
Say, we have a `Cat`. `Cat` can eat and poop.
class Cat { consumedFood = 0 eat(food: number) { this.consumedFood += food } poop() { return this.consumedFood } }
Even better. Say, we have an `EnterpriseCat`. It can eat and poop via `poopFactory`.
class EnterpriseCat { consumedFood = 0 eat(food: number) { this.consumedFood += food } poopFactory() { return function () { return this.consumedFood } } }
Flag disabled
class EnterpriseCat { consumedFood = 0 eat(food: number) { this.consumedFood += food } poopFactory() { return function () { return this.consumedFood } } } console.log(new EnterpriseCat().poopFactory()())
Result: compiles without a question. In runtime, it fails because of the lost context.
Flag enabled
```ts class EnterpriseCat { consumedFood = 0 eat(food: number) { this.consumedFood += food } poopFactory() { return function () { // THIS IMPLICITLY HAS TYPE ‘ANY’ BECAUSE IT DOESN’T HAVE A TYPE ANNOTATION return this.consumedFood } } } console.log(new EnterpriseCat().poopFactory()())
Result: fails on `this`.
Value
7/10
alwaysStrict
Treats every file as if it had “use strict” appended on the top.
Value
5/10
strictBindCallApply
Flag disabled
function foo(a: number, b: string): string { return a + b; } console.log(foo.apply(undefined, [10]))
Result: compiles without a question.
Flag enabled
function foo(a: number, b: string): string { return a + b; } // ARGUMENT OF TYPE ‘[NUMBER]’ IS NOT ASSIGNABLE TO PARAMETER OF TYPE ‘[NUMBER, STRING]’ console.log(foo.apply(undefined, [10]))
Result: fails on `apply`.
Value
5/10
strictNullChecks
Flag disabled
const doEpicShi_Stuff = (val: string) => val console.log(doEpicShi_Stuff(null))
Result: compiles without a question.
Flag enabled
const doEpicShi_Stuff = (val: string) => val // ARGUMENT OF TYPE ‘NULL’ IS NOT ASSIGNABLE TO PARAMETER OF TYPE ‘STRING’ console.log(doEpicShi_Stuff(null))
Result: fails on `doEpicShi_Stuff`.
Value
9/10
strictPropertyInitialization
#### Flag disabled
class Cat { name: string } console.log(new Cat().name)
Result: compiles without a question. Prints `undefined` which is kind of unexpected based on the type.
Flag enabled
class Cat { // PROPERTY ‘NAME’ DOES NOT HAVE AN INITIALIZER AND IS NOT DEFINITELY ASSIGNED IN THE CONSTRUCTOR name: string } console.log(new Cat().name)
Result: fails on `name`.
Value
8/10
strictFunctionTypes
Probably, the most misunderstood and underappreciated flag.
Flag disabled
```tsx // Here it is string | null const catName = (state): string | null => state.name // Here it is string interface CatProps { name: string } const CatInternal = ({ name }: CatProps) => <div>{name}</div> // Nevertheless, it works export const Cat = connect((state) => ({ name: catName(state) }))(CatInternal)
“`
Result: compiles without a question. Even without `strictNullChecks`.
Why?
A little of the type-related theory
Say, you have a class `Animal`. `Dog` is its direct child. Class `Greyhound` is a child of the `Dog`. So we have the following sequence:
Greyhound < Dog < Animal
Say you have a function that accepts a callback and returns a boolean. We expect the callback to accept a `Dog` and return a `Dog`.
f: (Dog -> Dog) -> boolean
You know, let’s make things a bit more fun.
Say, you have a `Primate`. `Primate` experienced thousands of years of evolution to become a `Human`. `Human` drank a gazillion of smoothies and even attended one or two meetups to become an `FE Developer`. So we have the following sequence:
FE Developer < Human < Primate
Now imagine that the function above (`f`) tries to answer a popular question: “How to become a software engineer good enough for Google?”
So we need to consider different callbacks that somehow transform one sort of `Human` into a different sort of `Human` and return if that way of the transformation is good enough for Google. In other words:
isGoodForGoogle: (transform: Human -> Human) -> boolean
Option 1
Read lots of geek-oriented Reddit. It will definitely help any `Human` to quickly transition into a `Primate`. So the sequence is `Human -> Primate`.
Let’s see it in action:
`Human` -> (`Human` -> `Primate`) -> `Primate` -> `Primate.read()`
Since `transform` is expected to return a `Human`, it is perfectly reasonable to expect that `Human` to read. Can a `Primate` read? No. So this option does not work for us.
Option 2
Pass a 4-hour JS online “from-zero-to-hero” crash course. Probably, it will leave us right where we started. So `Primate -> Primate` it is.
Let’s see it in action:
`Human` -> (`Primate` -> `Primate`) -> `Primate` -> `Primate.read()`
Same thing with option 2.
Option 3
Work 16 hours a day as a front-end engineer. Since we are already doing frontend with this option, it looks like `FE Developer -> FE Developer`.
Let’s see it in action:
`Human` -> (`FE Developer` -> `FE Developer`) -> `FE Developer`
At first glance, it looks alright, but wait. We can pass any human, right? Can we pass a `BE Developer`?
`BE Developer` -> (`FE Developer` -> `FE Developer`) -> `FE Developer`
Now what if our transform `FE Developer` -> `FE Developer` under the hood does something like `FE Developer.configureWebpack()`? Can a `BE Developer` deal with webpack? I would say it is beyond any reasonable expectations.
So this option does not work for us as well.
Option 4
Constantly invest in your own education over months, years, and decades. I guess it should give us `Human -> FE Developer`.
Let’s see it in action:
`Human` -> (`Human` -> `FE Developer`) -> `FE Developer`
Yay! This is it. Moreover, it could even be `Primate -> FE Developer`, not `Human -> FE Developer`. Using proper terminology, this option checks the argument contravariantly and checks the return type covariantly. Guess what? Without `strictFunctionTypes` TypeScript checks the arguments bivariantly that creates a bug we saw earlier.
Real word
{ name: string } < { name: string | null }
or
A < A | null
Intermediate conclusion #2. TypeScript checks the arguments bivariantly by default. `strictFunctionTypes` makes it check the arguments contravariantly.
Value
8/10
Postlude
As we can see, the greatest value is provided by the flags that require the most refactoring. So after enabling the most challenging and most rewarding flags, it makes no effort to enable them all. Especially since the awesome TypeScript team took care of us and added another flag called strict. It enables all of the flags above. In Management Center, we went down precisely that path. We enabled the most complex flags first and enabled all of them in the end as it was extremely small overhead.
> We left flags like `noImplicitOverride`, `noImplicitReturns`, `noUncheckedIndexedAccess`, `noPropertyAccessFromIndexSignature` and other out of scope of this article because they are not managed by the `strict` uber-flag.