Unleashing the Power of TypeScript: Key Considerations in tsconfig
Tags: typescript, javascript, webdev, tutorial
Published: 2023-07-16 08:35:16

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:

```json
{
    "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

```json
{
    "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

```json
{
    "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`.

```tsx
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.

```tsx
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.

```tsx
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

```json
{
    "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`.

```tsx
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.

```tsx
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

```json
{
    "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.

```tsx
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.

```tsx
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

```json
{
    "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.

```tsx
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

```json
{
    "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:

```tsx
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()`.

```tsx
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

```json
{
    "compilerOptions": {
        "strictFunctionTypes": true
    }
}
```

Enabling `strictFunctionTypes` makes TypeScript's compiler more intelligent. Prior to version 2.6, TypeScript did not check the [contravariance](https://devblogs.microsoft.com/typescript/announcing-typescript-2-6/#contravariant-parameter-types-with-strictfunctiontypes) 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.

```tsx
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.

```tsx
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

```json
{
    "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.

```tsx
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.

```tsx
email: string;
// ^  Error: Property 'email' has no initializer and
//           is not definitely assigned in the constructor.
```

---

## Safe Index Signatures

```json
{
    "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.

```tsx
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.

```tsx
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.

```json
{
    "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!


## Useful Links

- [TSConfig reference](https://www.typescriptlang.org/tsconfig)
- [Any type](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#any)
- [Null and Undefined](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#null-and-undefined)