Chapter 14: Real-World Projects
Authored by syscook.dev
What are Real-World TypeScript Projects?
Real-world TypeScript projects are practical applications that demonstrate the use of TypeScript in production environments. These projects showcase best practices, architectural patterns, and real-world challenges that developers face when building scalable, maintainable applications.
Key Project Types:
- Full-Stack Applications: Frontend and backend with TypeScript
- API Services: RESTful and GraphQL APIs
- Desktop Applications: Electron and native apps
- Mobile Applications: React Native and cross-platform
- Libraries and Packages: Reusable TypeScript libraries
- Microservices: Distributed systems with TypeScript
Why Build Real-World Projects?
1. Practical Experience
Real-world projects provide hands-on experience with TypeScript in production scenarios.
// Real-world example: E-commerce API
interface Product {
id: string;
name: string;
price: number;
category: string;
inventory: number;
createdAt: Date;
updatedAt: Date;
}
interface CreateProductRequest {
name: string;
price: number;
category: string;
inventory: number;
}
interface UpdateProductRequest extends Partial<CreateProductRequest> {
id: string;
}
class ProductService {
constructor(
private productRepository: ProductRepository,
private eventBus: EventBus
) {}
async createProduct(request: CreateProductRequest): Promise<Product> {
// Validation
if (request.price <= 0) {
throw new ValidationError('Price must be positive');
}
if (request.inventory < 0) {
throw new ValidationError('Inventory cannot be negative');
}
// Create product
const product = await this.productRepository.create({
...request,
id: generateId(),
createdAt: new Date(),
updatedAt: new Date()
});
// Emit event
await this.eventBus.emit('product.created', product);
return product;
}
async updateProduct(request: UpdateProductRequest): Promise<Product> {
const existingProduct = await this.productRepository.findById(request.id);
if (!existingProduct) {
throw new NotFoundError('Product not found');
}
const updatedProduct = await this.productRepository.update(request.id, {
...request,
updatedAt: new Date()
});
await this.eventBus.emit('product.updated', updatedProduct);
return updatedProduct;
}
}
2. Architecture Patterns
Real-world projects demonstrate proper architectural patterns and design principles.
// Clean Architecture example
// Domain Layer
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
}
// Application Layer
interface CreateUserCommand {
email: string;
name: string;
}
interface CreateUserResult {
success: boolean;
user?: User;
error?: string;
}
class CreateUserUseCase {
constructor(
private userRepository: UserRepository,
private emailService: EmailService
) {}
async execute(command: CreateUserCommand): Promise<CreateUserResult> {
try {
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(command.email);
if (existingUser) {
return {
success: false,
error: 'User with this email already exists'
};
}
// Create user
const user: User = {
id: generateId(),
email: command.email,
name: command.name,
createdAt: new Date()
};
const savedUser = await this.userRepository.save(user);
// Send welcome email
await this.emailService.sendWelcomeEmail(savedUser.email);
return {
success: true,
user: savedUser
};
} catch (error) {
return {
success: false,
error: 'Failed to create user'
};
}
}
}
// Infrastructure Layer
class DatabaseUserRepository implements UserRepository {
constructor(private db: Database) {}
async findById(id: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
return result.rows[0] || null;
}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE email = ?', [email]);
return result.rows[0] || null;
}
async save(user: User): Promise<User> {
await this.db.query(
'INSERT INTO users (id, email, name, created_at) VALUES (?, ?, ?, ?)',
[user.id, user.email, user.name, user.createdAt]
);
return user;
}
}
How to Build Real-World Projects?
1. Project 1: Task Management API
Project Structure
task-management-api/
├── src/
│ ├── domain/
│ │ ├── entities/
│ │ │ ├── Task.ts
│ │ │ └── User.ts
│ │ ├── repositories/
│ │ │ ├── TaskRepository.ts
│ │ │ └── UserRepository.ts
│ │ └── services/
│ │ ├── TaskService.ts
│ │ └── UserService.ts
│ ├── infrastructure/
│ │ ├── database/
│ │ │ ├── Database.ts
│ │ │ └── migrations/
│ │ ├── repositories/
│ │ │ ├── DatabaseTaskRepository.ts
│ │ │ └── DatabaseUserRepository.ts
│ │ └── external/
│ │ └── EmailService.ts
│ ├── application/
│ │ ├── use-cases/
│ │ │ ├── CreateTaskUseCase.ts
│ │ │ ├── UpdateTaskUseCase.ts
│ │ │ └── DeleteTaskUseCase.ts
│ │ └── dto/
│ │ ├── CreateTaskDTO.ts
│ │ └── UpdateTaskDTO.ts
│ ├── presentation/
│ │ ├── controllers/
│ │ │ └── TaskController.ts
│ │ ├── middleware/
│ │ │ ├── AuthMiddleware.ts
│ │ │ └── ValidationMiddleware.ts
│ │ └── routes/
│ │ └── taskRoutes.ts
│ └── main.ts
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── package.json
├── tsconfig.json
└── README.md
Core Implementation
// src/domain/entities/Task.ts
export interface Task {
id: string;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
dueDate?: Date;
userId: string;
createdAt: Date;
updatedAt: Date;
}
export enum TaskStatus {
TODO = 'todo',
IN_PROGRESS = 'in_progress',
DONE = 'done',
CANCELLED = 'cancelled'
}
export enum TaskPriority {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
URGENT = 'urgent'
}
// src/domain/repositories/TaskRepository.ts
export interface TaskRepository {
findById(id: string): Promise<Task | null>;
findByUserId(userId: string): Promise<Task[]>;
save(task: Task): Promise<Task>;
update(id: string, updates: Partial<Task>): Promise<Task>;
delete(id: string): Promise<void>;
}
// src/application/use-cases/CreateTaskUseCase.ts
export interface CreateTaskCommand {
title: string;
description?: string;
priority: TaskPriority;
dueDate?: Date;
userId: string;
}
export class CreateTaskUseCase {
constructor(
private taskRepository: TaskRepository,
private userRepository: UserRepository
) {}
async execute(command: CreateTaskCommand): Promise<Task> {
// Validate user exists
const user = await this.userRepository.findById(command.userId);
if (!user) {
throw new Error('User not found');
}
// Create task
const task: Task = {
id: generateId(),
title: command.title,
description: command.description,
status: TaskStatus.TODO,
priority: command.priority,
dueDate: command.dueDate,
userId: command.userId,
createdAt: new Date(),
updatedAt: new Date()
};
return await this.taskRepository.save(task);
}
}
// src/presentation/controllers/TaskController.ts
export class TaskController {
constructor(
private createTaskUseCase: CreateTaskUseCase,
private updateTaskUseCase: UpdateTaskUseCase,
private deleteTaskUseCase: DeleteTaskUseCase
) {}
async createTask(req: Request, res: Response): Promise<void> {
try {
const command: CreateTaskCommand = {
title: req.body.title,
description: req.body.description,
priority: req.body.priority,
dueDate: req.body.dueDate ? new Date(req.body.dueDate) : undefined,
userId: req.user.id
};
const task = await this.createTaskUseCase.execute(command);
res.status(201).json(task);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
async getTasks(req: Request, res: Response): Promise<void> {
try {
const tasks = await this.taskRepository.findByUserId(req.user.id);
res.json(tasks);
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
}
2. Project 2: React Dashboard with TypeScript
Project Structure
react-dashboard/
├── src/
│ ├── components/
│ │ ├── common/
│ │ │ ├── Button/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Button.module.css
│ │ │ │ └── index.ts
│ │ │ ├── Modal/
│ │ │ └── Table/
│ │ ├── layout/
│ │ │ ├── Header/
│ │ │ ├── Sidebar/
│ │ │ └── Layout.tsx
│ │ └── features/
│ │ ├── auth/
│ │ │ ├── LoginForm/
│ │ │ └── AuthProvider/
│ │ ├── dashboard/
│ │ │ ├── StatsCard/
│ │ │ ├── Chart/
│ │ │ └── Dashboard.tsx
│ │ └── users/
│ │ ├── UserList/
│ │ ├── UserForm/
│ │ └── UserDetails/
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ ├── useApi.ts
│ │ └── useLocalStorage.ts
│ ├── services/
│ │ ├── api.ts
│ │ ├── authService.ts
│ │ └── userService.ts
│ ├── types/
│ │ ├── auth.ts
│ │ ├── user.ts
│ │ └── api.ts
│ ├── utils/
│ │ ├── constants.ts
│ │ ├── helpers.ts
│ │ └── validation.ts
│ ├── App.tsx
│ └── main.tsx
├── public/
├── package.json
├── tsconfig.json
├── vite.config.ts
└── tailwind.config.js
Core Implementation
// src/types/user.ts
export interface User {
id: string;
email: string;
name: string;
role: UserRole;
avatar?: string;
createdAt: string;
updatedAt: string;
}
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
MODERATOR = 'moderator'
}
export interface CreateUserRequest {
email: string;
name: string;
role: UserRole;
password: string;
}
export interface UpdateUserRequest {
id: string;
email?: string;
name?: string;
role?: UserRole;
}
// src/services/userService.ts
export class UserService {
constructor(private apiClient: ApiClient) {}
async getUsers(): Promise<User[]> {
const response = await this.apiClient.get<User[]>('/users');
return response.data;
}
async getUserById(id: string): Promise<User> {
const response = await this.apiClient.get<User>(`/users/${id}`);
return response.data;
}
async createUser(userData: CreateUserRequest): Promise<User> {
const response = await this.apiClient.post<User>('/users', userData);
return response.data;
}
async updateUser(userData: UpdateUserRequest): Promise<User> {
const { id, ...updateData } = userData;
const response = await this.apiClient.put<User>(`/users/${id}`, updateData);
return response.data;
}
async deleteUser(id: string): Promise<void> {
await this.apiClient.delete(`/users/${id}`);
}
}
// src/hooks/useUsers.ts
export function useUsers() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const userService = new UserService(apiClient);
const usersData = await userService.getUsers();
setUsers(usersData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch users');
} finally {
setLoading(false);
}
}, []);
const createUser = useCallback(async (userData: CreateUserRequest) => {
try {
const userService = new UserService(apiClient);
const newUser = await userService.createUser(userData);
setUsers(prev => [...prev, newUser]);
return newUser;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user');
throw err;
}
}, []);
const updateUser = useCallback(async (userData: UpdateUserRequest) => {
try {
const userService = new UserService(apiClient);
const updatedUser = await userService.updateUser(userData);
setUsers(prev => prev.map(user =>
user.id === updatedUser.id ? updatedUser : user
));
return updatedUser;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
throw err;
}
}, []);
const deleteUser = useCallback(async (id: string) => {
try {
const userService = new UserService(apiClient);
await userService.deleteUser(id);
setUsers(prev => prev.filter(user => user.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
throw err;
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return {
users,
loading,
error,
fetchUsers,
createUser,
updateUser,
deleteUser
};
}
// src/components/features/users/UserList.tsx
interface UserListProps {
onUserSelect: (user: User) => void;
onUserEdit: (user: User) => void;
onUserDelete: (user: User) => void;
}
export function UserList({ onUserSelect, onUserEdit, onUserDelete }: UserListProps) {
const { users, loading, error, deleteUser } = useUsers();
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const handleUserSelect = (user: User) => {
setSelectedUser(user);
onUserSelect(user);
};
const handleUserEdit = (user: User) => {
onUserEdit(user);
};
const handleUserDelete = async (user: User) => {
if (window.confirm(`Are you sure you want to delete ${user.name}?`)) {
try {
await deleteUser(user.id);
onUserDelete(user);
} catch (error) {
console.error('Failed to delete user:', error);
}
}
};
if (loading) {
return <div className="flex justify-center p-8">Loading...</div>;
}
if (error) {
return <div className="text-red-500 p-4">Error: {error}</div>;
}
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Users
</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr
key={user.id}
className={`hover:bg-gray-50 cursor-pointer ${
selectedUser?.id === user.id ? 'bg-blue-50' : ''
}`}
onClick={() => handleUserSelect(user)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<img
className="h-10 w-10 rounded-full"
src={user.avatar || '/default-avatar.png'}
alt={user.name}
/>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.name}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.role === UserRole.ADMIN
? 'bg-red-100 text-red-800'
: user.role === UserRole.MODERATOR
? 'bg-yellow-100 text-yellow-800'
: 'bg-green-100 text-green-800'
}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={(e) => {
e.stopPropagation();
handleUserEdit(user);
}}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleUserDelete(user);
}}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
3. Project 3: TypeScript Library
Project Structure
typescript-library/
├── src/
│ ├── index.ts
│ ├── utils/
│ │ ├── validation.ts
│ │ ├── formatting.ts
│ │ └── helpers.ts
│ ├── types/
│ │ ├── common.ts
│ │ └── api.ts
│ └── classes/
│ ├── ApiClient.ts
│ └── Cache.ts
├── tests/
│ ├── utils/
│ ├── classes/
│ └── integration/
├── examples/
│ ├── basic-usage.ts
│ └── advanced-usage.ts
├── docs/
│ ├── README.md
│ └── API.md
├── package.json
├── tsconfig.json
├── rollup.config.js
└── .npmignore
Core Implementation
// src/index.ts
export * from './utils/validation';
export * from './utils/formatting';
export * from './utils/helpers';
export * from './types/common';
export * from './types/api';
export * from './classes/ApiClient';
export * from './classes/Cache';
// src/utils/validation.ts
export interface ValidationRule<T> {
validate(value: T): boolean;
message: string;
}
export class Validator<T> {
private rules: ValidationRule<T>[] = [];
addRule(rule: ValidationRule<T>): this {
this.rules.push(rule);
return this;
}
validate(value: T): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
for (const rule of this.rules) {
if (!rule.validate(value)) {
errors.push(rule.message);
}
}
return {
isValid: errors.length === 0,
errors
};
}
}
export const commonRules = {
required: <T>(message: string = 'This field is required'): ValidationRule<T> => ({
validate: (value: T) => value != null && value !== '',
message
}),
minLength: (min: number, message?: string): ValidationRule<string> => ({
validate: (value: string) => value.length >= min,
message: message || `Must be at least ${min} characters long`
}),
maxLength: (max: number, message?: string): ValidationRule<string> => ({
validate: (value: string) => value.length <= max,
message: message || `Must be no more than ${max} characters long`
}),
email: (message: string = 'Must be a valid email address'): ValidationRule<string> => ({
validate: (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message
}),
min: (min: number, message?: string): ValidationRule<number> => ({
validate: (value: number) => value >= min,
message: message || `Must be at least ${min}`
}),
max: (max: number, message?: string): ValidationRule<number> => ({
validate: (value: number) => value <= max,
message: message || `Must be no more than ${max}`
})
};
// src/classes/ApiClient.ts
export interface ApiClientConfig {
baseURL: string;
timeout?: number;
headers?: Record<string, string>;
}
export interface ApiResponse<T> {
data: T;
status: number;
statusText: string;
headers: Record<string, string>;
}
export class ApiClient {
private config: ApiClientConfig;
constructor(config: ApiClientConfig) {
this.config = {
timeout: 5000,
headers: {
'Content-Type': 'application/json',
...config.headers
},
...config
};
}
async get<T>(url: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
const queryString = params ? this.buildQueryString(params) : '';
const fullUrl = `${this.config.baseURL}${url}${queryString}`;
const response = await fetch(fullUrl, {
method: 'GET',
headers: this.config.headers,
signal: AbortSignal.timeout(this.config.timeout!)
});
return this.handleResponse<T>(response);
}
async post<T>(url: string, data?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.config.baseURL}${url}`, {
method: 'POST',
headers: this.config.headers,
body: data ? JSON.stringify(data) : undefined,
signal: AbortSignal.timeout(this.config.timeout!)
});
return this.handleResponse<T>(response);
}
async put<T>(url: string, data?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.config.baseURL}${url}`, {
method: 'PUT',
headers: this.config.headers,
body: data ? JSON.stringify(data) : undefined,
signal: AbortSignal.timeout(this.config.timeout!)
});
return this.handleResponse<T>(response);
}
async delete<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.config.baseURL}${url}`, {
method: 'DELETE',
headers: this.config.headers,
signal: AbortSignal.timeout(this.config.timeout!)
});
return this.handleResponse<T>(response);
}
private buildQueryString(params: Record<string, any>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value != null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
private async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
return {
data,
status: response.status,
statusText: response.statusText,
headers
};
}
}
// examples/basic-usage.ts
import { Validator, commonRules, ApiClient } from '../src';
// Validation example
const userValidator = new Validator<string>()
.addRule(commonRules.required('Name is required'))
.addRule(commonRules.minLength(2, 'Name must be at least 2 characters'))
.addRule(commonRules.maxLength(50, 'Name must be no more than 50 characters'));
const emailValidator = new Validator<string>()
.addRule(commonRules.required('Email is required'))
.addRule(commonRules.email('Must be a valid email address'));
// API client example
const apiClient = new ApiClient({
baseURL: 'https://api.example.com',
timeout: 10000
});
async function fetchUser(id: string) {
try {
const response = await apiClient.get<{ id: string; name: string; email: string }>(`/users/${id}`);
return response.data;
} catch (error) {
console.error('Failed to fetch user:', error);
throw error;
}
}
// Usage
const nameValidation = userValidator.validate('John Doe');
console.log('Name validation:', nameValidation);
const emailValidation = emailValidator.validate('[email protected]');
console.log('Email validation:', emailValidation);
fetchUser('123').then(user => {
console.log('User:', user);
});
Best Practices for Real-World Projects
1. Project Structure
// Good: Clear separation of concerns
src/
├── domain/ // Business logic
├── infrastructure/ // External dependencies
├── application/ // Use cases
└── presentation/ // Controllers, UI
2. Error Handling
// Good: Comprehensive error handling
try {
const result = await riskyOperation();
return { success: true, data: result };
} catch (error) {
if (error instanceof ValidationError) {
return { success: false, error: error.message, field: error.field };
}
if (error instanceof NetworkError) {
return { success: false, error: 'Network error', retryable: true };
}
return { success: false, error: 'Unknown error' };
}
3. Testing
// Good: Comprehensive testing
describe('UserService', () => {
let userService: UserService;
let mockRepository: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepository = createMockRepository();
userService = new UserService(mockRepository);
});
describe('createUser', () => {
it('should create user with valid data', async () => {
// Test implementation
});
it('should throw error for invalid data', async () => {
// Test implementation
});
});
});
4. Documentation
// Good: Comprehensive documentation
/**
* Creates a new user in the system
* @param userData - The user data to create
* @returns Promise that resolves to the created user
* @throws {ValidationError} When user data is invalid
* @throws {DuplicateEmailError} When email already exists
* @example
* ```typescript
* const user = await userService.createUser({
* email: '[email protected]',
* name: 'John Doe'
* });
* ```
*/
async createUser(userData: CreateUserRequest): Promise<User> {
// Implementation
}
Common Pitfalls and Solutions
1. Poor Project Structure
// ❌ Problem: Everything in one file
// app.ts - 1000+ lines of code
// ✅ Solution: Proper separation
// src/domain/entities/User.ts
// src/domain/repositories/UserRepository.ts
// src/application/use-cases/CreateUserUseCase.ts
// src/presentation/controllers/UserController.ts
2. Inadequate Error Handling
// ❌ Problem: Generic error handling
try {
await riskyOperation();
} catch (error) {
console.error(error);
}
// ✅ Solution: Specific error handling
try {
await riskyOperation();
} catch (error) {
if (error instanceof ValidationError) {
handleValidationError(error);
} else if (error instanceof NetworkError) {
handleNetworkError(error);
} else {
handleUnknownError(error);
}
}
3. Missing Tests
// ❌ Problem: No tests
// Production code without tests
// ✅ Solution: Comprehensive testing
describe('UserService', () => {
// Unit tests
});
describe('UserController', () => {
// Integration tests
});
describe('User API', () => {
// E2E tests
});
Conclusion
Real-world TypeScript projects demonstrate the practical application of TypeScript in production environments. By understanding:
- What real-world projects involve and their types
- Why they're important for practical experience and learning
- How to structure and implement various project types
You can build scalable, maintainable applications that solve real problems. These projects showcase best practices, architectural patterns, and real-world challenges that developers face when building production-ready applications.
Next Steps
- Start building your own real-world projects
- Contribute to open-source TypeScript projects
- Learn about deployment and DevOps practices
- Move on to Chapter 15: Best Practices & Patterns
This tutorial is part of the TypeScript Mastery series by syscook.dev