Learn TypeScript w/ Mike North

tsconfig strictness

October 27, 2023

Table of Contents

What’s in the tsconfig.json?

Let’s look closely at what we just did, and make sure we understand all of the parts that make up the whole

In my tsconfig.json what exactly is “strict”?

The source of truth is here, and it’s important to know that this is a moving target

noImplicitAny

  • “Default to explicit” instead of “default to loose”
  • This is not in any way restrictive, it only requires that we be explicit about any

noImplicitThis

  • There are certain places where this is important and non-inferrable

Example: addEventListener

ts
const my_element = document.createElement('button')
my_element.addEventListener('click', function (e) {
this
this: HTMLButtonElement
console.log(this.className)
// logs `true`
console.log(e.currentTarget === this)
})
Try

If we wanted to make it an independent function declaration and had noImplicitThis enabled, we’d be asked to provide a proper this type annotation

ts
function clickListener(e: MouseEvent) {
console.log(this.className)
'this' implicitly has type 'any' because it does not have a type annotation.2683'this' implicitly has type 'any' because it does not have a type annotation.
console.log(e.currentTarget === this)
'this' implicitly has type 'any' because it does not have a type annotation.2683'this' implicitly has type 'any' because it does not have a type annotation.
}
Try
ts
function clickListener(this: HTMLButtonElement, e: MouseEvent) {
console.log(this.className)
console.log(e.currentTarget === this)
}
Try

alwaysStrict

  • JS “use strict”
  • necessary for modern JS language features

strictBindCallApply

  • Bind, call, apply used to return very loosely-typed functions.
  • No good reasons I’m aware of to disable this

strictNullChecks

  • Without this enabled, all types allow null values
  • Leaving this disabled makes truthy/falsy type guards much less useful
  • Operating without strictNullChecks is asking for runtime errors that could otherwise be caught at build time
ts
// Without `strictNullChecks` enabled
class Animal {
run() {}
}
let animal = new Animal()
let animal: Animal
animal = null // type-checks, but do we really want it to?
Try

strictFunctionTypes

Some common-sense loopholes around matching function arguments during type-checking function values

ts
abstract class Animal {
public abstract readonly legs: number
}
class Cat extends Animal {
legs = 4
purr() {
console.log('purr')
}
}
class Dog extends Animal {
legs = 4
bark() {
console.log('arf')
}
}
 
let animalFn!: (x: Animal) => void
let dogFn!: (x: Dog) => void
let catFn!: (x: Cat) => void
 
animalFn = dogFn // Error with --strictFunctionTypes
Type '(x: Dog) => void' is not assignable to type '(x: Animal) => void'. Types of parameters 'x' and 'x' are incompatible. Property 'bark' is missing in type 'Animal' but required in type 'Dog'.2322Type '(x: Dog) => void' is not assignable to type '(x: Animal) => void'. Types of parameters 'x' and 'x' are incompatible. Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
dogFn = animalFn // Always ok
dogFn = catFn // Always error
Type '(x: Cat) => void' is not assignable to type '(x: Dog) => void'. Types of parameters 'x' and 'x' are incompatible. Property 'purr' is missing in type 'Dog' but required in type 'Cat'.2322Type '(x: Cat) => void' is not assignable to type '(x: Dog) => void'. Types of parameters 'x' and 'x' are incompatible. Property 'purr' is missing in type 'Dog' but required in type 'Cat'.
Try

If you took my Intermediate TypeScript course, you may recognize this as detecting when functions are contravariant over their argument types, and preventing a problematic assignment.

strictPropertyInitialization

Holds you to your promises around class fields really being “always there” vs. “sometimes undefined”

ts
class Car {
numDoors: number
Property 'numDoors' has no initializer and is not definitely assigned in the constructor.2564Property 'numDoors' has no initializer and is not definitely assigned in the constructor.
}
Try

Even more strict

noUnusedLocals

  • Busts you on unused local variables
  • Better to have TS detect this rather than a linter

noUnusedParameters

Function arguments you don’t use need to be prefixed with _

ts
const p = new Promise((resolve, reject) => {
'resolve' is declared but its value is never read.6133'resolve' is declared but its value is never read.
reject("boom")
});
Try
diff
- const p = new Promise((resolve, reject) => {
+ const p = new Promise((_resolve, reject) => {
ts
const p = new Promise((_resolve, reject) => {
reject("boom")
});
Try

noImplicitReturns

If any code paths return something explicitly, all code paths must return something explicitly. I’m a big fan of explicitly typing function boundaries, so I love this compiler setting

noFallthroughCasesInSwitch

I’m ok with this one as being disabled, as I find case fall-throughs to be useful, important and easy (enough) to notice while reading code

types

  • Instead of pulling in all @types/* packages, specify exactly what should be available
  • NOTE: this is nuanced, and only affects global scope (i.e., window, process) and auto-import.
  • Why I care: I don’t want types used exclusively in things like tests to be quite so readily available for accidental use in “app code”

stripInternal (most important for libraries)

  • Sometimes you need type information to only be available within a codebase.
  • @internal JSdoc tag surgically strips out type information for respective symbols

exactOptionalPropertyTypes

In essence, I think of this one as establishing some very reasonable rule of appropriate distinction between null and undefined

If we have a type like this

ts
interface VideoPlayerPreferences {
volume?: number // 0 - 100, initially unset
}
Try

and enable exactOptionalPropertyTypes, we’ll be stopped from doing things like this

ts
const prefs: VideoPlayerPreferences = {
volume: 50
}
prefs.volume = undefined // Bad practice
Type 'undefined' is not assignable to type 'number' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target.2412Type 'undefined' is not assignable to type 'number' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target.
delete prefs.volume // Good practice
Try

What we’re seeing here is that we’re being stopped from explicitly setting an optional property to undefined. This is a good thing to prevent, because it causes some strangeness around things you’d expect to behave a certain way with undefined properties

ts
prefs.hasOwnProperty('volume') // returns TRUE
Object.keys(prefs) // includes "volume"
for (let key in prefs) {} // will iterate over "volume" key
Try

The fact is, we do have a property on prefs, and it has a value. We’ve just made that value undefined.

It’s much more appropriate to use null for these cases, since it’s an explicitly provided value that can’t be confused with the thing we get when we ask for properties that don’t exist.

noUncheckedIndexedAccess

Sometimes we can have object types which have some known properties as well as index signatures

ts
interface PhoneBookEntry {
name: string
home_phone: string
cell_phone: string
[k: string]: string
}
Try

This rule ensures that undeclared keys have the undefined type added to them, to represent the possibility that no value is present

ts
function callback(phoneBook: PhoneBookEntry) {
phoneBook.name
(property) PhoneBookEntry.name: string
phoneBook.office_phone // would have been `string`
(index) PhoneBookEntry[string]: string | undefined
}
Try

noPropertyAccessFromIndexSignature

This rule draws a distinction between .foo syntax (to be used for known property access) and ["foo"] syntax (to be used for index signatures). Using the same example from above

ts
function callback(phoneBook: PhoneBookEntry) {
phoneBook.name
(property) PhoneBookEntry.name: string
phoneBook.office_phone // would have been `string`
Property 'office_phone' comes from an index signature, so it must be accessed with ['office_phone'].4111Property 'office_phone' comes from an index signature, so it must be accessed with ['office_phone'].
(index) PhoneBookEntry[string]: string
}
Try

noImplicitOverride

The override keyword prevents incomplete refactors and other kinds of errors relating to object-oriented inheritance

ts
class Animal {
walk() {}
}
 
class Dog extends Animal {
override walk() {}
}
Try

if we make this change to Animal

diff
- walk() {}
+ run() {}

We’ll be appropriately alerted

ts
class Animal {
run() {}
}
 
class Dog extends Animal {
override walk() {}
This member cannot have an 'override' modifier because it is not declared in the base class 'Animal'.4113This member cannot have an 'override' modifier because it is not declared in the base class 'Animal'.
}
Try

So this override keyword is quite valuable — we just need to make sure we put it in place where appropriate. noImplicitOverride helps guide us to do just that.

ts
class Animal {
walk() {}
}
 
class Dog extends Animal {
walk() {}
This member must have an 'override' modifier because it overrides a member in the base class 'Animal'.4114This member must have an 'override' modifier because it overrides a member in the base class 'Animal'.
}
Try

Don’t go viral

There are some compiler options that I really dislike when used in libraries, because they have a high probability of “infecting” any consumer and depriving them from making choices about their own codebase

allowSyntheticDefaultImports

Allows you to import CommonJS modules as if they’re ES modules with a default export

esModuleInterop

Adds some runtime support for CJS/ESM interop, and enables allowSyntheticDefaultImports

skipDefaultLibCheck

This effectively ignores potential breaking changes that stem from your node_modules types mixing with your own types. Particularly if you’re building a library, you need to know that if you “hide” this problem they’ll still “feel” it (and probably need to “skip” too)

useUnknownInCatchVariables

Caught throwables in a catch block will be typed as unknown instead of any, promoting better error-handling hygiene

ts
try {}
catch (err) { }
var err: unknown
Try

But sometimes we need these, right?

I have never found a good reason to enable these options in well-structured TS code.

allowSyntheticDefaultImports and esModuleInterop aim to allow patterns like

ts
import fs from 'fs'

in situations where fs doesn’t actually expose an ES module export default. It exports a namespace of filesystem-related functions. Thankfully, even with these flags both disabled, we can still use a namespace import:

ts
import * as fs from 'fs'

Now there are rare situations where some CommonJS code exports a single non-namespace thing in a way like

ts
module.exports = function add(a, b) {
return a + b
}

add is definitely not a namespace, and

ts
import * as add from './calculator'

WILL NOT WORK. There’s a TS-specific pattern that will work though — it’s a little weird, but it doesn’t require turning any compiler options on

ts
import add = require('./calculator')

Is Mike asking me to take on tech debt?

You may be thinking “these don’t look like ES modules”, and “won’t the TS team standardize on ES modules later?”

The answer is: yes, but you should think about this the same way that you think about a “legacy” version of Node.js that you need to support

  • You may not wish to break consumers yet
  • Apps should be the first to adopt new things, followed by libraries that are more conservative

TS modules predate ES modules, but there’s tons of code out there that already uses TS module stuff, and this is one of the most easy to codemod kinds of “tech debt” to incur.



© 2023 All Rights Reserved