Home Blog Page 32

Applying SOLID Principles with TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • What are the SOLID Principles?
  • S – Single Responsibility Principle (SRP)
  • O – Open/Closed Principle (OCP)
  • L – Liskov Substitution Principle (LSP)
  • I – Interface Segregation Principle (ISP)
  • D – Dependency Inversion Principle (DIP)
  • Best Practices for SOLID in TypeScript
  • Conclusion

Introduction

SOLID is an acronym for five principles of object-oriented programming and design. Originally popularized by Robert C. Martin (Uncle Bob), these principles help developers create systems that are easier to maintain, scale, and refactor.

In TypeScript, which supports object-oriented paradigms alongside functional programming features, applying SOLID principles can significantly enhance the quality of your codebase.


What are the SOLID Principles?

  • S: Single Responsibility Principle (SRP)
  • O: Open/Closed Principle (OCP)
  • L: Liskov Substitution Principle (LSP)
  • I: Interface Segregation Principle (ISP)
  • D: Dependency Inversion Principle (DIP)

Let’s dive into each with examples in TypeScript.


S – Single Responsibility Principle (SRP)

Definition:
A class should have one, and only one, reason to change.

Each module/class should focus on a single part of the functionality provided by the software.

Example (Violating SRP):

class UserService {
createUser() {
// logic to create a user
}

sendEmail() {
// logic to send an email
}
}

The UserService is handling both user creation and sending emails — two different responsibilities.

Correct Application:

class UserService {
createUser() {
// logic to create a user
}
}

class EmailService {
sendEmail() {
// logic to send an email
}
}

Now, each class has a single responsibility.


O – Open/Closed Principle (OCP)

Definition:
Software entities should be open for extension but closed for modification.

You should be able to add new behavior without altering existing code.

Example (Violating OCP):

class PaymentProcessor {
processPayment(type: string) {
if (type === 'credit') {
// process credit card
} else if (type === 'paypal') {
// process PayPal
}
}
}

Adding new payment types requires modifying PaymentProcessor, risking breaking existing logic.

Correct Application with OCP:

interface PaymentMethod {
pay(amount: number): void;
}

class CreditCardPayment implements PaymentMethod {
pay(amount: number) {
console.log(`Paid ${amount} using Credit Card`);
}
}

class PaypalPayment implements PaymentMethod {
pay(amount: number) {
console.log(`Paid ${amount} using PayPal`);
}
}

class PaymentProcessor {
constructor(private paymentMethod: PaymentMethod) {}

processPayment(amount: number) {
this.paymentMethod.pay(amount);
}
}

Adding new payment methods now only requires creating new classes implementing PaymentMethod, without modifying PaymentProcessor.


L – Liskov Substitution Principle (LSP)

Definition:
Subtypes must be substitutable for their base types without altering the correctness of the program.

Example (Violating LSP):

class Bird {
fly() {
console.log('Flying');
}
}

class Ostrich extends Bird {
fly() {
throw new Error('Ostriches cannot fly!');
}
}

An Ostrich is a Bird but cannot fly. Using an Ostrich where a Bird is expected would break the program.

Correct Application:

Separate behaviors:

abstract class Bird {
abstract move(): void;
}

class FlyingBird extends Bird {
move() {
console.log('Flying');
}
}

class WalkingBird extends Bird {
move() {
console.log('Walking');
}
}

class Ostrich extends WalkingBird {}

class Sparrow extends FlyingBird {}

Now, birds behave correctly according to their abilities.


I – Interface Segregation Principle (ISP)

Definition:
Clients should not be forced to depend on interfaces they do not use.

Example (Violating ISP):

interface Worker {
work(): void;
eat(): void;
}

class Robot implements Worker {
work() {
console.log('Robot working');
}

eat() {
throw new Error('Robots do not eat');
}
}

The Robot class is forced to implement eat(), which makes no sense.

Correct Application:

Split interfaces:

interface Workable {
work(): void;
}

interface Eatable {
eat(): void;
}

class Human implements Workable, Eatable {
work() {
console.log('Human working');
}

eat() {
console.log('Human eating');
}
}

class Robot implements Workable {
work() {
console.log('Robot working');
}
}

Each class only implements the methods it actually uses.


D – Dependency Inversion Principle (DIP)

Definition:
High-level modules should not depend on low-level modules. Both should depend on abstractions.

Example (Violating DIP):

class MySQLDatabase {
connect() {
console.log('Connecting to MySQL');
}
}

class UserService {
database = new MySQLDatabase();

createUser() {
this.database.connect();
console.log('User created');
}
}

UserService is tightly coupled to MySQLDatabase.

Correct Application:

Use an abstraction:

interface Database {
connect(): void;
}

class MySQLDatabase implements Database {
connect() {
console.log('Connecting to MySQL');
}
}

class PostgresDatabase implements Database {
connect() {
console.log('Connecting to PostgreSQL');
}
}

class UserService {
constructor(private database: Database) {}

createUser() {
this.database.connect();
console.log('User created');
}
}

// Now you can inject any database implementation
const mysqlDb = new MySQLDatabase();
const userService = new UserService(mysqlDb);
userService.createUser();

Now UserService depends on an abstraction (Database), not a specific implementation.


Best Practices for SOLID in TypeScript

  • Use Interfaces and Abstract Classes: Promote abstraction to decouple high-level and low-level modules.
  • Apply Constructor Injection: Inject dependencies through constructors for better flexibility and testability.
  • Avoid God Classes: Keep classes focused on single tasks.
  • Think Extensibility: Design classes in a way that extending functionality doesn’t mean modifying existing code.
  • Write Unit Tests: SOLID principles naturally lead to code that is easier to test.

Conclusion

Applying the SOLID principles in TypeScript can greatly improve your code’s readability, maintainability, scalability, and testability.

Although following these principles might require a little extra thought and design effort upfront, they pay off immensely in the long run by reducing bugs, simplifying feature additions, and easing team collaboration.

By mastering SOLID in TypeScript, you build software that truly embraces good software engineering practices.

Dependency Injection (DI) in TypeScript Projects

0
typscript course
typscript course

Table of Contents

  • Introduction
  • What is Dependency Injection (DI)?
  • Benefits of Dependency Injection
  • Types of Dependency Injection
  • Implementing DI in TypeScript
    • Manual DI
    • Using DI Frameworks (InversifyJS)
  • Best Practices for Dependency Injection in TypeScript
  • Conclusion

Introduction

Dependency Injection (DI) is a software design pattern that promotes loose coupling in applications. It involves passing dependencies (i.e., the components or services that a class needs) into a class rather than hard-coding them inside the class. This design pattern is particularly beneficial for improving testability, maintainability, and flexibility in applications.

In TypeScript, DI can be implemented manually or by using a DI framework like InversifyJS. This article will explore DI concepts, its benefits, and how you can apply it in a TypeScript-based project.


What is Dependency Injection (DI)?

Dependency Injection is the process of providing a class or module with the objects it needs (its dependencies) rather than letting it construct them internally. DI helps decouple components, making it easier to manage their interactions.

Instead of a class creating instances of the services or objects it depends on, the dependencies are provided externally. This leads to better code organization and makes it easier to swap out components, mock dependencies for unit testing, or reconfigure parts of the application.

Example Without DI:

Without DI, a class may create its own dependencies:

class UserService {
private userRepository: UserRepository;

constructor() {
// Directly creating the dependency
this.userRepository = new UserRepository();
}

getUserById(id: number) {
return this.userRepository.findById(id);
}
}

In this case, the UserService is tightly coupled with the UserRepository, which makes it difficult to test or change the repository implementation without modifying the UserService class.


Benefits of Dependency Injection

  1. Loose Coupling: DI helps decouple components by ensuring that classes do not create their own dependencies. This makes the application easier to maintain and extend.
  2. Testability: DI allows for the injection of mock or stubbed dependencies, making it easier to write unit tests for individual components.
  3. Flexibility: With DI, you can easily swap implementations of dependencies. For example, you can replace a repository implementation with another without changing the dependent classes.
  4. Improved Code Organization: DI forces you to organize your classes more effectively, ensuring that each class focuses on a single responsibility and delegates others to their dependencies.

Types of Dependency Injection

There are several types of Dependency Injection, depending on how and where the dependencies are injected:

  1. Constructor Injection: Dependencies are provided through the class constructor. This is the most common form of DI in TypeScript. class UserService { constructor(private userRepository: UserRepository) {} getUserById(id: number) { return this.userRepository.findById(id); } }
  2. Setter Injection: Dependencies are provided through setter methods after the object has been constructed. class UserService { private userRepository: UserRepository; setUserRepository(userRepository: UserRepository) { this.userRepository = userRepository; } getUserById(id: number) { return this.userRepository.findById(id); } }
  3. Interface Injection: The class provides an injector method that allows the dependency to be injected through an interface.
    • This is less common in TypeScript, as it typically requires additional design patterns.

Implementing DI in TypeScript

Manual Dependency Injection

One simple approach to DI is to manually pass dependencies to classes. This method gives full control but can become cumbersome as the application grows.

Example:

class UserRepository {
findById(id: number) {
// Simulating a database lookup
return { id, name: 'John Doe' };
}
}

class UserService {
constructor(private userRepository: UserRepository) {}

getUserById(id: number) {
return this.userRepository.findById(id);
}
}

// Create dependencies manually and inject them
const userRepository = new UserRepository();
const userService = new UserService(userRepository);

const user = userService.getUserById(1);
console.log(user);

In this approach, you manually create instances of the UserRepository and inject them into the UserService constructor.

While this approach is simple and works well for smaller applications, it can become difficult to manage as the application grows, especially if there are many classes and dependencies to manage.


Using DI Frameworks (InversifyJS)

In larger TypeScript applications, using a Dependency Injection framework can help manage and automate the injection of dependencies. One popular DI framework for TypeScript is InversifyJS.

Installing InversifyJS

First, install the necessary dependencies:

npm install inversify reflect-metadata

You will also need to enable decorators in your TypeScript configuration (tsconfig.json):

{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

Basic Setup with InversifyJS

Step 1: Define interfaces for dependencies.

// interfaces/UserRepository.ts
export interface IUserRepository {
findById(id: number): { id: number; name: string };
}

Step 2: Implement the interfaces in concrete classes.

// repositories/UserRepository.ts
import { IUserRepository } from '../interfaces/UserRepository';

export class UserRepository implements IUserRepository {
findById(id: number) {
return { id, name: 'John Doe' }; // Simulating DB call
}
}

Step 3: Create services that depend on repositories.

// services/UserService.ts
import { injectable, inject } from 'inversify';
import { IUserRepository } from '../interfaces/UserRepository';

@injectable()
export class UserService {
constructor(@inject('IUserRepository') private userRepository: IUserRepository) {}

getUserById(id: number) {
return this.userRepository.findById(id);
}
}

Step 4: Configure the container.

// inversify.config.ts
import { Container } from 'inversify';
import { IUserRepository } from './interfaces/UserRepository';
import { UserRepository } from './repositories/UserRepository';
import { UserService } from './services/UserService';

const container = new Container();
container.bind<IUserRepository>('IUserRepository').to(UserRepository);
container.bind<UserService>(UserService).toSelf();

export { container };

Step 5: Resolve dependencies.

// app.ts
import 'reflect-metadata';
import { container } from './inversify.config';
import { UserService } from './services/UserService';

const userService = container.get(UserService);
const user = userService.getUserById(1);
console.log(user);

Here, InversifyJS is used to automatically manage dependency injection. You can inject the UserRepository into the UserService through the DI container.


Best Practices for Dependency Injection in TypeScript

  1. Use Constructor Injection: Constructor injection is the most common and recommended method. It ensures that dependencies are provided upfront and makes the class immutable.
  2. Avoid Overusing DI: DI is powerful, but it should not be overused. Use it when it provides clear benefits like decoupling and testing, but don’t use it for trivial classes.
  3. Favor Interfaces Over Concrete Classes: When possible, inject interfaces rather than concrete implementations. This makes the application more flexible and allows for easier swapping of implementations.
  4. Leverage DI Containers: In complex applications, use a DI container (e.g., InversifyJS) to manage object creation and dependency resolution automatically. This is especially useful as the number of services and dependencies grows.
  5. Keep the DI Container Centralized: Keep all DI container configurations in a central location (e.g., inversify.config.ts) to manage dependency mappings and ensure consistency.

Conclusion

Dependency Injection is a valuable design pattern for building maintainable, testable, and flexible TypeScript applications. Whether you manually manage DI or use a framework like InversifyJS, DI helps decouple components, improve testability, and organize your code.

In larger TypeScript projects, adopting a DI framework can make it much easier to manage complex dependency relationships and automate object creation. For smaller applications, manual DI can still be effective while keeping the code simple and clean.

By following the best practices mentioned in this article, you can ensure that your TypeScript projects remain scalable and easy to maintain.

Advanced Patterns: Repository, Service, Controller Layers in TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • Why Use Layered Architecture?
  • Layer 1: The Repository Layer
    • What is the Repository Pattern?
    • Repository Layer in TypeScript
    • Example of Repository Pattern
  • Layer 2: The Service Layer
    • What is the Service Layer?
    • Service Layer in TypeScript
    • Example of Service Layer
  • Layer 3: The Controller Layer
    • What is the Controller Layer?
    • Controller Layer in TypeScript
    • Example of Controller Layer
  • Benefits of Using Layered Architecture
  • Conclusion

Introduction

In modern software development, using layered architecture is a common design pattern that helps improve the organization, scalability, and maintainability of applications. A typical layered architecture consists of three main layers: the Repository, the Service, and the Controller.

  • Repository Layer: Manages the persistence logic (e.g., interaction with the database).
  • Service Layer: Contains the business logic, interacting with the Repository and performing calculations, transformations, etc.
  • Controller Layer: Handles HTTP requests and responses, mapping requests to services and formatting the output.

In this guide, we’ll dive into these patterns and show how to implement them using TypeScript in a Node.js-based application (for example, using Express).


Why Use Layered Architecture?

Layered architecture helps in organizing code into distinct layers, each with its own responsibilities. The primary benefits are:

  1. Separation of Concerns: Each layer is responsible for a distinct concern (e.g., database interaction, business logic, HTTP handling).
  2. Reusability: By encapsulating functionality in specific layers (like the Repository or Service), you can reuse them across different parts of the application.
  3. Testability: Individual layers are easier to test because they are independent and have clear responsibilities.
  4. Maintainability: Changes to one layer (e.g., switching the database or modifying business logic) are less likely to affect other layers.

Layer 1: The Repository Layer

What is the Repository Pattern?

The Repository Pattern is a way of abstracting data access logic. It acts as a bridge between the Service Layer and the underlying data source (e.g., a database). The Repository provides an interface to query and manipulate data, isolating the rest of the application from the specific details of data storage.

Repository Layer in TypeScript

In TypeScript, we define a Repository as a class that encapsulates methods for interacting with the database or data source. This layer is often generic, so it can work with multiple entities.

Example of Repository Pattern

import { User } from './models/User'; // Example User model
import { Repository } from 'typeorm'; // Assuming using TypeORM as ORM
import { getRepository } from 'typeorm';

// Repository for User entity
export class UserRepository {
private repository: Repository<User>;

constructor() {
this.repository = getRepository(User);
}

async findById(id: number): Promise<User | undefined> {
return await this.repository.findOne(id);
}

async create(user: User): Promise<User> {
return await this.repository.save(user);
}

async findAll(): Promise<User[]> {
return await this.repository.find();
}

async update(id: number, user: Partial<User>): Promise<User | undefined> {
await this.repository.update(id, user);
return this.findById(id);
}

async delete(id: number): Promise<void> {
await this.repository.delete(id);
}
}

Here, the UserRepository class handles all database-related logic for the User entity. This helps decouple the rest of the application from the database details.


Layer 2: The Service Layer

What is the Service Layer?

The Service Layer contains the business logic of your application. It communicates with the Repository layer to fetch, modify, or persist data, and it applies business rules or transformations as needed.

The Service layer is responsible for organizing and processing data before sending it to the controller or formatting it for the client.

Service Layer in TypeScript

The Service layer typically acts as an intermediary between the controller and repository, ensuring that business logic is abstracted and reusable.

Example of Service Layer

import { UserRepository } from './repositories/UserRepository';
import { User } from './models/User';

export class UserService {
private userRepository: UserRepository;

constructor() {
this.userRepository = new UserRepository();
}

async getUserById(id: number): Promise<User | undefined> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}

async createUser(userData: User): Promise<User> {
// Apply business rules here
const user = await this.userRepository.create(userData);
return user;
}

async updateUser(id: number, userData: Partial<User>): Promise<User | undefined> {
const user = await this.userRepository.update(id, userData);
return user;
}

async deleteUser(id: number): Promise<void> {
await this.userRepository.delete(id);
}
}

Here, the UserService handles the business logic, such as applying rules and transformations, and delegates data access to the UserRepository.


Layer 3: The Controller Layer

What is the Controller Layer?

The Controller Layer is responsible for handling HTTP requests and mapping them to the appropriate service methods. It formats the request and response objects, processes query parameters, handles routing, and passes data between the client and server.

The Controller layer does not contain business logic or database interaction; it acts as a middleman between the client and the business logic.

Controller Layer in TypeScript

The Controller layer in TypeScript can be implemented using frameworks like Express. The controller functions are typically asynchronous, handling requests and responses.

Example of Controller Layer

import { Request, Response } from 'express';
import { UserService } from './services/UserService';

export class UserController {
private userService: UserService;

constructor() {
this.userService = new UserService();
}

async getUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id, 10);
try {
const user = await this.userService.getUserById(userId);
res.status(200).json(user);
} catch (error) {
res.status(404).json({ error: error.message });
}
}

async createUser(req: Request, res: Response): Promise<void> {
try {
const userData = req.body;
const user = await this.userService.createUser(userData);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
}

async updateUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id, 10);
const userData = req.body;
try {
const updatedUser = await this.userService.updateUser(userId, userData);
res.status(200).json(updatedUser);
} catch (error) {
res.status(404).json({ error: error.message });
}
}

async deleteUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id, 10);
try {
await this.userService.deleteUser(userId);
res.status(204).end();
} catch (error) {
res.status(404).json({ error: error.message });
}
}
}

In the UserController class:

  • HTTP requests are mapped to service methods (e.g., getUserById, createUser).
  • HTTP status codes and responses are formatted before sending back to the client.

Benefits of Using Layered Architecture

  1. Separation of Concerns: Each layer is responsible for a specific part of the application, making the code more modular and easier to maintain.
  2. Testability: Each layer can be tested independently. For example, you can test the repository methods with mock data, the service methods with the repository mocked, and the controllers using integration tests.
  3. Scalability: As your application grows, the layered architecture allows you to scale the codebase by introducing new layers or modifying existing ones without breaking the entire system.
  4. Reusability: Code is more reusable across different parts of the application or even other projects. For instance, the service layer can be used in multiple controllers.
  5. Maintainability: Changes to one layer (e.g., switching from MongoDB to PostgreSQL in the repository layer) don’t affect the rest of the system significantly.

Conclusion

Layered architecture is a powerful design pattern that helps in organizing complex applications. By using the Repository, Service, and Controller layers in TypeScript, you can create maintainable, scalable, and easily testable code.

  • Repository: Manages data access and persistence.
  • Service: Contains business logic and interacts with the repository.
  • Controller: Handles HTTP requests and maps them to services.

This pattern can be used to build scalable applications with TypeScript, especially when combined with frameworks like Express and TypeORM for ORM-based database access.

By adhering to these design principles, you’ll be able to build applications that are easier to maintain, test, and extend.

Connecting a Frontend Framework (React) with TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • Prerequisites
  • Step 1: Set Up a New React Project with TypeScript
  • Step 2: Install Required Dependencies
  • Step 3: Set Up Folder Structure
  • Step 4: Create Functional Components with TypeScript
  • Step 5: Props and State in TypeScript
  • Step 6: Handling Events with TypeScript in React
  • Step 7: Working with Context API and Redux (Optional)
  • Step 8: TypeScript Types for React Router
  • Step 9: Type Safety in useEffect and useState Hooks
  • Step 10: Conclusion

Introduction

React, a popular JavaScript library for building user interfaces, works seamlessly with TypeScript. TypeScript adds type safety to React components, making development more robust, easier to maintain, and less error-prone. By combining React and TypeScript, developers can enhance their workflow, improve code readability, and catch potential bugs during the development phase.

In this guide, we will show how to set up and work with React and TypeScript, step-by-step, covering various aspects such as component creation, state management, event handling, and more.


Prerequisites

Before getting started, ensure you have the following tools installed:

  1. Node.js: Install from Node.js website.
  2. npm (Node Package Manager): This comes with Node.js and is used to install dependencies.

You should also have some familiarity with the basics of React and TypeScript.


Step 1: Set Up a New React Project with TypeScript

The first step is to create a new React project using TypeScript. You can easily set up a React and TypeScript project using Create React App:

npx create-react-app my-react-ts-app --template typescript

This will set up a new React application pre-configured with TypeScript.

Once the installation is complete, navigate to your project directory:

cd my-react-ts-app

Step 2: Install Required Dependencies

You should already have most of the required dependencies installed by Create React App, but here are some additional packages you may need:

  • React Router for handling routes (if building a multi-page app).
  • Redux or Context API for state management.

For React Router:

npm install react-router-dom
npm install @types/react-router-dom --save-dev

For Redux (if needed for state management):

npm install react-redux @reduxjs/toolkit
npm install @types/react-redux --save-dev

Step 3: Set Up Folder Structure

Create the basic folder structure for better organization. In a large project, it’s a good practice to organize your project into components, assets, and other necessary directories.

Example folder structure:

src/
├── assets/
├── components/
├── pages/
├── services/
├── App.tsx
└── index.tsx

This will help in keeping the project modular and scalable as it grows.


Step 4: Create Functional Components with TypeScript

React components can be written as functional components with TypeScript. Here’s an example of creating a simple Button component:

src/components/Button.tsx:

import React from 'react';

// Define type for props
type ButtonProps = {
label: string;
onClick: () => void;
};

const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
return (
<button onClick={onClick}>{label}</button>
);
};

export default Button;

In this example:

  • ButtonProps defines the expected properties (label and onClick).
  • React.FC (Function Component) automatically infers children and enforces the type safety for props.

Step 5: Props and State in TypeScript

To handle props and state in TypeScript, you can explicitly define types for the state and props for your components.

Example with State:

src/components/Counter.tsx:

import React, { useState } from 'react';

// Define type for state
type CounterState = {
count: number;
};

const Counter: React.FC = () => {
const [state, setState] = useState<CounterState>({ count: 0 });

const increment = () => setState({ count: state.count + 1 });
const decrement = () => setState({ count: state.count - 1 });

return (
<div>
<h1>{state.count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
};

export default Counter;

Here:

  • The state (count) is typed with CounterState.
  • TypeScript ensures that you only set the state object with a count property of type number.

Step 6: Handling Events with TypeScript in React

When dealing with events like onClick, onChange, or onSubmit, TypeScript can provide type safety. Below is an example of handling a form submission event.

Example:

import React, { useState } from 'react';

const Form: React.FC = () => {
const [name, setName] = useState<string>('');

const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
console.log('Submitted name:', name);
};

return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">Submit</button>
</form>
);
};

export default Form;

Here, React.FormEvent ensures that handleSubmit handles the event correctly with type safety.


Step 7: Working with Context API and Redux (Optional)

If your app needs to manage state globally, you can use React Context API or Redux. Below is a simple example of using the Context API for global state management.

Example (Context API):

  1. Create Context:
import React, { createContext, useState, useContext } from 'react';

interface ThemeContextType {
theme: string;
setTheme: (theme: string) => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC = ({ children }) => {
const [theme, setTheme] = useState<string>('light');

return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};

export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
  1. Using Context in a Component:
import React from 'react';
import { useTheme } from './ThemeContext';

const ThemeSwitcher: React.FC = () => {
const { theme, setTheme } = useTheme();

return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
};

export default ThemeSwitcher;

Step 8: TypeScript Types for React Router

When using React Router, you can add type definitions for route parameters and queries.

Example:

import React from 'react';
import { BrowserRouter as Router, Route, Switch, useParams } from 'react-router-dom';

interface UserParams {
userId: string;
}

const User: React.FC = () => {
const { userId } = useParams<UserParams>();
return <div>User ID: {userId}</div>;
};

const App: React.FC = () => {
return (
<Router>
<Switch>
<Route path="/user/:userId" component={User} />
</Switch>
</Router>
);
};

export default App;

In this example, useParams<UserParams> provides type safety for route parameters.


Step 9: Type Safety in useEffect and useState Hooks

TypeScript also adds type safety to useEffect and useState hooks.

Example (useEffect with Fetch):

import React, { useState, useEffect } from 'react';

interface Data {
id: number;
name: string;
}

const FetchData: React.FC = () => {
const [data, setData] = useState<Data | null>(null);

useEffect(() => {
const fetchData = async () => {
const response = await fetch('https://api.example.com/data');
const result: Data = await response.json();
setData(result);
};

fetchData();
}, []);

return (
<div>
{data ? <p>{data.name}</p> : <p>Loading...</p>}
</div>
);
};

export default FetchData;

Here, the Data interface ensures the fetched data matches the expected type.


Step 10: Conclusion

You’ve now learned how to connect a React frontend with TypeScript, including:

  • Setting up a React app with TypeScript.
  • Creating typed functional components with props and state.
  • Handling events and managing global state with Context API or Redux.
  • Ensuring type safety when working with React Router, useEffect, and useState.

By combining React and TypeScript, you can improve your code quality, catch errors early, and make your React applications more maintainable and scalable. With these techniques, you’re well-equipped to develop large-scale, type-safe React applications.

Building a REST API with Express and TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • Prerequisites
  • Step 1: Initialize a New Node.js Project
  • Step 2: Install Dependencies
  • Step 3: Setting Up TypeScript
  • Step 4: Setting Up Express
  • Step 5: Create Routes and Handlers
  • Step 6: Create Models and Controllers
  • Step 7: Connect to a Database (Optional)
  • Step 8: Set Up TypeScript Compilation
  • Step 9: Run the API
  • Conclusion

Introduction

Express.js is one of the most popular web frameworks for Node.js, and TypeScript offers static typing, which can improve the development experience and reduce errors. Combining Express with TypeScript allows developers to write maintainable, scalable, and type-safe applications.

In this guide, we’ll walk through building a simple REST API using Express and TypeScript. By the end, you will have a fully working API with basic CRUD (Create, Read, Update, Delete) operations.


Prerequisites

Ensure you have the following tools installed on your system:

  1. Node.js: You’ll need Node.js to run the backend application.
  2. npm: The Node Package Manager will be used to install dependencies.

To check if Node.js and npm are installed, run:

node -v
npm -v

If these tools are not installed, download and install Node.js from here.


Step 1: Initialize a New Node.js Project

Start by creating a new directory for your project and initializing it as a Node.js project:

mkdir express-ts-api
cd express-ts-api
npm init -y

This will generate a package.json file with default settings.


Step 2: Install Dependencies

Now, install the required dependencies for Express and TypeScript:

npm install express
npm install typescript @types/express @types/node --save-dev
  • express: The Express web framework.
  • typescript: TypeScript compiler.
  • @types/express: Type definitions for Express.
  • @types/node: Type definitions for Node.js (useful for Node’s built-in modules like fs, http, etc.).

Additionally, you can install ts-node and nodemon to run and watch TypeScript files in development:

npm install ts-node nodemon --save-dev
  • ts-node: Allows direct execution of TypeScript code.
  • nodemon: Automatically restarts the server when code changes.

Step 3: Setting Up TypeScript

Next, initialize TypeScript in your project by generating the tsconfig.json file:

npx tsc --init

The tsconfig.json file will be created in the root directory. Modify it for a Node.js and Express setup:

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

This configuration ensures that TypeScript compiles to modern JavaScript, targets ES6, and puts the output in the dist directory.


Step 4: Setting Up Express

Create the directory structure for your project:

mkdir src
touch src/index.ts

In src/index.ts, set up the basic Express application:

import express, { Request, Response } from 'express';

const app = express();
const port = 3000;

app.use(express.json()); // Middleware to parse JSON bodies

// Basic route
app.get('/', (req: Request, res: Response) => {
res.send('Hello, TypeScript and Express!');
});

// Start the server
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});

This code creates a simple Express app that responds to requests at the root (/) endpoint with a “Hello” message.


Step 5: Create Routes and Handlers

Let’s set up basic routes for the API. Create a routes folder in the src directory:

mkdir src/routes
touch src/routes/userRoutes.ts

In userRoutes.ts, define routes for a simple CRUD API:

import express, { Request, Response } from 'express';

const router = express.Router();

// Sample in-memory store for users
let users: { id: number, name: string }[] = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
];

// GET /users - Get all users
router.get('/users', (req: Request, res: Response) => {
res.json(users);
});

// GET /users/:id - Get a single user by ID
router.get('/users/:id', (req: Request, res: Response) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (user) {
res.json(user);
} else {
res.status(404).send('User not found');
}
});

// POST /users - Create a new user
router.post('/users', (req: Request, res: Response) => {
const newUser = {
id: users.length + 1,
name: req.body.name
};
users.push(newUser);
res.status(201).json(newUser);
});

// PUT /users/:id - Update a user by ID
router.put('/users/:id', (req: Request, res: Response) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (user) {
user.name = req.body.name;
res.json(user);
} else {
res.status(404).send('User not found');
}
});

// DELETE /users/:id - Delete a user by ID
router.delete('/users/:id', (req: Request, res: Response) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index !== -1) {
users.splice(index, 1);
res.status(204).send();
} else {
res.status(404).send('User not found');
}
});

export default router;

Now, import and use the routes in the main index.ts file:

import express from 'express';
import userRoutes from './routes/userRoutes';

const app = express();
const port = 3000;

app.use(express.json());
app.use('/api', userRoutes); // Prefix all routes with /api

app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});

Now you have CRUD operations for users available at /api/users.


Step 6: Create Models and Controllers

In more complex applications, it’s a good practice to separate models and controllers. For simplicity, we’ll continue to keep everything in a single file structure. However, you can create separate folders for models and controllers as your application grows.


Step 7: Connect to a Database (Optional)

To store data persistently, you can integrate a database like MongoDB or PostgreSQL. For MongoDB, you can use Mongoose as an ORM. For PostgreSQL, use Sequelize or TypeORM. Here’s an example of using MongoDB:

  1. Install MongoDB and Mongoose:
npm install mongoose
  1. Set up a connection in index.ts:
import mongoose from 'mongoose';

// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/express-ts-api', {
useNewUrlParser: true,
useUnifiedTopology: true,
});

Step 8: Set Up TypeScript Compilation

To compile your TypeScript code into JavaScript, add a build script in your package.json:

"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon -x ts-node src/index.ts"
}

This will allow you to run the project in development mode using npm run dev.


Step 9: Run the API

To run the API, use the following command:

npm run dev

This will start the server using ts-node and automatically restart it on file changes.


Conclusion

Congratulations! You’ve successfully built a REST API using Express and TypeScript. This setup gives you the benefits of static typing, which reduces runtime errors and improves code maintainability. You can now extend the API by adding more features, handling database interactions, implementing authentication, and much more.