Back

Improving code quality in Typescript with compiler options - Part 2

Improving code quality in Typescript with compiler options - Part 2

Switching from JavaScript to Typescript is a huge improvement in any codebase. The switch can be made even better and improved by going beyond the default typescript features, making our codebase far more secure. To accomplish this, we will need to include some Typescript compiler options. Compiler options are properties in the tsconfig.json file that can be enabled and disabled to improve our Typescript experience. It is important to note that as Typescript evolves, new flags will be created; therefore, it is recommended that you review the typescript documentation and see if any new flags may be of interest to you at the time of reading this.

This article is part 2 of Improving Typescript code quality with compiler options. Here we will cover more options in the tsconfig.json file and how to improve code quality with the strictPropertyInitialization, useUnknownInCatchVariables, strictFunctionTypes, allowUnusedLabels, allowUnreachableCode, noPropertyAccessFromIndexSignature, exactOptionalPropertyType, and forceConsistentCasingInFileNames options.

The tsconfig.json file

All compiler options are saved in the tsconfig.json file, generated by running tsc --init in our project root terminal. In this post, we will add more commonly used compiler options to the tsconfig.json file and demonstrate the new functionality they introduce to our code. We will add the following compiler options to improve code quality:

"compilerOptions": {
    "module": "system",
    "strictPropertyInitialization": true,
    "useUnknownInCatchVariables": true,
    "strictFunctionTypes": true,
    "allowUnusedLabels": false,
    "allowUnreachableCode": false,
    "noPropertyAccessFromIndexSignature": true,
    "exactOptionalPropertyType": true,
    "forceConsistentCasingInFileNames": true,
    "target": "es5", 
    "module": "commonjs", 
    "rootDir": ".",
    "outDir": "../dist/",
    "sourceMap": true 
}

strictPropertyInitialization

When set to true, this compiler option ensures we initialize all class properties inside a constructor.

class User{
    name: string;
    age: number;
    occupation: string | undefined;
    
    constructor(name: string) {
        this.name = name;
    }
}

We have a User class in the code block above, and the constructor() method is where we initialize the properties of its instance. JavaScript automatically calls the constructor() method when we instantiate a class object. Typescript requires us to either initialize the properties we defined or specify a type of undefined. Hence when we compile the above code, we get the error shown below:

error TS2564: Property 'age' has no initializer and is not definitely assigned in the constructor.

Undefined variables in our code cannot be ignored with this option.

useUnknownInCatchVariables

In Javascript, we can throw an error and catch it in a catch clause. Often, this will be an instance of error and is set to any by default. When the useUnknownInCatchVariable compiler option is set to true, it implicitly sets any variable in a catch clause to unknown instead of any. Consider the example below:

try {
    // random code...
      throw 'myException'; 
}
catch (err) {
    console.error(err.message); 
}

When we compile the above code, with useUnknownInCatchVariables set to true, it changes our error to an unknown type. Hence we get the error below from the terminal.

error TS2571: Object is of type 'unknown'.

This error is produced because the error was set to unknown by Typescript. To fix this error, we can:

  • Narrow the error from unknown to an instance of Error.
try {
  // random code...
    throw 'myException'; 
}
catch (err) { // err: unknown
  if (err instanceof Error) {
    console.error(err.message);
  }
}
try {
  // random code...
  throw "myException";
} catch (err) {
  // Use the type guard
  // error is now narrowed to definitely be a string
  if (typeof err === "string") {
    console.error(err);
  }
}

With the help of our if check, Typescript recognizes that typeof err === "string" is a special form of code where we explicitly state and describe types. This is referred to as type guard.

strictFunctionTypes

When strictFunctionTypes is true, function parameters are checked more thoroughly. Typescript parameters are bivariant by default, implying that they can be both covariant and contravariant. Variance is a way to gain insight into subtyping relationships. When the parameter is covariance, we can assign a specific type to a broader type (e.g., assign a subtype to a supertype). Contravariance is the inverse; here, we can assign a broader type to a specific type (e.g., assigning a supertype to a subtype). We have covariance and contravariance in bivariance. To learn more about variance, click here.

//strictFunctionTypes: false

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breeds: Array<string>;
}

let getDogName = (dog: Dog) => dog.name;
let getAnimalName = (animal: Animal) => animal.name;

getDogName = getAnimalName;  // Okay
getAnimalName = getDogName;  // Okay 

The above code runs without error, and parameters are compared bivariantly by default. The method of the supertype getAnimalName and subtype getDogName can be assigned to each other. Typescript’s parameters are compared contravariantly if strictFunctionTypes is set to true

//strictFunctionTypes : true

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breeds: Array<string>;
}

let getDogName = (dog: Dog) => dog.name;
let getAnimalName = (animal: Animal) => animal.name;

getDogName = getAnimalName; // Okay
getAnimalName = getDogName; // Error

When the code block above is run, we get an error:

Type '(dog: Dog) => string' is not assignable to type '(animal: Animal) => string'.
Types of parameters 'dog' and 'animal' are incompatible.
Property 'breeds' is missing in type 'Animal' but required in type 'Dog'.

Here, getAnimalName is a broader function than getDogName. As a result, the supertype cannot be assigned to the subtype in this case. However, a subtype can be assigned to a supertype. Most of the time, function parameters should be contravariant, not bivariant. If we enable this typescript option, Typescript will not treat function parameters as bivariant.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

replayer.png

Start enjoying your debugging experience - start using OpenReplay for free.

allowUnusedLabels

A label statement in JavaScript is used to prefix a label as an identifier. Label statements in JavaScript are similar to checkpoints in our code. Because the label keyword is not a reserved keyword in JavaScript, we can represent it with any string that is not a reserved word and is followed by a colon :. The syntax for declaring a label is similar to the syntax for declaring an object in JavaScript. Most developers make the error of declaring labels instead of objects. To avoid this, Typescript provides us with the allowUnusedLabels option, which, when set to false, causes Typescript to throw an error highlighting any unused labels in our code.

const isUserAgeValid = (age: number) => {
  if (age > 18) {
    curfew: true; //-- COMPILER ERROR - Unused label
  }
};

In the code above, curfew is a label. The following error will be raised when the code block is compiled:

error TS7028: Unused label

allowUnreachableCode

UnReachable code will never be executed, such as a code that follows a return statement. When this compiler option is set to true, unreachable codes are ignored. In contrast, TypeScript verifies our code paths when allowUnreachableCode is set to false, ensuring that all codes can be accessed and used. When set to true, an error is thrown if any unreachable code is detected.

const randomNum = (n: number): boolean => {
  if (n > 5) {
    return true;
  } else {
    return false;
  }
    
  return true;
};

If a code is proven to be unreachable, Typescript will display this warning:

error TS7027: Unreachable code detected.

noPropertyAccessFromIndexSignature

When this compiler option is set to true, it requires us to access unknown properties using [] bracket notation and .dot notation to access defined properties. This promotes consistency because property accessed with dot notation always indicates existing property. (obj.key) syntax While we use the [] bracket notation if the property does not exist. obj["key"].

interface HorseRace {
  // defined properties
  breed: "Shetland" | "Hackney" | "Standardbred";
  speed: "fast" | "slow";

  //index signature for properties yet to be defined
  [key: string]: string;
}

declare const pick: HorseRace;

pick.breed;
pick.speed;
pick.ownerName;

In the above example, we defined an interface HorseRace and gave it some properties such as breed, speed, and an index signature (for unknown properties). We get this error when we compile the code above.

error TS4111: Property 'ownerName' comes from an index signature, so it must be accessed with ['ownerName'].

To correct this error, use bracket notation when calling the property ownerName.

pick.breed;
pick.speed;
pick["ownerName"]

exactOptionalPropertyType

By default, Typescript ignores whether a property is set to “undefined” because it wasn’t defined or because we set it that way.

//exactOptionalPropertyTypes = false

interface Test {
  property?: string;
}

const test1: Test = {};
console.log("property" in test1); //=> false

const test2: Test = { property: undefined };
console.log("property" in test2);

The above code block would execute without error, logging false and true values. In test1, we’re checking to see if property was defined; if it wasn’t, it logs a false. In test2, the value true is logged because we defined property and set property to undefined. Next, let’s set the exactOptionalPropertyTypes option to true.

//exactOptionalPropertyTypes = true

interface Test {
  property?: string;
}

const test1: Test = {};
console.log("property" in test1); // false

const test2: Test = { property: undefined };
console.log("property" in test2);  // true

When the above code block is compiled, the following error will be logged:

error TS2375: Type '{ property: undefined; }' is not assignable to type 'Test' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties. Types of property 'property' are incompatible. Type 'undefined' is not assignable to type 'string'.

What happened here is that Typescript refused to let us define a property of undefined. If property is not explicitly annotated to a type of undefined first. To solve this, we can annotate the property with the type undefined.

//exactOptionalPropertyTypes = true

interface Test {
  property?: string | undefined;
}

const test1: Test = {};
console.log("property" in test1); //false

const test2: Test = { property: undefined };
console.log("property" in test2);  //true

Our code runs smoothly again, logging false and true values. Typescript becomes aware of these two different ways of having an undefined property when exactOptionalPropertyTypes is enabled. It also ensures that if we want to explicitly define a property as undefined, it must first be annotated with the type undefined.

forceConsistentCasingInFileNames

This is a highly recommended compiler option among teams, and it encourages team members to use best practices and to be consistent. When this option is set to false, the case sensitivity rules of the operating system (OS) on which Typescript is running are followed. It could be either case sensitive (the operating system distinguishes between lowercase and uppercase characters in file names) or case insensitive (the operating system does not distinguish between character cases). When forceConsistentCasingInFileNames option is set to true, Typescript will raise an error If we try to import a file with a name casing that differs from the file’s name casing on the disk.

// StringValidator.ts

export interface StringValidator {
  isAcceptable(s: string): boolean;
}
// ZipCodeValidator.ts

import { StringValidator } from "./stringValidator";

export const numberRegexp = /^[0-9]+$/;
export class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}

Without considering the file’s name case, we import the StringValidator file in the code above. If forceConsistentCasingInFileNames option is set to true, an error will be raised:

error TS1149: File name 'C:/Users/acer/Desktop/workBase/writing/Typescript/tsc/stringValidator.ts' differs from already included file name 'C:/Users/acer/Desktop/workBase/writing/Typescript/tsc/StringValidator.ts' only in casing.

To solve this, use the exact character case for your file’s name when importing.

Conclusion

This article took a long look at some compiler options, the errors they generate, how to solve them and how to improve code quality by using them. Many other compiler options that can help with code quality can be found here.