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 files that hold all the configuration options for the Compiler. tsconfig.json 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 and types in various cases. null undefined Secondly, the type may appear uncontrollably in your codebase, leading to disabled type checking around this type. any 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 compiler option enables all of the strict mode family options, which include , , , among others. strict noImplicitAny strictNullChecks strictFunctionTypes 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 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. any Using is okay only in extreme cases or for prototyping needs. Despite our best efforts, the type can sometimes sneak into a codebase implicitly. any any By default, the compiler forgives us a lot of errors in exchange for the appearance of in a codebase. Specifically, TypeScript allows us to not specify the type of variables, even when the type cannot be inferred automatically. any 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 compiler option will cause the compiler to highlight all places where the type of a variable is automatically inferred as . In our example, TypeScript will prompt us to specify the type for the function argument. noImplicitAny any 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 , will also have the correct type. res2 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 allows for the safe handling of exceptions in try-catch blocks. By default, TypeScript assumes that the error type in a catch block is , which allows us to do anything with the error. useUnknownInCatchVariables any 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 , this will result in a runtime error. Error Therefore, the option switches the type of the error from to to remind us to check the type of the error before doing anything with it. useUnknownInCatchVariables any unknown 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 variable before passing it to the function, resulting in more correct and safer code. Unfortunately, this option does not help with typing errors in functions or callback functions. err logError promise.catch() But we will discuss ways to deal with in such cases in the next article. any Type Checking for the Call and Apply Methods { "compilerOptions": { "strictBindCallApply": true } } Another option fixes the appearance of in-function calls via and . 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. any call apply For example, we can pass anything as an argument to a function, and in the end, we will always receive the type. any function parse(value: string) { return parseInt(value, 10); } const n1 = parse.call(undefined, '10'); // ^? any const n2 = parse.call(undefined, false); // ^? any Enabling the option makes TypeScript smarter, so the return type will be correctly inferred as . And when trying to pass an argument of the wrong type, TypeScript will point to the error. strictBindCallApply number 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 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. any By default, TypeScript uses the type for the context in such cases and doesn't provide any warnings. any 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 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 field of the class. noImplicitThis name Person Null and Undefined Support in TypeScript { "compilerOptions": { "strictNullChecks": true } } Next several options that are included in the mode do not result in the 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. strict any The first such option fixes the handling of and in TypeScript. By default, TypeScript assumes that and are valid values for any type, which can result in unexpected runtime errors. null undefined null undefined Enabling the compiler option forces the developer to explicitly handle cases where and can occur. strictNullChecks null undefined 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 returns . To prevent this, we can enable the compiler option. users.find() undefined strictNullChecks Now, TypeScript will force us to explicitly handle the possibility of or being returned by . null undefined 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 and , we can avoid runtime errors and ensure that our code is more robust and error-free. null undefiined Strict Function Types { "compilerOptions": { "strictFunctionTypes": true } } Enabling makes TypeScript's compiler more intelligent. Prior to version 2.6, TypeScript did not check the of function arguments. This will lead to runtime errors if the function is called with an argument of the wrong type. strictFunctionTypes contravariance 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 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. strictFunctionTypes 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 option enables checking of mandatory class property initialization for types that do not include as a value. strictPropertyInitialization undefined For example, in the following code, the developer forgot to initialize the property. By default, TypeScript would not detect this error, and an issue could occur at runtime. email class UserAccount { name: string; email: string; constructor(name: string) { this.name = name; // Forgot to assign a value to this.email } } However, when the option is enabled, TypeScript will highlight this problem for us. strictPropertyInitialization email: string; // ^ Error: Property 'email' has no initializer and // is not definitely assigned in the constructor. Safe Index Signatures { "compilerOptions": { "noUncheckedIndexedAccess": true } } The option is not a part of the 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 or return type, which can prevent runtime errors. noUncheckedIndexedAccess strict null undefined 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 . This can lead to a runtime error. string const cache: Record<string, string> = {}; const value = cache['key']; // ^? string console.log(value.toUpperCase()); // ^ TypeError: Cannot read properties of undefined Enabling the option in TypeScript requires checking index access expressions for return type, which can help us avoid runtime errors. This applies to accessing elements in an array as well. noUncheckedIndexedAccess undefined 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 and options in your project's file for optimal type safety. strict noUncheckedIndexedAccess tsconfig.json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, } } If you have already enabled the option, you may consider removing the following options to avoid duplicating the option: strict strict: true 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! Useful Links TSConfig reference Any type Null and Undefined