Stricter than Strict: 3 Compiler Flags to Consider in your TypeScript Codebase

As some of you will know, I am a fan of TypeScript and have written a few blog posts previously that touch on the language. For those of you who aren't familar TypeScript provides optional static typing and a range of advanced type-checking features, which allow users to catch type-related errors at compile time, paving the way for safer, more maintainable codebases. One of the primary attractions of TypeScript is its "strict" mode setting. When activated, this setting enforces more rigorous checks on your code, ensuring that developers avoid common mistakes and reducing potential for runtime errors. While the strict mode can be challenging, especially when migrating existing code, I would argue the dividends it pays in code quality and resilience make it a compelling choice for many projects.

Strict mode enforces a series of compiler flags, namely:

These all have there place an make a valuable contribution to ensuring correctness in your programs. The purpose of this post is to explore three other compiler options that are not included as part of strict but you may find useful in your codebase. Namely they are: exactOptionalPropertyTypes, noImplicitOverride and noUncheckedIndexedAccess. We will break down why each one is valuable, but all three are guided by the principle that we should try and reduce bugs at write/compile time rather than further along down the pipeline. It is important to not you must be using TypeScript 4.4 or higher to be able to use all of these flags!

There are other compiler options that can be valuable to improving your codebase that I will not go into, as I would put forward that some of these other flags (e.g. noFallthroughCasesInSwitch, noPropertyAccessFromIndexSignature) are more situational, or others more based on preference (e.g. noUnusedParameters, noUnusedLocals). As with all technology choices, the three I put forward are still situational, but I would argue you will probably want them enabled more often than not. Let's dig in!

The Extra Compiler Flags #

Assuming your tsconfig.json file is set up with the compilerOptions set up something like this:

    "compilerOptions": {
"strict": true
}

We can begin looking at additional flags for compilerOptions that help maintain the quality of your codebase. All the code samples in the following sections are contrived, but hopefully they help illustrate the point for each flag.

exactOptionalPropertyTypes #

This new check was introduced in TypeScript version 4.4. The use case here is that in many real-world scenarios, there's a semantic difference between omitting a property and explicitly setting it to undefined. To elaborate, a value set to undefined is different to a value simply not being defined; for example, the in operater will return true for explicit undefined setting, as well as properties being set to undefined appearing in Object.keys and for...in loops.

With this understanding, the exactOptionalPropertyTypes option provides an way to give developers more control to capture and enforce such distinctions in their type definitions and code. Here we can stop optional parameters being set to undefined like so:

type ConfigOptions = {
verbose?: boolean
}

// This will now throw an error because the property is optional
const config: ConfigOptions = { verbose: undefined };

Here verbose can be uninitialsied but it cannot be explicitly set to undefined.

In the above example to get rid of the new TypeScript error we could simply do:

// This will not throw an error because verbose is optional
const config: ConfigOptions = {};

If we dynmaically wanted to remove the verbose option from config we can use the delete operator like so:

const config: ConfigOptions = { verbose: true };

// Later on...

delete config.verbose

noImplicitOverride #

Introduced in TypeScript version 4.3, this option prohibits us from accidently overriding a base class method by accident, and instead we have to explicitly define when we are overriding a base method. This also stops you from overriding a method that does not exist, helping developers avoid simple typo mistakes. I feel like this flag is particularly helpful in code bases where you have a lot of base classes which you extend.

To give an example, lets say we have a base class and we want to override it when noImplicitOverride is set to false:

class BaseConfigManager {
constructor(private config: ConfigOptions) {}

logConfig() {
console.log(this.configOptions);
}
}


// Doing this will not throw an error when noImplicitOverride is
// set to false, but will throw an error when noImplicitOverride
// is true:
class CustomConfigManager extends BaseConfigManager {
logConfig() {
console.log("the current config is: ", this.configOptions);
}
}

To fix the error when noImplicitOverride is set to true we can use the override key word like so:

// This will not throw an error
class CustomConfigManager extends BaseConfigManager {
override logConfig() {
console.log("the current config is: ", this.configOptions);
}
}

This helps signal to other developers that this method exist on the base class and we are extending it, rather than it being some arbitary method we are adding to the class. As elluded to we also get the added benefit of the it handling typos better when we set noImplicitOverride to true:

// This will error as loggConfig is not defined in the base class
class CustomConfigManager extends BaseConfigManager {
override loggConfig() {
console.log("the current config is: ", this.configOptions);
}
}

This reduces the possibility of accidently creating an additional misspelt method on our extending class which could cause issues down the line which may be hard to spot.

noUncheckedIndexedAccess #

This flag was introduced in TypeScript version 4.1. By default, TypeScript is optimistic about element access. If you access an array or tuple's element with an index, TypeScript assumes you'll get a valid element of the array or tuple's type. Similarly, for indexed types, TypeScript assumes you'll always get a value of the indexed property type. This can be problematic if you access an index that's out of bounds or doesn't exist.

For example we might do something like this:

type ConfigOptions = {
[key: string]: string;
}

const config: ConfigOptions = {};

const database = config.database

// Will throw an error with noUncheckedIndexedAccess true,
// won't throw an error when set to false
database.includes('postgres')

This feature was introduced to catch potential runtime errors that occur when trying to access non-existing elements or properties. By treating these accesses as potentially undefined, we are nudged to add the necessary runtime checks or use TypeScript's type guards to narrow down the types and ensure safer code execution. This is especially helpful in ensuring we better handle code paths where a object property is undefined which can be common (this could be by throwing an explicit error or providing some default value for example).

Conclusion #

Hopefully these explanations and examples have been useful and you can see how turning on these flags may help improve the quality of your codebase. The aim here is to reduce bugs or uncessary errors that could occur at run time by enforcing stricter checks on our code when we write in / compile time. As mentioned previously there are other flags you could explore, although I would contend these are more dependent on the preferences of your team or your coding style.

One notable flag I was considering adding is noPropertyAccessFromIndexSignature which forces you to access potentially existing properties via the square brackets syntax (i.e. ['property']). This could be helpful as a convention in helping delinate which properties are defined and which could potentially exist (as square bracket access allows for dynamic property access). I didn't include it on the basis that some development teams may find this restrictive or simply just not like the enforced coding style.

On a similar note noFallthroughCasesInSwitch was excluded as there are times where you may want to rely on the fallthrough behaviour in switch statements which would require using ts-ingore to override this behaviour.

If you feel that these flags are not helpful, or are actually more situational than not, then I'd be happy to hear from you. Feel free to reach out to me on Twitter (X?)!

Published