Building Scalable Node.js Applications with TypeScript
TypeScript has become the standard for building robust, maintainable Node.js applications. When combined with proper architecture patterns and testing strategies, TypeScript enables teams to build applications that scale both in terms of features and team size.
Why TypeScript for Node.js?
TypeScript brings several advantages to Node.js development:
- Type Safety: Catch errors at compile time rather than runtime
- Better IDE Support: Enhanced autocomplete and refactoring capabilities
- Self-Documenting Code: Types serve as living documentation
- Easier Refactoring: Confidence when making changes to large codebases
- Team Collaboration: Consistent code patterns across team members
Project Structure
A well-organized TypeScript Node.js project should follow this structure:
src/
├── controllers/
├── middleware/
├── models/
├── routes/
├── services/
├── types/
├── utils/
└── index.ts
tests/
├── unit/
├── integration/
└── e2e/
config/
├── database.ts
├── logger.ts
└── server.ts
dist/ (compiled output)
docs/
package.json
tsconfig.json
jest.config.js
Architecture Patterns
1. Layered Architecture
// controllers/userController.ts
export class UserController {
constructor(private userService: IUserService) {}
async getUser(req: Request, res: Response) {
const user = await this.userService.getUser(req.params.id);
res.json(user);
}
}
// services/userService.ts
export interface IUserService {
getUser(id: string): Promise<User>;
}
export class UserService implements IUserService {
constructor(private userRepository: IUserRepository) {}
async getUser(id: string): Promise<User> {
return this.userRepository.findById(id);
}
}
2. Dependency Injection
// config/dependencies.ts
import { Container } from 'inversify';
import { UserController } from '../controllers/userController';
import { UserService } from '../services/userService';
const container = new Container();
container.bind<UserController>(UserController).toSelf();
container.bind<IUserService>(IUserService).to(UserService);
export { container };
Error Handling
Global Error Handler
// middleware/errorHandler.ts
export const errorHandler = (
error: Error,
req: Request,
res: Response,
next: NextFunction
) => {
console.error(error.stack);
if (error instanceof ValidationError) {
return res.status(400).json({ error: error.message });
}
if (error instanceof NotFoundError) {
return res.status(404).json({ error: error.message });
}
res.status(500).json({ error: 'Internal server error' });
};
Custom Error Classes
// utils/errors.ts
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
export class NotFoundError extends Error {
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
}
Testing Strategies
Unit Tests with Jest
// tests/unit/userService.test.ts
describe('UserService', () => {
let userService: UserService;
let mockRepository: jest.Mocked<IUserRepository>;
beforeEach(() => {
mockRepository = {
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
userService = new UserService(mockRepository);
});
it('should return user when found', async () => {
const mockUser = { id: '1', name: 'John Doe' };
mockRepository.findById.mockResolvedValue(mockUser);
const result = await userService.getUser('1');
expect(result).toEqual(mockUser);
expect(mockRepository.findById).toHaveBeenCalledWith('1');
});
});
Integration Tests
// tests/integration/userRoutes.test.ts
import request from 'supertest';
import app from '../../src/index';
describe('User Routes', () => {
it('should return 404 for non-existent user', async () => {
const response = await request(app)
.get('/api/users/999')
.expect(404);
expect(response.body.error).toBe('User not found');
});
});
Database Integration
TypeORM with TypeScript
// models/User.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
name: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
// repositories/userRepository.ts
export class UserRepository implements IUserRepository {
constructor(private dataSource: DataSource) {}
async findById(id: string): Promise<User | null> {
return this.dataSource.getRepository(User).findOne({
where: { id: parseInt(id) }
});
}
}
Deployment Best Practices
Environment Configuration
// config/environment.ts
export const config = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000'),
database: {
url: process.env.DATABASE_URL || 'postgresql://localhost:5432/myapp',
ssl: process.env.NODE_ENV === 'production',
},
jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key',
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
},
};
Docker Configuration
# Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
Monitoring and Logging
Structured Logging
// config/logger.ts
import winston from 'winston';
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'user-service' },
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
Health Checks
// routes/health.ts
app.get('/health', async (req, res) => {
try {
// Check database connection
await sequelize.authenticate();
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message,
});
}
});
Conclusion
Building scalable Node.js applications with TypeScript requires careful attention to architecture, testing, and operational concerns. By implementing proper patterns, comprehensive testing, and robust error handling, you can create applications that grow with your business needs.
The combination of TypeScript's type safety with Node.js's performance and ecosystem makes for a powerful platform for building modern web applications. Start with a solid foundation, implement proper testing, and continuously monitor your application's health as it scales.
Tags: