Clean Code in TypeScript: Class Architecture & Optimization
A short excerpt (shown in listings): Master decorators, SOLID principles, generics, and design patterns with real before/after examples that make the difference obvious.

Why This Matters
Most TypeScript codebases start clean. Then they grow. Then someone adds logic in the wrong place, copy-pastes retry handling into three files, and six months later nobody wants to touch the auth module.
This article is about the patterns that prevent that — written for developers who already know TypeScript syntax and want to write code that's easy to read, test, and extend.
01 — Standard Class Structure
A well-structured TypeScript class follows a predictable member ordering. When every developer on the team arranges members the same way, you spend zero time hunting for a field or method.
The Canonical Member Order:
Static constants & static fields
Private fields (# or private)
Protected fields
Public fields / readonly properties
Constructor
Static factory methods
Public methods (interface contract)
Protected methods (template methods)
Private helpers
Getters & Setters
❌ Bad — scattered members:
class UserService {
getUser(id: string) { ... }
private db: Database;
baseUrl = '/api';
constructor(db: Database) {
this.db = db;
}
private validate(u: User) { ... }
static readonly MAX = 100;
createUser(u: User) { ... }
}
✅ Good — canonical order:
class UserService {
// 1. Static constants
static readonly MAX_USERS = 100;
// 2. Private fields (true runtime private)
readonly #db: Database;
// 4. Public fields
readonly baseUrl = '/api';
// 5. Constructor
constructor(db: Database) {
this.#db = db;
}
// 7. Public methods
getUser(id: string) { ... }
createUser(u: User) { ... }
// 9. Private helpers
private validate(u: User) { ... }
}
💡 Key insight: Use
#fieldinstead ofprivate field. Theprivatekeyword is TypeScript-only and disappears at runtime — anyone can bypass it with(obj as any).field. The#syntax is enforced by the JavaScript engine itself.
02 — Decorators: Cross-Cutting Concerns Done Right
Decorators are functions that wrap classes or methods to add behaviour without modifying the original source. Think of them as reusable plugins you attach with a single line.
TypeScript 5 ships with the TC39 Stage 3 decorator standard — no need for "experimentalDecorators": true anymore.
@Log — Know What Your Code Is Doing
// src/decorators/log.ts
export function Log<T extends (...args: unknown[]) => unknown>(
originalMethod: T,
context: ClassMethodDecoratorContext
): T {
const methodName = String(context.name);
return function(this: unknown, ...args: unknown[]) {
const start = performance.now();
console.log(`▶ ${methodName}(${JSON.stringify(args)})`);
const result = originalMethod.apply(this, args);
if (result instanceof Promise) {
return result.then((val) => {
const ms = (performance.now() - start).toFixed(2);
console.log(`✔ ${methodName} resolved in ${ms}ms`);
return val;
});
}
const ms = (performance.now() - start).toFixed(2);
console.log(`✔ ${methodName} done in ${ms}ms`);
return result;
} as T;
}
@Memoize — Cache Expensive Results
// src/decorators/memoize.ts
export function Memoize<T extends (...args: unknown[]) => unknown>(
originalMethod: T,
_context: ClassMethodDecoratorContext
): T {
const cache = new Map<string, unknown>();
return function(this: unknown, ...args: unknown[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`[Memoize] cache hit: ${key}`);
return cache.get(key);
}
const result = originalMethod.apply(this, args);
cache.set(key, result);
return result;
} as T;
}
@Retry — Handle Flaky Dependencies Gracefully
// src/decorators/retry.ts
interface RetryOptions {
attempts: number;
delayMs: number;
backoff?: boolean; // exponential backoff
}
export function Retry(opts: RetryOptions) {
return function<T extends (...args: unknown[]) => Promise<unknown>>(
originalMethod: T,
context: ClassMethodDecoratorContext
): T {
const name = String(context.name);
return async function(this: unknown, ...args: unknown[]) {
let lastError: Error;
for (let attempt = 1; attempt <= opts.attempts; attempt++) {
try {
return await originalMethod.apply(this, args);
} catch (err) {
lastError = err as Error;
const delay = opts.backoff
? opts.delayMs * Math.pow(2, attempt - 1)
: opts.delayMs;
console.warn(`[Retry] ${name} attempt ${attempt}/${opts.attempts} failed. Retrying in ${delay}ms`);
if (attempt < opts.attempts) {
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError!;
} as T;
};
}
@Singleton — One Instance, Always
// src/decorators/singleton.ts
export function Singleton<T extends { new(...args: unknown[]): unknown }>(
OriginalClass: T,
_context: ClassDecoratorContext
): T {
let instance: InstanceType<T> | undefined;
return new Proxy(OriginalClass, {
construct(target, args) {
if (!instance) {
instance = Reflect.construct(target, args) as InstanceType<T>;
}
return instance;
}
});
}
Putting It All Together
// src/services/reportService.ts
@Singleton
class ReportService {
static readonly CACHE_TTL = 300_000; // 5 min
readonly #db: Database;
constructor(db: Database) { this.#db = db; }
@Log
@Memoize
getSummary(year: number): Summary {
// Result cached after first call per year
return this.#db.query(`SELECT * FROM reports WHERE year = ${year}`);
}
@Log
@Retry({ attempts: 3, delayMs: 500, backoff: true })
async fetchFromExternalApi(endpoint: string): Promise<unknown> {
// Retried up to 3× with 500ms → 1s → 2s delays
const res = await fetch(endpoint);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}
// Both return the SAME instance
const a = new ReportService(db);
const b = new ReportService(db);
console.log(a === b); // true
03 — SOLID Principles in TypeScript
S — Single Responsibility
A class should have one reason to change. If a class validates users AND saves to DB AND sends emails AND writes logs — changing any one of those behaviours requires touching the same class.
❌ Bad:
class UserManager {
createUser(u: User) {
// validate
// save to DB
// send welcome email
// write audit log
}
}
✅ Good:
class UserValidator { validate(u: User) {...} }
class UserRepository { save(u: User) {...} }
class EmailService { sendWelcome(u: User) {...} }
class AuditLogger { log(action: string) {...} }
class UserService {
constructor(
private validator: UserValidator,
private repo: UserRepository,
private email: EmailService,
private logger: AuditLogger
) {}
async createUser(u: User) {
this.validator.validate(u);
await this.repo.save(u);
await this.email.sendWelcome(u);
this.logger.log(`created:user:${u.id}`);
}
}
O — Open/Closed
Open for extension, closed for modification. Add new behaviour by writing new classes — not by editing existing ones.
// Interface defines the contract — never changes
interface NotificationChannel {
send(message: string, recipient: string): Promise<void>;
}
// Add WhatsApp without touching EmailChannel or SlackChannel
class EmailChannel implements NotificationChannel { ... }
class SlackChannel implements NotificationChannel { ... }
class WhatsAppChannel implements NotificationChannel { ... }
// NotificationService never changes when you add a new channel
class NotificationService {
constructor(private channels: NotificationChannel[]) {}
async broadcast(msg: string, recipient: string) {
await Promise.all(this.channels.map(ch => ch.send(msg, recipient)));
}
}
D — Dependency Inversion
Depend on abstractions (interfaces), not concrete implementations. This is what makes your code testable.
❌ Bad — impossible to test without a real DB:
class OrderService {
private db = new PostgresDatabase(); // hardcoded
async getOrder(id: string) {
return this.db.query(id);
}
}
✅ Good — inject the dependency, mock in tests:
interface IDatabase {
query(id: string): Promise<Order>;
}
class OrderService {
constructor(private db: IDatabase) {} // inject the abstraction
async getOrder(id: string) {
return this.db.query(id);
}
}
// Production: new OrderService(new PostgresDatabase())
// Tests: new OrderService(new InMemoryDatabase())
04 — Generics & Utility Types
Generic Repository Pattern
Write the CRUD logic once. Use it for any entity.
// src/repositories/base.repository.ts
interface Entity { id: string; }
abstract class BaseRepository<T extends Entity> {
protected items: Map<string, T> = new Map();
findById(id: string): T | undefined { return this.items.get(id); }
findAll(): T[] { return [...this.items.values()]; }
save(entity: T): T { this.items.set(entity.id, entity); return entity; }
delete(id: string): boolean { return this.items.delete(id); }
// Subclasses define domain-specific queries
abstract findByFilter(filter: Partial<T>): T[];
}
// Zero CRUD boilerplate — just add domain-specific methods
class UserRepository extends BaseRepository<User> {
findByFilter(filter: Partial<User>) {
return this.findAll().filter(u =>
(Object.keys(filter) as (keyof User)[]).every(k => u[k] === filter[k])
);
}
}
Built-in Utility Types You Should Use Daily
interface User {
id: string;
name: string;
email: string;
password: string;
role: 'admin' | 'user';
createdAt: Date;
}
// Partial — all fields optional (for PATCH body)
type UpdateUserDto = Partial<Omit<User, 'id' | 'createdAt'>>;
// Pick — public-facing response (strip password)
type PublicUser = Pick<User, 'id' | 'name' | 'email' | 'role'>;
// Required — force all optional fields to be present
type CreateUserDto = Required<Omit<User, 'id' | 'createdAt'>>;
// Record — map from role to permissions
type RolePermissions = Record<User['role'], string[]>;
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read']
};
// ReturnType — derive a type from a function's return value
function buildConfig() {
return { port: 3000, debug: false, db: 'postgres' };
}
type Config = ReturnType<typeof buildConfig>;
// → { port: number; debug: boolean; db: string }
05 — Design Patterns
Observer Pattern — Typed Event Bus
Decouple event producers from consumers. A UserService emits user.created without knowing who's listening.
// src/patterns/eventBus.ts
type EventHandler<T> = (payload: T) => void | Promise<void>;
class EventBus {
private handlers = new Map<string, Set<EventHandler<unknown>>>();
on<T>(event: string, handler: EventHandler<T>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as EventHandler<unknown>);
// Returns an unsubscribe function
return () => this.handlers.get(event)?.delete(handler as EventHandler<unknown>);
}
async emit<T>(event: string, payload: T): Promise<void> {
const fns = this.handlers.get(event);
if (!fns) return;
await Promise.all([...fns].map(fn => fn(payload)));
}
}
// Usage — completely decoupled
const bus = new EventBus();
bus.on<User>('user.created', (user) => emailService.sendWelcome(user));
bus.on<User>('user.created', (user) => analyticsService.track(user));
bus.on<User>('user.created', (user) => logger.info(`New user: ${user.id}`));
await bus.emit('user.created', newUser); // all 3 handlers run in parallel
Builder Pattern — Fluent API
Construct complex objects step by step. Much cleaner than a constructor with 8 positional arguments.
// src/patterns/queryBuilder.ts
class QueryBuilder<T> {
private _table = '';
private _fields: string[] = ['*'];
private _where: string[] = [];
private _limit = 100;
private _orderBy = '';
from(table: string): this { this._table = table; return this; }
select(...fields: string[]): this { this._fields = fields; return this; }
where(condition: string): this { this._where.push(condition); return this; }
limit(n: number): this { this._limit = n; return this; }
orderBy(col: keyof T & string, dir: 'ASC' | 'DESC' = 'ASC'): this {
this._orderBy = `${String(col)} ${dir}`; return this;
}
build(): string {
let sql = `SELECT ${this._fields.join(', ')} FROM ${this._table}`;
if (this._where.length) sql += ` WHERE ${this._where.join(' AND ')}`;
if (this._orderBy) sql += ` ORDER BY ${this._orderBy}`;
sql += ` LIMIT ${this._limit}`;
return sql;
}
}
// Readable, type-safe, no positional argument confusion
const sql = new QueryBuilder<User>()
.from('users')
.select('id', 'name', 'email')
.where("role = 'admin'")
.where("active = true")
.orderBy('createdAt', 'DESC')
.limit(20)
.build();
06 — Performance Optimizations
Object Pool — Eliminate GC Pressure
Creating and garbage-collecting thousands of objects per second is expensive. A pool pre-allocates a set and reuses them.
// src/utils/objectPool.ts
class ObjectPool<T> {
private pool: T[] = [];
private active = 0;
constructor(
private factory: () => T,
private reset: (obj: T) => void,
private maxSize = 50
) {
// Pre-warm the pool
for (let i = 0; i < 10; i++) this.pool.push(this.factory());
}
acquire(): T {
this.active++;
return this.pool.pop() ?? this.factory();
}
release(obj: T): void {
this.active--;
if (this.pool.length < this.maxSize) {
this.reset(obj); // clean before returning
this.pool.push(obj); // no GC pressure
}
}
}
// Usage
const connPool = new ObjectPool(
() => new DbConnection(),
(conn) => conn.reset(),
20
);
const conn = connPool.acquire();
try {
await conn.query('SELECT ...');
} finally {
connPool.release(conn); // always return, even on error
}
Lazy Initialization
❌ Eager — always pays the cost at startup:
class AppConfig {
// Expensive file read runs at import time, even if never used
readonly settings = parseConfig(fs.readFileSync('config.json'));
}
✅ Lazy — pays the cost only when actually needed:
class AppConfig {
#settings: Config | undefined;
get settings(): Config {
if (!this.#settings) {
this.#settings = parseConfig(fs.readFileSync('config.json'));
}
return this.#settings;
}
}
Map vs Object for Dynamic Keys
❌ Plain object — prototype chain on every lookup:
const cache: Record<string, User> = {};
cache[userId] = user;
const hit = cache[userId];
✅ Map — true O(1), no prototype chain:
const cache = new Map<string, User>();
cache.set(userId, user);
const hit = cache.get(userId);
Use
Mapwhen keys are dynamic or the collection is frequently updated. Use a plain object when you need JSON serialization or keys are known at compile time.
07 — Dependency Injection Without a Framework
You don't need NestJS or InversifyJS to get DI benefits. A simple composition root — one place where all dependencies are wired — gives you testability with zero overhead.
// src/container.ts
class UserRepository {
constructor(private db: IDatabase) {}
async findById(id: string): Promise<User | null> {
return this.db.query(`SELECT * FROM users WHERE id='${id}'`);
}
}
class EmailService {
constructor(private transport: IEmailTransport) {}
async sendWelcome(user: User): Promise<void> {
await this.transport.send({ to: user.email, subject: 'Welcome!' });
}
}
class UserService {
constructor(
private repo: UserRepository,
private email: EmailService
) {}
async onboard(id: string): Promise<void> {
const user = await this.repo.findById(id);
if (user) await this.email.sendWelcome(user);
}
}
// ── Composition Root: wire everything once ──
function buildContainer() {
const db = new PostgresDatabase(process.env.DATABASE_URL!);
const transport = new SendGridTransport(process.env.SENDGRID_KEY!);
const userRepo = new UserRepository(db);
const emailSvc = new EmailService(transport);
const userSvc = new UserService(userRepo, emailSvc);
return { userSvc, userRepo, emailSvc };
}
export const container = buildContainer();
// In tests — swap the DB for an in-memory mock:
// const container = { userRepo: new UserRepository(new InMemoryDatabase()) };
08 — Error Handling Architecture
Throwing raw Error objects loses context. Typed error hierarchies give you structured information and let callers make precise decisions.
Typed Error Hierarchy
// src/errors/index.ts
abstract class AppError extends Error {
abstract readonly code: string;
abstract readonly statusCode: number;
readonly timestamp = new Date().toISOString();
readonly isOperational: boolean;
constructor(message: string, isOperational = true) {
super(message);
this.name = this.constructor.name;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
readonly code = 'NOT_FOUND';
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} with id '${id}' not found`);
}
}
class ValidationError extends AppError {
readonly code = 'VALIDATION_ERROR';
readonly statusCode = 400;
constructor(public readonly fields: Record<string, string>) {
super('Validation failed');
}
}
class ConflictError extends AppError {
readonly code = 'CONFLICT';
readonly statusCode = 409;
constructor(message: string) { super(message); }
}
Centralized Express Error Handler
// src/middleware/errorHandler.ts
export function globalErrorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
// Known operational error — safe to expose details
if (err instanceof AppError && err.isOperational) {
res.status(err.statusCode).json({
error: err.code,
message: err.message,
...(err instanceof ValidationError && { fields: err.fields })
});
return;
}
// Unknown/programmer error — never leak internals
console.error('Unexpected error:', err);
res.status(500).json({
error: 'INTERNAL_ERROR',
message: 'An unexpected error occurred'
});
}
// Usage in services — throw typed, not raw strings
async function getUser(id: string): Promise<User> {
const user = await db.findById(id);
if (!user) throw new NotFoundError('User', id);
return user;
}
async function createUser(dto: CreateUserDto): Promise<User> {
const errors: Record<string, string> = {};
if (!dto.email.includes('@')) errors.email = 'Invalid email';
if (dto.password.length < 8) errors.password = 'Min 8 characters';
if (Object.keys(errors).length) throw new ValidationError(errors);
const exists = await db.findByEmail(dto.email);
if (exists) throw new ConflictError(`Email ${dto.email} already registered`);
return db.create(dto);
}
⚠️ Operational vs Programmer errors: An operational error is expected behaviour (user not found, validation failed) — expose it cleanly. A programmer error is a bug (cannot read property of undefined) — catch it, log the stack trace internally, and return a generic 500 to the client. Never expose stack traces in API responses.
Quick Reference
Practice | Avoid | Prefer |
|---|---|---|
Field privacy |
|
|
Type assertion |
|
|
Null checks |
|
|
Dynamic keys |
|
|
Error throwing |
|
|
Many constructor params | 8 positional arguments | Options object or Builder pattern |
Cross-cutting concerns | Copy-paste log/retry/cache |
|
Expensive init | Eager field initialization | Getter with lazy |
Dependency wiring |
| Constructor injection + composition root |
Partial updates | Separate |
|
TypeScript 5 · Node 20+ · June 2026
Promote this post
Share & amplify
LinkedIn post generator
Generate a polished, professional LinkedIn post from this article. Edit before posting.

Comments (0)