GoodLookRetail BlogEmber
Engineering

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.

A
Ankit Kothari
6 July 2026 · 12 min read
12 Views0 Comments12 Min Read
Clean Code in TypeScript: Class Architecture & Optimization

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:

  1. Static constants & static fields

  2. Private fields (# or private)

  3. Protected fields

  4. Public fields / readonly properties

  5. Constructor

  6. Static factory methods

  7. Public methods (interface contract)

  8. Protected methods (template methods)

  9. Private helpers

  10. 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 #field instead of private field. The private keyword 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 Map when 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

private field

#field (true runtime private)

Type assertion

as any

as unknown then narrow with guards

Null checks

if (x != null)

x ?? fallback / x?.y

Dynamic keys

Record<string, T> + {}

Map<string, T>

Error throwing

throw new Error('not found')

throw new NotFoundError('User', id)

Many constructor params

8 positional arguments

Options object or Builder pattern

Cross-cutting concerns

Copy-paste log/retry/cache

@Log, @Retry, @Memoize decorators

Expensive init

Eager field initialization

Getter with lazy #field ?? compute()

Dependency wiring

new ConcreteClass() inside body

Constructor injection + composition root

Partial updates

Separate UpdateDto interface

Partial<Omit<Entity, 'id' | 'createdAt'>>


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)

Sort:
Sign in to join the discussion.