Lesson 6.2: Project Structure
Duration: 50 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Organize TypeScript projects using industry-standard patterns
- Create logical folder structures for different project types
- Separate concerns using models, services, and utilities
- Configure TypeScript for multi-file projects
- Understand when to use different organizational patterns
Why Project Structure Matters
A well-organized project is easier to:
- Navigate: Find code quickly when you need it
- Maintain: Make changes without breaking unrelated code
- Scale: Add features without creating chaos
- Test: Test components in isolation
- Collaborate: Multiple developers can work without conflicts
Bad structure leads to "spaghetti code" where everything depends on everything else.
Basic Project Structure
Here is a minimal TypeScript project structure:
my-project/
├── src/
│ ├── index.ts # Entry point
│ ├── types.ts # Shared type definitions
│ └── utils.ts # Utility functions
├── dist/ # Compiled JavaScript (generated)
├── package.json
└── tsconfig.json
The src folder contains source code, dist contains compiled output.
Standard Folder Structure
For larger projects, organize by purpose:
my-project/
├── src/
│ ├── index.ts # Application entry point
│ ├── models/ # Data structures and types
│ │ ├── user.ts
│ │ ├── product.ts
│ │ └── index.ts # Barrel export
│ ├── services/ # Business logic
│ │ ├── user-service.ts
│ │ ├── product-service.ts
│ │ └── index.ts
│ ├── utils/ # Helper functions
│ │ ├── validation.ts
│ │ ├── formatting.ts
│ │ └── index.ts
│ ├── config/ # Configuration
│ │ └── app-config.ts
│ └── types/ # Global type definitions
│ └── index.ts
├── tests/ # Test files
│ ├── models/
│ ├── services/
│ └── utils/
├── dist/ # Compiled output
├── package.json
└── tsconfig.json
Organizing by Feature
An alternative is organizing by feature instead of by type:
my-project/
├── src/
│ ├── index.ts
│ ├── users/ # Everything related to users
│ │ ├── user.model.ts
│ │ ├── user.service.ts
│ │ ├── user.utils.ts
│ │ └── index.ts
│ ├── products/ # Everything related to products
│ │ ├── product.model.ts
│ │ ├── product.service.ts
│ │ ├── product.utils.ts
│ │ └── index.ts
│ ├── orders/ # Everything related to orders
│ │ ├── order.model.ts
│ │ ├── order.service.ts
│ │ └── index.ts
│ └── shared/ # Shared across features
│ ├── utils/
│ ├── types/
│ └── index.ts
├── tests/
├── dist/
├── package.json
└── tsconfig.json
When to use feature-based structure:
- Large applications with distinct features
- Teams working on different features
- Features that might become separate packages
Models Folder
Models define the shape of your data. They contain interfaces, types, and classes:
// src/models/user.ts
export interface User {
id: number;
username: string;
email: string;
createdAt: Date;
}
export interface CreateUserInput {
username: string;
email: string;
password: string;
}
export interface UpdateUserInput {
username?: string;
email?: string;
}
export type UserRole = 'admin' | 'user' | 'guest';
// src/models/product.ts
export interface Product {
id: number;
name: string;
description: string;
price: number;
category: ProductCategory;
inStock: boolean;
}
export type ProductCategory = 'electronics' | 'clothing' | 'books' | 'other';
export interface ProductFilter {
category?: ProductCategory;
minPrice?: number;
maxPrice?: number;
inStockOnly?: boolean;
}
Services Folder
Services contain business logic. They use models and provide operations:
// src/services/user-service.ts
import type { CreateUserInput, UpdateUserInput, User } from '../models/user';
export class UserService {
private users: User[] = [];
private nextId = 1;
create(input: CreateUserInput): User {
const user: User = {
id: this.nextId++,
username: input.username,
email: input.email,
createdAt: new Date(),
};
this.users.push(user);
return user;
}
findById(id: number): User | undefined {
return this.users.find((user) => user.id === id);
}
findByEmail(email: string): User | undefined {
return this.users.find((user) => user.email === email);
}
update(id: number, input: UpdateUserInput): User | undefined {
const user = this.findById(id);
if (!user) return undefined;
if (input.username) user.username = input.username;
if (input.email) user.email = input.email;
return user;
}
delete(id: number): boolean {
const index = this.users.findIndex((user) => user.id === id);
if (index === -1) return false;
this.users.splice(index, 1);
return true;
}
findAll(): User[] {
return [...this.users];
}
}
Utils Folder
Utilities are helper functions that can be used anywhere:
// src/utils/validation.ts
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function isValidUsername(username: string): boolean {
// 3-20 characters, alphanumeric and underscores only
const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/;
return usernameRegex.test(username);
}
export function isValidPassword(password: string): boolean {
// At least 8 characters, one uppercase, one lowercase, one number
return (
password.length >= 8 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/[0-9]/.test(password)
);
}
// src/utils/formatting.ts
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
export function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
Types Folder
Global types that are used across the application:
// src/types/index.ts
// Generic result type for operations
export interface Result<T> {
success: boolean;
data?: T;
error?: string;
}
// Pagination
export interface PaginationParams {
page: number;
limit: number;
}
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
totalPages: number;
}
// Common ID type
export type ID = number | string;
// Timestamp fields
export interface Timestamps {
createdAt: Date;
updatedAt: Date;
}
Config Folder
Configuration values for your application:
// src/config/app-config.ts
export const config = {
app: {
name: 'My Application',
version: '1.0.0',
environment: process.env.NODE_ENV || 'development',
},
pagination: {
defaultLimit: 10,
maxLimit: 100,
},
validation: {
usernameMinLength: 3,
usernameMaxLength: 20,
passwordMinLength: 8,
},
} as const;
// Type for the config
export type AppConfig = typeof config;
The Entry Point
The main index.ts ties everything together:
// src/index.ts
import { config } from './config/app-config';
import { ProductService } from './services/product-service';
import { UserService } from './services/user-service';
// Initialize services
const userService = new UserService();
const productService = new ProductService();
// Application startup
function main(): void {
console.log(`Starting ${config.app.name} v${config.app.version}`);
console.log(`Environment: ${config.app.environment}`);
// Create sample data
const user = userService.create({
username: 'johndoe',
email: 'john@example.com',
password: 'Password123',
});
console.log('Created user:', user);
// Your application logic here
}
main();
TypeScript Configuration
Configure tsconfig.json for your project structure:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
Key options:
rootDir: Where your source files areoutDir: Where compiled files goinclude: Which files to compileexclude: Which files to ignore
Path Aliases
For cleaner imports, configure path aliases:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@models/*": ["models/*"],
"@services/*": ["services/*"],
"@utils/*": ["utils/*"],
"@config/*": ["config/*"],
"@types/*": ["types/*"]
}
}
}
Now you can import like this:
// Before: relative paths can get messy
import { User } from "../../../models/user";
import { formatDate } from "../../utils/formatting";
// After: clean path aliases
import { User } from "@models/user";
import { formatDate } from "@utils/formatting";
Note: You may need tsconfig-paths or bundler configuration to resolve these at runtime.
Naming Conventions
Consistent naming makes projects easier to navigate:
Files:
- Use kebab-case:
user-service.ts,date-utils.ts - Or camelCase:
userService.ts,dateUtils.ts - Choose one and be consistent
Classes:
- PascalCase:
UserService,ProductModel
Interfaces and Types:
- PascalCase:
User,CreateUserInput - Some prefix with
I:IUser(less common in TypeScript)
Functions and Variables:
- camelCase:
findById,formatDate
Constants:
- UPPER_SNAKE_CASE:
MAX_RETRIES,DEFAULT_TIMEOUT - Or camelCase for object constants:
config
Common Patterns
Pattern 1: Separation of Concerns
Each module has one responsibility:
models/ - Data shapes only (no logic)
services/ - Business logic (uses models)
utils/ - Pure helper functions (no state)
config/ - Configuration values
Pattern 2: Dependency Direction
Dependencies should flow in one direction:
index.ts (entry)
↓
services/ (business logic)
↓
models/ (data structures)
↓
utils/ (helpers)
Services can use models and utils. Models should not import services.
Pattern 3: Index Files
Each folder has an index.ts that exports its public API:
// models/index.ts
export * from './user';
export * from './product';
export * from './order';
This enables clean imports:
import { Order, Product, User } from './models';
Exercises
Exercise 1: Create Project Structure
Create the following project structure with basic implementations:
task-manager/
├── src/
│ ├── index.ts
│ ├── models/
│ │ ├── task.ts # Task interface
│ │ └── index.ts
│ ├── services/
│ │ ├── task-service.ts # CRUD operations
│ │ └── index.ts
│ └── utils/
│ ├── date-utils.ts # Date formatting
│ └── index.ts
├── package.json
└── tsconfig.json
Solution
// src/models/task.ts
export interface Task {
id: number;
title: string;
description: string;
completed: boolean;
dueDate: Date | null;
createdAt: Date;
}
export interface CreateTaskInput {
title: string;
description?: string;
dueDate?: Date;
}
export type TaskStatus = "pending" | "completed" | "overdue";
// src/models/index.ts
export * from "./task";
// src/utils/date-utils.ts
export function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric"
});
}
export function isOverdue(date: Date): boolean {
return date < new Date();
}
export function daysUntil(date: Date): number {
const now = new Date();
const diff = date.getTime() - now.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
// src/utils/index.ts
export * from "./date-utils";
// src/services/task-service.ts
import type { Task, CreateTaskInput } from "../models";
import { isOverdue } from "../utils";
export class TaskService {
private tasks: Task[] = [];
private nextId = 1;
create(input: CreateTaskInput): Task {
const task: Task = {
id: this.nextId++,
title: input.title,
description: input.description || "",
completed: false,
dueDate: input.dueDate || null,
createdAt: new Date()
};
this.tasks.push(task);
return task;
}
findAll(): Task[] {
return [...this.tasks];
}
findById(id: number): Task | undefined {
return this.tasks.find(task => task.id === id);
}
complete(id: number): Task | undefined {
const task = this.findById(id);
if (task) {
task.completed = true;
}
return task;
}
delete(id: number): boolean {
const index = this.tasks.findIndex(task => task.id === id);
if (index === -1) return false;
this.tasks.splice(index, 1);
return true;
}
getOverdue(): Task[] {
return this.tasks.filter(
task => !task.completed && task.dueDate && isOverdue(task.dueDate)
);
}
}
// src/services/index.ts
export * from "./task-service";
// src/index.ts
import { TaskService } from "./services";
import { formatDate } from "./utils";
const taskService = new TaskService();
// Create some tasks
taskService.create({
title: "Learn TypeScript modules",
description: "Complete lesson 6.2"
});
taskService.create({
title: "Practice project structure",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days from now
});
// Display tasks
const tasks = taskService.findAll();
tasks.forEach(task => {
console.log(`[${task.completed ? "x" : " "}] ${task.title}`);
if (task.dueDate) {
console.log(` Due: ${formatDate(task.dueDate)}`);
}
});
Exercise 2: Add Validation
Add a validation.ts file to utils and use it in the service:
// src/utils/validation.ts
// Implement: validateTaskTitle, validateTaskDescription
// Use in task-service.ts to validate input
Solution
// Updated src/services/task-service.ts
import type { CreateTaskInput, Task } from '../models';
import { isOverdue, validateTaskDescription, validateTaskTitle } from '../utils';
// src/utils/validation.ts
export interface ValidationResult {
valid: boolean;
error?: string;
}
export function validateTaskTitle(title: string): ValidationResult {
if (!title || title.trim().length === 0) {
return { valid: false, error: 'Title is required' };
}
if (title.length > 100) {
return { valid: false, error: 'Title must be 100 characters or less' };
}
return { valid: true };
}
export function validateTaskDescription(description: string): ValidationResult {
if (description.length > 500) {
return { valid: false, error: 'Description must be 500 characters or less' };
}
return { valid: true };
}
// src/utils/index.ts
export * from './date-utils';
export * from './validation';
export class TaskService {
private tasks: Task[] = [];
private nextId = 1;
create(input: CreateTaskInput): Task {
// Validate title
const titleValidation = validateTaskTitle(input.title);
if (!titleValidation.valid) {
throw new Error(titleValidation.error);
}
// Validate description
if (input.description) {
const descValidation = validateTaskDescription(input.description);
if (!descValidation.valid) {
throw new Error(descValidation.error);
}
}
const task: Task = {
id: this.nextId++,
title: input.title.trim(),
description: input.description?.trim() || '',
completed: false,
dueDate: input.dueDate || null,
createdAt: new Date(),
};
this.tasks.push(task);
return task;
}
// ... rest of the methods
}
Key Takeaways
- Organize by purpose: Models, services, utils, config
- Or organize by feature: User, product, order folders
- Use index files: Create barrel exports for clean imports
- Keep dependencies flowing one way: Services use models, not vice versa
- Separate concerns: Each file has one responsibility
- Be consistent: Pick naming conventions and stick to them
- Configure TypeScript: Set up paths in tsconfig.json
Resources
| Resource | Type | Description |
|---|---|---|
| TypeScript Project References | Documentation | Large project organization |
| Node.js Best Practices | Guide | Industry-standard patterns |
| Clean Code TypeScript | Guide | Clean code principles |
Next Lesson
Now that you understand project structure, let us learn about barrel exports for cleaner module APIs.