TypeScript Unleashed: Mastering Best Practices for Building High-Quality Code

Aren Chiling
7 min readMar 31, 2023

--

These are just a few of the many best practices for building high-quality code in TypeScript. By following these guidelines, you can improve the readability, maintainability, and overall quality of your code.

Use strict mode

TypeScript’s strict mode is a compiler option that enables a set of strict type-checking rules and additional error checking. It helps to catch common programming errors and improve code quality by detecting type errors at compile-time, rather than at runtime. By enabling strict mode, you can catch many common errors and ensure that your code is as bug-free as possible. Enabling strict mode is as simple as adding "strict": true to your tsconfig.json file.

Example 1:

function addNumbers(a: number, b: number) {
return a + b + " dollars"; // Error: Type 'string' is not assignable to type 'number'
}

const result = addNumbers(5, 10);
console.log(result); // Error: The result type of an arithmetic operation must be a number

In this example, the addNumbers function is intended to add two numbers and return the result as a number. However, due to a typo, it is actually returning the result as a string. With strict mode enabled, TypeScript will catch this error and prevent the code from compiling.

Example 2:

let x: number;
let y = null;
x = y; // Error: Type 'null' is not assignable to type 'number'

In this example, we are attempting to assign a null value to a variable of type number. With strict mode enabled, TypeScript will catch this error and prevent the code from compiling.

Use interfaces and types

Interfaces and types are an important part of writing high-quality code in TypeScript. They provide a way to define the structure and shape of data in your application, making it easier to understand and maintain your code. They can be used to define custom types, objects, functions, and more.

interface Person {
firstName: string;
lastName: string;
age: number;
email?: string;
}

function getFullName(person: Person) {
return `${person.firstName} ${person.lastName}`;
}

const person: Person = {
firstName: "John",
lastName: "Doe",
age: 30,
email: "john.doe@example.com",
};

console.log(getFullName(person)); // John Doe

In this example, we define an interface called Person that describes the shape of a person object. We then define a function called getFullName that takes a Person object and returns their full name. Finally, we create a person object that conforms to the Person interface and pass it to the getFullName function.

Example 2:

type User = {
id: number;
name: string;
email: string;
};

function sendEmail(user: User) {
console.log(`Sending email to ${user.name} (${user.email})`);
}

const users: User[] = [
{ id: 1, name: "John Doe", email: "john.doe@example.com" },
{ id: 2, name: "Jane Doe", email: "jane.doe@example.com" },
{ id: 3, name: "Bob Smith", email: "bob.smith@example.com" },
];

users.forEach(sendEmail);

In this example, we define a type called User that describes the shape of a user object. We then define a function called sendEmail that takes a User object and logs a message to the console. Finally, we create an array of User objects and use the forEach method to call the sendEmail function for each user.

Use descriptive variable names

Using descriptive variable names is an important part of writing high-quality code. Variables with clear and accurate names make your code more readable, understandable, and maintainable. This reduces the risk of errors caused by confusion or ambiguity.

Example 1:

const ageInYears = 30;
const totalAmountDue = calculateTotalAmountDue(price, taxRate, discount);

In this example, we use descriptive variable names to make the code more readable and self-explanatory. The variable ageInYears represents a person's age in years, and the variable totalAmountDue represents the total amount due for a purchase.

Example 2:

const currentUserId = 123;
const currentUserEmail = "john.doe@example.com";
const currentUserIsAdmin = true;

In this example, we use descriptive variable names to represent information about a user. The variable currentUserId represents the ID of the currently logged-in user, currentUserEmail represents their email address, and currentUserIsAdmin represents whether or not they are an admin.

By using descriptive variable names, you can make your code more readable and understandable, and reduce the risk of errors caused by confusion or ambiguity.

Use the nullish coalescing operator

The nullish coalescing operator (??) is a TypeScript feature that provides a concise and safe way to handle null or undefined values. It returns the left-hand side expression if it is not null or undefined, otherwise it returns the right-hand side expression.

Example 1:

const myValue = null ?? "default value"; // "default value"
const anotherValue = undefined ?? "default value"; // "default value"
const myNumber = 0 ?? 10; // 0

In this example, we use the nullish coalescing operator to provide a default value for a variable if it is null or undefined. The variable myValue is assigned the value "default value" because null is nullish, and the variable anotherValue is also assigned "default value" because undefined is nullish. The variable myNumber is assigned the value 0 because it is not nullish.

By using the nullish coalescing operator, you can write cleaner and more concise code that handles null and undefined values in a safe and reliable way.

Use enums for constants

Enums are a TypeScript feature that provides a way to define a set of related values that are constant. Use them to represent a set of values that are related to each other, and avoid using magic numbers or strings that may cause confusion or errors.

Example 1:

enum Colors {
Red = "#ff0000",
Green = "#00ff00",
Blue = "#0000ff",
}

const myColor = Colors.Red;

In this example, we define an enum called Colors that represents a set of colors and their hexadecimal values. We then assign the value Colors.Red to a variable called myColor.

Example 2:

enum Direction {
Up,
Down,
Left,
Right,
}

function move(direction: Direction) {
// move the player in the specified direction
}

move(Direction.Up);

In this example, we define an enum called Direction that represents a set of directions. We then define a function called move that takes a Direction argument and moves the player in the specified direction. Finally, we call the move function with the Direction.Up argument.

By using enums to represent related values, you can make your code more readable and maintainable. Enums provide a way to define a set of related constants, making it easier to understand their purpose and use them consistently throughout your code.

Use namespaces and modules

Namespaces and modules provide a way to organize your code and avoid naming conflicts. Namespaces are a way to group related code together and avoid polluting the global namespace, while modules provide a way to encapsulate code and share it across different parts of your application.

namespace MyNamespace {
export const myVariable = "Hello, world!";
export function myFunction() {
console.log(myVariable);
}
}

MyNamespace.myFunction(); // "Hello, world!"

In this example, we define a namespace called MyNamespace that contains a variable called myVariable and a function called myFunction. We then call the myFunction function from outside the namespace using the MyNamespace.myFunction syntax.

Example 2:

// math.ts module
export function addNumbers(a: number, b: number) {
return a + b;
}

// app.ts module
import { addNumbers } from "./math";

const result = addNumbers(5, 10);
console.log(result); // 15

In this example, we define a module called math.ts that exports a function called addNumbers. We then import the addNumbers function into another module called app.ts and use it to calculate the sum of two numbers.

By using namespaces and modules to organize your code, you can reduce naming conflicts, improve code maintainability, and make it easier to share code across different parts of your application.

Use async/await instead of callbacks

Async/await is a cleaner and more readable way to write asynchronous code in TypeScript than using callbacks. Always prefer async/await when working with asynchronous operations.

Example:

async function getData() {
const response = await fetch("/api/data");
const data = await response.json();
return data;
}

getData()
.then((data) => console.log(data))
.catch((error) => console.error(error));

In this example, we define an asynchronous function called getData that uses the fetch API to retrieve some data from a remote server. We then use the await keyword to wait for the response to be returned, and the json method to parse the data. Finally, we return the data from the function and handle any errors using the then and catch methods.

By using async/await instead of callbacks, you can write cleaner and more readable code that is easier to understand and maintain.

Use generics

Generics are a powerful TypeScript feature that allows you to write reusable code that can work with a variety of data types. They provide a way to write flexible and type-safe functions and classes that can work with any data type.

Example 1:

function identity<T>(arg: T): T {
return arg;
}

const result1 = identity("Hello, world!"); // "Hello, world!"
const result2 = identity(42); // 42

In this example, we define a function called identity that uses a generic type parameter T. The function takes an argument of type T and returns the same value. We then call the function with two different types of arguments: a string and a number.

Example 2:

interface Repository<T> {
getById(id: number): T;
getAll(): T[];
save(entity: T): void;
}

class UserRepository implements Repository<User> {
getById(id: number): User {
// implementation
}
getAll(): User[] {
// implementation
}
save(user: User): void {
// implementation
}
}

interface User {
id: number;
name: string;
email: string;
}

const userRepository = new UserRepository();
const user = userRepository.getById(123);
userRepository.save(user);

In this example, we define an interface called Repository that uses a generic type parameter T. The interface defines three methods for working with data of type T. We then define a class called UserRepository that implements the Repository interface for data of type User. Finally, we create an instance of the UserRepository class and use it to retrieve and save user data.

By using generics, you can write flexible and type-safe code that can work with any data type, making your code more reusable and easier to maintain.

To summarize, TypeScript is a powerful tool for building high-quality, scalable, and maintainable code. By adhering to these best practices, you can ensure that your TypeScript code is well-organized, easy to read, and straightforward to maintain:

--

--