If you're building complex web applications, TypeScript is likely your programming language of choice. TypeScript is well-loved for its strong type system and static analysis capabilities, making it a powerful tool for ensuring that your code is robust and error-free. It also accelerates the development process through integration with code editors, allowing developers to navigate the code more efficiently and get more accurate hints and auto-completion, as well as enabling safe refactoring of large amounts of code.
The Compiler is the heart of TypeScript, responsible for checking type correctness and transforming TypeScript code into JavaScript. However, to fully utilize TypeScript's power, it's important to configure the Compiler correctly. Each TypeScript project has one or more tsconfig.json
files that hold all the configuration options for the Compiler.
Configuring tsconfig is a crucial step in achieving optimal type safety and developer experience in your TypeScript projects. By taking the time to carefully consider all of the key factors involved, you can speed up the development process and ensure that your code is robust and error-free.
Downsides of the Standard Configuration
The default configuration in tsconfig can cause developers to miss out on the majority of benefits of TypeScript. This is because it does not enable many powerful type checking capabilities. By "default" configuration, I mean a configuration where no type checking compiler options are set. For example:
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
},
"include": ["src"]
}
The absence of several key configuration options can result in lower code quality for two primary reasons. Firstly, TypeScript's compiler may incorrectly handle null
and undefined
types in various cases. Secondly, the any
type may appear uncontrollably in your codebase, leading to disabled type checking around this type.
Fortunately, these issues are easy to fix by tweaking a few options in the configuration.
The Strict Mode
{
"compilerOptions": {
"strict": true
}
}
Strict mode is an essential configuration option that provides stronger guarantees of program correctness by enabling a wide range of type checking behaviors. Enabling strict mode in the tsconfig file is a crucial step towards achieving maximum type safety and a better developer experience. It requires a little extra effort in configuring tsconfig, but it can go a long way in improving the quality of your project.
The strict
compiler option enables all of the strict mode family options, which include noImplicitAny
, strictNullChecks
, strictFunctionTypes
, among others. These options can also be configured separately, but it's not recommended to turn off any of them. Let's look at examples to see why.
Implicit Any Inferring
{
"compilerOptions": {
"noImplicitAny": true
}
}
The any
type is a dangerous loophole in the static type system and using it disables all type checking rules. As a result, all benefits of TypeScript are lost: bugs are missed, code editor hints stop working properly, and so on. Using any
is okay only in extreme cases or for prototyping needs. Despite our best efforts, the any
type can sometimes sneak into a codebase implicitly.
By default, the compiler forgives us a lot of errors in exchange for the appearance of any
in a codebase. Specifically, TypeScript allows us to not specify the type of variables, even when the type cannot be inferred automatically. The problem is that we may accidentally forget to specify the type of a variable, for example, to a function argument. Instead of showing an error, TypeScript will automatically infer the type of the variable as any
.
function parse(str) {
// ^? any
return str.split('');
}
// TypeError: str.split is not a function
const res1 = parse(42);
const res2 = parse('hello');
// ^? any
Enabling the noImplicitAny
compiler option will cause the compiler to highlight all places where the type of a variable is automatically inferred as any
. In our example, TypeScript will prompt us to specify the type for the function argument.
function parse(str) {
// ^ Error: Parameter 'str' implicitly has an 'any' type.
return str.split('');
}
When we specify the type, TypeScript will quickly catch the error of passing a number to a string parameter. The return value of the function, stored in the variable res2
, will also have the correct type.
function parse(str: string) {
return str.split('');
}
const res1 = parse(42);
// ^ Error: Argument of type 'number' is not
// assignable to parameter of type 'string'
const res2 = parse('hello');
// ^? string[]
Unknown Type in Catch Variables
{
"compilerOptions": {
"useUnknownInCatchVariables": true
}
}
Configuring useUnknownInCatchVariables
allows for safe handling of exceptions in try-catch blocks. By default, TypeScript assumes that the error type in a catch block is any
, which allows us to do anything with the error. For example, we could pass the caught error as-is to a logging function that accepts an instance of Error
.
function logError(err: Error) {
// ...
}
try {
return JSON.parse(userInput);
} catch (err) {
// ^? any
logError(err);
}
However, in reality, there are no guarantees about the type of error, and we can only determine its true type at runtime when the error occurs. If the logging function receives something that is not an Error
, this will result in a runtime error.
Therefore, the useUnknownInCatchVariables
option switches the type of the error from any
to unknown
to remind us to check the type of the error before doing anything with it.
try {
return JSON.parse(userInput);
} catch (err) {
// ^? unknown
// Now we need to check the type of the value
if (err instanceof Error) {
logError(err);
} else {
logError(new Error('Unknown Error'));
}
}
Now, TypeScript will prompt us to check the type of the err
variable before passing it to the logError
function, resulting in more correct and safer code. Unfortunately, this option does not help with typing errors in promise.catch()
functions or callback functions. But we will discuss ways to deal with any
in such cases in the next article.
Type Checking for the Call and Apply Methods
{
"compilerOptions": {
"strictBindCallApply": true
}
}
Another option fixes the appearance of any
in function calls via call
and apply
. This is a less common case than the first two, but it's still important to consider. By default, TypeScript does not check types in such constructions at all.
For example, we can pass anything as an argument to a function, and in the end, we will always receive the any
type.
function parse(value: string) {
return parseInt(value, 10);
}
const n1 = parse.call(undefined, '10');
// ^? any
const n2 = parse.call(undefined, false);
// ^? any
Enabling the strictBindCallApply
option makes TypeScript smarter, so the return type will be correctly inferred as number
. And when trying to pass an argument of the wrong type, TypeScript will point to the error.
function parse(value: string) {
return parseInt(value, 10);
}
const n1 = parse.call(undefined, '10');
// ^? number
const n2 = parse.call(undefined, false);
// ^ Argument of type 'boolean' is not
// assignable to parameter of type 'string'.
Strict Types for Execution Context
{
"compilerOptions": {
"noImplicitThis": true
}
}
The next option that can help prevent the appearance of any
in your project fixes the handling of the execution context in function calls. JavaScript's dynamic nature makes it difficult to statically determine the type of the context inside a function. By default, TypeScript uses the type any
for the context in such cases and doesn't provide any warnings.
class Person {
private name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return function () {
return this.name;
// ^ 'this' implicitly has type 'any' because
// it does not have a type annotation.
};
}
}
Enabling the noImplicitThis
compiler option will prompt us to explicitly specify the type of the context for a function. This way, in the example above, we can catch the error of accessing the function context instead of the name
field of the Person
class.
Null and Undefined Support in TypeScript
{
"compilerOptions": {
"strictNullChecks": true
}
}
Next several options that are included in the strict
mode do not result in the any
type appearing in the codebase. However, they make the behavior of the TS compiler stricter and allow for more errors to be found during development.
The first such option fixes the handling of null
and undefined
in TypeScript. By default, TypeScript assumes that null
and undefined
are valid values for any type, which can result in unexpected runtime errors. Enabling the strictNullChecks
compiler option forces the developer to explicitly handle cases where null
and undefined
can occur.
For example, consider the following code:
const users = [
{ name: 'Oby', age: 12 },
{ name: 'Heera', age: 32 },
];
const loggedInUser = users.find(u => u.name === 'Max');
// ^? { name: string; age: number; }
console.log(loggedInUser.age);
// ^ TypeError: Cannot read properties of undefined
This code will compile without errors, but it may throw a runtime error if the user with name “Max” does not exist in the system, and users.find()
returns undefined
. To prevent this, we can enable strictNullChecks
compiler option.
Now, TypeScript will force us to explicitly handle the possibility of null
or undefined
being returned by users.find()
.
const loggedInUser = users.find(u => u.name === 'Max');
// ^? { name: string; age: number; } | undefined
if (loggedInUser) {
console.log(loggedInUser.age);
}
By explicitly handling the possibility of null
and undefiined
, we can avoid runtime errors and ensure that our code is more robust and error-free.
Strict Function Types
{
"compilerOptions": {
"strictFunctionTypes": true
}
}
Enabling strictFunctionTypes
makes TypeScript's compiler more intelligent. Prior to version 2.6, TypeScript did not check the contravariance of function arguments. This will lead to runtime errors if the function is called with an argument of the wrong type.
For example, even if a function type is capable of handling both strings and numbers, we can assign a function to that type that can only handle strings. We can still pass a number to that function, but we will receive a runtime error.
function greet(x: string) {
console.log("Hello, " + x.toLowerCase());
}
type StringOrNumberFn = (y: string | number) => void;
// Incorrect Assignment
const func: StringOrNumberFn = greet;
// TypeError: x.toLowerCase is not a function
func(10);
Fortunately, enabling the strictFunctionTypes
option fixes this behavior, and the compiler can catch these errors at compile-time, showing us a detailed message of the type incompatibility in functions.
const func: StringOrNumberFn = greet;
// ^ Type '(x: string) => void' is not assignable to type 'StringOrNumberFn'.
// Types of parameters 'x' and 'y' are incompatible.
// Type 'string | number' is not assignable to type 'string'.
// Type 'number' is not assignable to type 'string'.
Class Property Initialization
{
"compilerOptions": {
"strictPropertyInitialization": true
}
}
Last but not least, the strictPropertyInitialization
option enables checking of mandatory class property initialization for types that do not include undefined
as a value.
For example, in the following code, the developer forgot to initialize the email
property. By default, TypeScript would not detect this error, and an issue could occur at runtime.
class UserAccount {
name: string;
email: string;
constructor(name: string) {
this.name = name;
// Forgot to assign a value to this.email
}
}
However, when the strictPropertyInitialization
option is enabled, TypeScript will highlight this problem for us.
email: string;
// ^ Error: Property 'email' has no initializer and
// is not definitely assigned in the constructor.
Safe Index Signatures
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
The noUncheckedIndexedAccess
option is not a part of the strict
mode, but it is another option that can help improve code quality in your project. It enables the checking of index access expressions to have a null
or undefined
return type, which can prevent runtime errors.
Consider the following example, where we have an object for storing cached values. We then get the value for one of the keys. Of course, we have no guarantee that the value for the desired key actually exists in the cache. By default, TypeScript would assume that the value exists and has the type string
. This can lead to a runtime error.
const cache: Record<string, string> = {};
const value = cache['key'];
// ^? string
console.log(value.toUpperCase());
// ^ TypeError: Cannot read properties of undefined
Enabling the noUncheckedIndexedAccess
option in TypeScript requires checking index access expressions for undefined
return type, which can help us avoid runtime errors. This applies to accessing elements in an array as well.
const cache: Record<string, string> = {};
const value = cache['key'];
// ^? string | undefined
if (value) {
console.log(value.toUpperCase());
}
Recommended Configuration
Based on the options discussed, it is highly recommended to enable the strict
and noUncheckedIndexedAccess
options in your project's tsconfig.json
file for optimal type safety.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
}
}
If you have already enabled the strict
option, you may consider removing the following options to avoid duplicating the strict: true
option:
noImplicitAny
useUnknownInCatchVariables
strictBindCallApply
noImplicitThis
strictFunctionTypes
strictNullChecks
strictPropertyInitialization
It is also recommended to remove the following options that can weaken the type system or cause runtime errors:
keyofStringsOnly
noStrictGenericChecks
suppressImplicitAnyIndexErrors
suppressExcessPropertyErrors
By carefully considering and configuring these options, you can achieve optimal type safety and a better developer experience in your TypeScript projects.
Conclusion
TypeScript has come a long way in its evolution, constantly improving its compiler and type system. However, to maintain backwards compatibility, the TypeScript configuration has become more complex, with many options that can significantly affect the quality of type checking.
By carefully considering and configuring these options, you can achieve optimal type safety and a better developer experience in your TypeScript projects. It is important to know which options to enable and remove from a project configuration. Understanding the consequences of disabling certain options will allow you to make informed decisions for each one.
It is important to keep in mind that strict typing may have consequences. To effectively deal with the dynamic nature of JavaScript, you will need to have a good understanding of TypeScript beyond simply specifying "number" or "string" after a variable. You will need to be familiar with more complex constructs and the TypeScript-first ecosystem of libraries and tools to more effectively solve type-related issues that will arise.
As a result, writing code may require a little more effort, but based on my experience, this effort is worth it for long-term projects.
I hope you have learned something new from this article. This is the first part of a series. In the next article, we will discuss how to achieve better type safety and code quality by improving the types in TypeScript's standard library. Stay tuned and thanks for reading!
Top comments (0)