Using TypeScript: Best Practices and Patterns

Using TypeScript: Best Practices and Patterns

Main_Cube

Hello everyone,

My name is Gustavo Gutiérrez and I am delighted to share with you some of the best practices and patterns that have been developed and refined to use TypeScript effectively. Since its release, TypeScript has radically changed the way we write and maintain our JavaScript code, offering a syntax that allows for greater clarity and robustness.

In this article, we will examine how to use the power of TypeScript to optimize the quality of our code. Recommended techniques will be discussed that can help prevent common errors and preserve our optimal and readable code. We'll also examine design patterns that are uniquely suited to TypeScript, allowing us to create more scalable and maintainable applications.

My purpose is to provide you with a practical and accessible guide that you can directly apply to your daily projects. We are going to show situations where TypeScript shines and helps us create more reliable and efficient software.

I hope that, at the end of this article, you will have a series of tools and knowledge that will allow you to get the most out of TypeScript, which will allow you to improve your productivity and the quality of your applications.

Join me on this journey through TypeScript best practices and patterns!

Getting Started with Best Practices

Strict Typing

One of the main advantages of TypeScript is its static type system, which allows us to explicitly define the type of data that our variables and functions should handle. To take full advantage of this feature, it is essential to activate strict typing mode. This will help us detect potential bugs during the development phase, before our code reaches production.

// Enable strict mode in tsconfig.json

{
 "compilerOptions": {
 "strict": true
 }
}

Use of Types and Interfaces

Another best practice is to use types and interfaces to define clear and consistent data structures. Interfaces allow us to define contracts for our objects, ensuring that they follow a specific structure, which facilitates team collaboration and improves code readability.

interface User {
 name: string;
 age: number;
 mail: string;
}

const user: User = {
 name: "John",
 age: 30,
 email: "juan@example.com"
};

Avoid Using the Type any

The any type in TypeScript can be useful in certain cases, but overusing it can defeat the purpose of using TypeScript. By using any, we lose the benefits of static typing and open the door to runtime errors. Instead of any, try to use more specific or generic types when possible.

// Avoid
let value: any = "Hello";
value = 42;

// Better
let value: string | number = "Hello";
value = 42;

Use Enums for Constant Values

Enums are a powerful TypeScript feature that allows us to define a set of constant, named values. Enums can help avoid errors and make our code more readable.

enum Address {
 North,
 South,
 This,
 West
}

let address: Address = Address.North;

if (address === NorthDirection) {
 console.log("We're heading north");
}

Using Generics
Generics allow you to write functions and classes that can work with any type, providing greater flexibility and reusability. Generics help us avoid using the any type and keep our code type safe.

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

let number = identity<number>(42);
let text = identity<string>("Hello");

tsconfig.json configuration

Be sure to properly configure your tsconfig.json file to take full advantage of TypeScript features and customize compiler settings to your project's needs.

{
 "compilerOptions": {
 "target": "es6",
 "module": "commonjs",
 "strict": true,
 "esModuleInterop": true,
 "skipLibCheck": true,
 "forceConsistentCasingInFileNames": true,
 "outDir": "./dist",
 "rootDir": "./src"
 },
 "include": ["src/**/*"],
 "exclude": ["node_modules", "**/*.spec.ts"]
}

Documentation with JSDoc

Documenting your code with JSDoc is a good practice that can help other developers (and yourself in the future) better understand what your code does. Additionally, TypeScript can use this documentation to improve the autocom experience.completion and error messages in your editor.

/**
 * Add two numbers.
 * @param {number} a - The first number.
 * @param {number} b - The second number.
 * @returns {number} - The sum of the two numbers.
**/

function add(a: number, b: number): number {
 return a + b;
}

Some Interesting Design Patterns

Image_ilutration_of_mind

The Builder Design Pattern with TypeScript

The Builder design pattern is a creational pattern used to build complex objects in a more controlled and readable way. This pattern is responsible for encapsulating the construction of an object in a series of well-defined steps, allowing the same construction process to create different representations of the object.

Why Use the Builder Pattern?

The Builder pattern is useful when:

• You need to create an instance of a class that requires a large number of configuration parameters.
• The creation of the object involves a series of complex steps.
• We want to avoid the use of telescopic constructors (constructors with many parameters).

Applications of the Builder Pattern

The Builder pattern is applied in the following circumstances:

• Construction of complex configuration objects.
• When greater clarity and legibility are desired in the creation of objects.
• When you need different configurations of the same object in a safe and easy to understand way.

Practical Example in TypeScript

Let's create a simple example of a Builder pattern to build a Car object.

// Definition of the Car class
class Car {
 public flag: string;
 public model: string;
 public year: number;
 public color: string;
 public engine: string;

 builder(builder: CarBuilder) {
 this.brand = builder.brand;
 this.model = builder.model;
 this.year = builder.year;
 this.color = builder.color;
 this.motor = builder.motor;
 }
}

// Builder definition
class CarBuilder {
 public flag: string;
 public model: string;
 public year: number;
 public color: string;
 public engine: string;

 constructor(brand: string, model: string) {
 this.brand = brand;
 this.model = model;
 }

 setYear(year: number): CarBuilder {
 this.year = year;
 return this;
 }

 setColor(color: string): CarBuilder {
 this.color = color;return this;
 }

 setMotor(motor: string): CarBuilder {
 this.motor = motor;
 return this;
 }

 build(): Car {
 return new Car(this);
 }
}

// Using the Builder pattern to create a Car object
 const car = new CarBuilder('Toyota', 'Corolla')
 .setYear(2022)
 .setColor('Red')
 .setMotor('Hybrid')
 .build();

console.log(car);

In this example:

Car: The class we want to build.

CocheBuilder: The Builder class that is responsible for building the Car object.

setYear, setColor, setMotor: Methods that set the attributes of the car and return the CarBuilder object to allow chaining of calls.

Where can it be applied?

The Builder pattern is ideal for building complex objects in scenarios such as:

• Application configuration: When an application needs a complex configuration that must be initialized in a controlled manner.
• Construction of reports or documents: Where there are multiple sections and configurations.
• Creating user interfaces: When you need to build interfaces dynamically with multiple configuration options.

The Factory Method Design Pattern with TypeScript

The Factory Method design pattern is a creational pattern that defines an interface for creating objects, but allows subclasses to alter the type of objects that are created. This pattern is used when a class cannot anticipate what kind of objects it should create or when a class wants to delegate creation responsibility to its subclasses.

Why Use the Factory Method Pattern?

The Factory Method pattern is useful when:

• You need to create objects of different types depending on certain parameters or conditions.
• We want to encapsulate the object creation logic to make the code more flexible and extensible.
• We want to adhere to the principle of single responsibility, delegating the creation of objects to specific classes.

Applications of the Factory Method Pattern

The Factory Method pattern is applied in the following circumstances:

• When a class does not know what type of objects it needs to create.
• I also want the object creation logic to be extensible in the future.
• By wanting classes to derive creation responsibility to subclasses to keep the code open to extensions, but closed to modifications (OCP principle).

Practical Example in TypeScript

Let's create a simple example of a Factory Method pattern to create different types of buttons (Button).

// Definition of the Product interface
interface Button {
 render(): void;
 onClick(): void;
}

// Specific implementations of the Product
class HTMLButton implements Button {
 render(): void {
 console.log('Rendering an HTML button.');
 }
 onClick(): void {
 console.log('Click on the HTML button.');
 }
}

class WindowsButton implements Button {
 render(): void {
 console.log('Rendering a Windows button.');
 }
 onClick(): void {
 console.log('Click on the Windows button.');
 }
}

// Definition of the Creator class
abstract class Dialog {
 abstract createButton(): Button;

 render(): void {
 const okButton = this.createButton();
 okButton.render();
 okButton.onClick();
 }
}


// Concrete implementations of the Creator
class HTMLDialog extends Dialog {
 createButton(): Button {
 return new HTMLButton();
 }
}

class WindowsDialog extends Dialog {
 createButton(): Button {
 return new WindowsButton();
 }
}

// Using the Factory Method pattern
const dialogType = 'HTML'; // You can switch to 'Windows'
let dialog: Dialog;

if (dialogType === 'HTML') {
 dialog = new HTMLDialog();
} else {
 dialog = new WindowsDialog();
}

dialog.render();

In this example:

• Button: Product interface that defines the methods that the buttons must implement.
• HTMLButton and WindowsButton: Concrete classes that implement the Button interface.
• Dialog: Abstract creator class that declares the factory createButton method and uses the product.
• HTMLDialog and WindowsDialog: Concrete subclasses of the creator that implement the createButton method to create the specific products.

Where can it be applied?

The Factory Method pattern is ideal for the following scenarios:

• User interfaces: Creation of different visual components depending on the environment or platform (buttons, menus, etc.).
• File systems: To handle different types of files (text, binaries, images) with a common interface.
• Database connections: Creation of different connections depending on the type of database (MySQL, PostgreSQL, SQLite).
• Notification systems: To send notifications to different channels (email, SMS, push) using a common interface.

The Decorator Design Pattern with TypeScript

The Decorator design pattern is a structural pattern that allows you to add additional functionality to an object dynamically. This pattern is a flexible alternative to inheritance because it allows you to combine behaviors and responsibilities at run time.

Why Use the Decorator Pattern?

The Decorator pattern is useful when:

• Responsibilities need to be added to objects dynamically and transparently without affecting other objects.
• You want to avoid an explosion of subclasses to handle various combinations of behaviors.
• We want to adhere to the principle of single responsibility, keeping the main classes free of additional functionality.

Applications of the Decorator Pattern

The Decorator pattern is applied in the following circumstances:

• When it is required to extend the functionality of classes dynamically without modifying their basic structure.
• To add, remove or modify behaviors at run time.
• When needing to apply various functionalities independently and combined to objects.

Practical Example in TypeScript

// Component interface
interface Coffee {
 cost(): number;
 description(): string;
}

// Concrete implementation of the component
class SimpleCoffee implements Coffee {
 cost(): number {
 return 5;
 }

 description(): string {
 return 'Plain coffee';
 }
}

// Abstract decorator class that implements the component interface
abstract class CoffeeDecorator implements Coffee {
 protected decoratedCoffee: Coffee;

 constructor(coffee: Coffee) {
 this.decoratedCoffee = coffee;
 }

 cost(): number {
 return this.decoratedCoffee.cost();
 }

 description(): string {
 return this.decoratedCoffee.description();
 }
}

// Concrete decorator to add milk
class MilkDecorator extends CoffeeDecorator {
 constructor(coffee: Coffee) {
 super(coffee);
 }

 cost(): number {
 return this.decoratedCoffee.cost() + 2;
 }

 description(): string {
 return `${this.decoratedCoffee.description()}, with milk`;
 }
}

// Concrete decorator to add sugar
class SugarDecorator extends CoffeeDecorator {
 constructor(coffee: Coffee) {
 super(coffee);
 }

 cost(): number {
 return this.decoratedCoffee.cost() + 1;
 }

 description(): string {
 return `${this.decoratedCoffee.description()}, with sugar`;
 }
}

// Using the Decorator pattern
let myCoffee: Coffee = new SimpleCoffee();
console.log(`${myCoffee.description()} costs $${myCoffee.cost()}`);

myCoffee = new MilkDecorator(myCoffee);
console.log(`${myCoffee.description()} costs $${myCoffee.cost()}`);

myCoffee = new SugarDecorator(myCoffee);
console.log(`${myCoffee.description()} costs $${myCoffee.cost()}`);

In this example:

• Coffee: Component interface that defines the cost and description methods.
• SimpleCoffee: Concrete implementation of the component.
• CoffeeDecorator: Abstract decorator class that implements the Coffee interface and delegates the work to a Coffee object.
• MilkDecorator and SugarDecorator: Concrete decorators that extend the functionality of SimpleCoffee by adding milk and sugar respectively.

Where can it be applied?

The Decorator pattern is ideal for the following scenarios:

• User interfaces: Add functionalities to graphic components dynamically (such as adding scroll, borders, etc.).
• Notification systems: Decorate basic notifications with additional delivery methods (e.g. email, SMS, push).
• I/O Streams: Add functionality to input/output streams (such as compression, encryption, buffering).
• Middleware on servers: Add processing layers to HTTP requests (such as authentication, logging, caching).

The Adapter Design Pattern with TypeScript

The Adapter design pattern is a structural pattern that allows two incompatible interfaces to work together. It is particularly useful when you need to integrate existing code with a new interface without modifying the original classes. This pattern acts as a bridge between two interfaces, allowing them to collaborate seamlessly.

Why Use the Adapter Pattern?

The Adapter pattern is useful when:

• You need to use an existing class, but its interface does not match the one required.
• You want to reuse old code in a new system without altering its original code.
• An interface from an external library or API needs to be adapted to the interface that is being used in the project.

Applications of the Adapter Pattern

The Adapter pattern is applied in the following circumstances:

• Integration of legacy systems with new systems.
• Adaptation of third-party interfaces (APIs, libraries) to match the interface required in your application.
• Migration of applications where you want to keep the old code without modifying it, but use it with new interfaces.

Practical Example in TypeScript

Let's create a simple example of an Adapter pattern to adapt an OldPrinter interface to a new Printer interface.

// Interface expected by the client
interface Printer {
 print(document: string): void;
}

// Existing implementation (old class) that we cannot change
class OldPrinter {
 oldPrint(doc: string): void {
 console.log(`Printing document in old format: ${doc}`);
 }
}

// Adapter that adapts OldPrinter to the Printer interface
class PrinterAdapter implements Printer {
 private oldPrinter: OldPrinter;

 constructor(oldPrinter: OldPrinter) {
 this.oldPrinter = oldPrinter;
 }

 print(document: string): void {
 this.oldPrinter.oldPrint(document);
 }
}

// Using the Adapter pattern
const oldPrinter = new OldPrinter();
const printer: Printer = new PrinterAdapter(oldPrinter);

printer.print("This is an adapted document.");

In this example:

• Printer: The interface expected by the client.
• OldPrinter: The existing class with an incompatible interface that we cannot change.
• PrinterAdapter: The adapter class that implements the Printer interface and translates calls to OldPrinter.

Where can it be applied?

The Adapter pattern is ideal for the following scenarios:

• Integration of legacy systems: Adapt old classes and components to work with new interfaces without changing the original code.
• Use of third-party libraries and APIs: Adapt the interfaces of external libraries and APIs to match the interface needed in your application.
• Migrations and refactorings: Adapt old components to new architectures or standards without modifying the old code.irectly.

Conclusion on Using Design Patterns

square_stars_backgroundsquare_stars_background

Design patterns are proven, reusable solutions to common problems in software development. Using design patterns in TypeScript provides multiple advantages, such as:

1. Code Reuse: Design patterns allow you to reuse already tried and tested solutions, which reduces development time and improves software quality.
2. Maintainability: They facilitate the structure and organization of the code, making it easier to understand and maintain
3. Flexibility and Extensibility: They help design systems that can evolve and adapt to new requirements without major code rewrites.
4. Decoupling: They promote the separation of responsibilities, which reduces interdependence between components and improves the modularity of the system.
5. Communicability: Patterns provide a common vocabulary for developers, facilitating communication and collaboration within the team.

Applicable Design Patterns in TypeScript

Here are some of the most relevant design patterns and how they can be applied in TypeScript:

Creational Patterns

1. Factory Method: Defines an interface for creating objects in a superclass, but allows subclasses to alter the type of objects to be created.
2. Abstract Factory: Provides an interface to create families of related or dependent objects without specifying their concrete classes.
3. Builder: Separates the construction of a complex object from its representation, allowing the same construction process to create different representations.
4. Prototype: Allows you to create new objects by cloning an existing instance.
5. Singleton: Ensures that a class has a single instance and provides a global access point to it.

Structural Patterns

1. Adapter: Allows classes with incompatible interfaces to work together by adapting an interface from one class to another.
2. Bridge: Decouples an abstraction from its implementation, allowing both to evolve independently.
3. Composite: Allows objects to be composed into tree structures to represent part-whole hierarchies.
4. Decorator: Add additional responsibilities to an object dynamically.
5. Facade: Provides a simplified interface to a complex system.
6. Flyweight: Reduces the cost of creating and using large quantities of objects through the use of sharing.
7. Proxy: Provides a substitute or placeholder for controlling access to an object.

Behavioral Patterns

1. Chain of Responsibility: Allows several objects to handle a request, avoiding coupling between the sender and the receiver.
2. Command: Encapsulates a request as an object, allowing clients to be parameterized with different requests, queuing requests or registering requests.
3. Interpreter: Provides a way to evaluate sentences in a language.
4. Iterator: Provides a way to sequentially access the elements of an aggregate object without exposing its underlying representation.
5. Mediator: Defines an object that encapsulates how a set of objects interact.
6. Memento: Allows you to capture and externalize the internal state of an object so that the object can be restored to this state later.
7. Observer: Defines a one-to-many dependency between objects, so that when an object changes state, all its dependents are notified.
8. State: Allows an object to alter its behavior when its internal state changes.
9. Strategy: Defines a family of algorithms, encapsulates each one and makes them interchangeable.
10. Template Method: Defines the skeleton of an algorithm in one operation, leaving some steps to subclasses.
11. Visitor: Allows you to define new operations on an object structure without changing the classes of the objects on which it operates.

These design patterns, when applied correctly, can lead to the creation of more robust, flexible, and maintainable software. By learning and applying these patterns, developers can tackle complex problems in a structured and efficient way, improving the overall quality of their TypeScript projects.

Using TypeScript: Best Practices and Patterns

Gustavo Gutiérrez