paint-brush
Unleashing the Power of TypeScript: Key Considerations in tsconfigby@nodge
2,700 reads
2,700 reads

Unleashing the Power of TypeScript: Key Considerations in tsconfig

by Maksim ZemskovJuly 12th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

TypeScript is a popular language for building complex applications, thanks to its strong type system and static analysis capabilities. However, to achieve maximum type safety, it's important to configure tsconfig correctly. In this article, we'll discuss the key considerations for configuring tsconfig to achieve optimal type safety.
featured image - Unleashing the Power of TypeScript: Key Considerations in tsconfig
Maksim Zemskov HackerNoon profile picture

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 toward 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 the 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 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 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 the name “Max” does not exist in the system, and users.find() returns undefined. To prevent this, we can enable the 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());
}

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 backward 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!