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);
}
}
- Use a type guard
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: falseinterface 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 : trueinterface 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.
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 = falseinterface Test {
property?: string;
}const test1: Test = {};
console.log("property" in test1); //=> falseconst 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 = trueinterface Test {
property?: string;
}const test1: Test = {};
console.log("property" in test1); // falseconst 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 = trueinterface Test {
property?: string | undefined;
}const test1: Test = {};
console.log("property" in test1); //falseconst 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.tsexport 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.
This article was originally published at blog.openreplay.com on July 27th, 2022