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
thisis important and non-inferrable
Example: addEventListener
tsTryconstmy_element =document .createElement ('button')my_element .addEventListener ('click', function (e ) {thisconsole .log (this.className )// logs `true`console .log (e .currentTarget === this)})
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
tsTryfunctionclickListener (e :MouseEvent ) {'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 (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 )}
tsTryfunctionclickListener (this :HTMLButtonElement ,e :MouseEvent ) {console .log (this.className )console .log (e .currentTarget === this)}
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
nullvalues - Leaving this disabled makes truthy/falsy type guards much less useful
- Operating without
strictNullChecksis asking for runtime errors that could otherwise be caught at build time
tsTry// Without `strictNullChecks` enabledclassAnimal {run () {}}letanimal = newAnimal ()animal = null // type-checks, but do we really want it to?
strictFunctionTypes
Some common-sense loopholes around matching function arguments during type-checking function values
tsTryabstract classAnimal {public abstract readonlylegs : number}classCat extendsAnimal {legs = 4purr () {console .log ('purr')}}classDog extendsAnimal {legs = 4bark () {console .log ('arf')}}letanimalFn !: (x :Animal ) => voidletdogFn !: (x :Dog ) => voidletcatFn !: (x :Cat ) => voidType '(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'.= animalFn dogFn // Error with --strictFunctionTypesdogFn =animalFn // Always okType '(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'.= dogFn catFn // Always error
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”
tsTryclassCar {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.: number numDoors }
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 _
tsTryconst'resolve' is declared but its value is never read.6133'resolve' is declared but its value is never read.p = newPromise ((, resolve reject ) => {reject ("boom")});
diff- const p = new Promise((resolve, reject) => {+ const p = new Promise((_resolve, reject) => {
tsTryconstp = newPromise ((_resolve ,reject ) => {reject ("boom")});
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.
@internalJSdoc 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
tsTryinterfaceVideoPlayerPreferences {volume ?: number // 0 - 100, initially unset}
and enable exactOptionalPropertyTypes, we’ll be stopped from doing things like this
tsTryconstprefs :VideoPlayerPreferences = {volume : 50}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.= prefs .volume undefined // Bad practicedeleteprefs .volume // Good practice
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
tsTryprefs .hasOwnProperty ('volume') // returns TRUEObject .keys (prefs ) // includes "volume"for (letkey inprefs ) {} // will iterate over "volume" key
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
tsTryinterfacePhoneBookEntry {name : stringhome_phone : stringcell_phone : string[k : string]: string}
This rule ensures that undeclared keys have the undefined type added to them, to represent the possibility that no value is present
tsTryfunctioncallback (phoneBook :PhoneBookEntry ) {phoneBook .name phoneBook .office_phone // would have been `string`}
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
tsTryfunctioncallback (phoneBook :PhoneBookEntry ) {phoneBook .name 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'].phoneBook .// would have been `string` office_phone }
noImplicitOverride
The override keyword prevents incomplete refactors and other kinds of errors relating to object-oriented inheritance
tsTryclassAnimal {walk () {}}classDog extendsAnimal {overridewalk () {}}
if we make this change to Animal
diff- walk() {}+ run() {}
We’ll be appropriately alerted
tsTryclassAnimal {run () {}}classDog extendsAnimal {overrideThis 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'.() {} walk }
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.
tsTryclassAnimal {walk () {}}classDog extendsAnimal {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'.() {} walk }
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
tsTrytry {}catch (err ) { }
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
tsimport 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:
tsimport * as fs from 'fs'
Now there are rare situations where some CommonJS code exports a single non-namespace thing in a way like
tsmodule.exports = function add(a, b) {return a + b}
add is definitely not a namespace, and
tsimport * 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
tsimport 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.