Skip to content

SOLID Principles in TypeScript — A Practical Code Guide

Learn SOLID principles through TypeScript examples from real scenarios, auth services, HTTP clients, and event handlers instead of shapes and animals.

· · 8 min read
A developer writing TypeScript code with interfaces and class definitions visible on screen

Quick Take

Learn SOLID principles through TypeScript examples from real scenarios, auth services, HTTP clients, and event handlers instead of shapes and animals.

SOLID principles appear in every clean code list. Most tutorials explain them with Circle, Rectangle, and Animal classes. It works as a teaching device, but it's hard to carry back to real work, with practical examples.

TypeScript changes the equation. Interfaces aren't just documentation, they're enforceable contracts. When you violate the Interface Segregation Principle, the compiler complains. When you wire up Dependency Inversion correctly, tests write themselves. The type system becomes your architecture guardrail.

I've refactored enough production TypeScript to know which violations hurt the most. Here they are, with real examples from clean code patterns you can actually use.

TL;DR:

  • S, Single Responsibility: one class, one reason to change
  • O, Open/Closed: extend behavior without editing existing code
  • L, Liskov Substitution: subtypes must behave like their base type
  • I, Interface Segregation: small focused interfaces beat one fat one
  • D, Dependency Inversion: depend on abstractions, inject concretions

What Is Single Responsibility, and Why Do Auth Services Break It?

Single Responsibility means one class has one reason to change. According to Robert C. Martin's Clean Architecture, a module should be responsible to exactly one actor, one business function that could demand a change. When a class changes for two different reasons, those reasons will eventually conflict.

Here's an AuthService I see all the time. It handles login, sends a welcome email, and logs every attempt, all in one place.

// Bad: AuthService is doing three different jobs
class AuthService {
  async login(email: string, password: string) {
    const user = await this.db.findUser(email);
    if (!user || !this.verifyPassword(password, user.hash)) {
      throw new Error("Invalid credentials");
    }
    // Why is email logic inside an auth service?
    await this.mailer.send(user.email, "You logged in");
    // Why is logging interleaved with business logic?
    this.logger.info(`Login attempt: ${email}`);
    return this.generateToken(user);
  }
}

If the email template changes, you edit AuthService. If the log format changes, you edit AuthService. That's two unrelated reasons to touch authentication logic.

The fix splits responsibilities cleanly.

// Good: each service has one job
class AuthService {
  constructor(
    private db: UserRepository,
    private tokenService: TokenService
  ) {}

  async login(email: string, password: string): Promise<string> {
    const user = await this.db.findUser(email);
    if (!user || !this.verifyPassword(password, user.hash)) {
      throw new Error("Invalid credentials");
    }
    return this.tokenService.generate(user);
  }
}

class LoginEventHandler {
  async onLogin(user: User): Promise<void> {
    await this.mailer.sendLoginNotification(user);
    this.logger.info(`Login: ${user.email}`);
  }
}

Each class now changes for exactly one reason. The TypeScript strict mode flags missing injected dependencies at compile time, another free guardrail.

For more on this style of small, focused class design, our TypeScript clean code patterns collects 15 of the patterns SRP tends to push you toward in practice.

How Does Open/Closed Prevent If-Else Hell in Payment Processing?

Open/Closed means your code is open for extension but closed for modification. Every time you add a payment method by editing a switch statement, you're one typo away from breaking existing payment paths. The TypeScript Handbook's section on interfaces shows exactly how polymorphism solves this.

The same principle holds for design tokens - every time you hard-code a color value in a component, you've closed it for theme extension. The Figma design system guide on Art of Styleframe covers how to structure tokens so a Color or Spacing value stays single-responsibility on the design side; the TypeScript patterns below carry that same intent into code.

The bad version looks like this.

// Bad: adding a new payment method requires editing this function
async function processPayment(type: string, amount: number) {
  if (type === "stripe") {
    return await stripe.charge(amount);
  } else if (type === "paypal") {
    return await paypal.pay(amount);
  } else if (type === "crypto") {
    return await crypto.transfer(amount);
  }
  throw new Error("Unknown payment type");
}

Adding Apple Pay means editing this function. Every edit risks a regression. Every merge conflict touches business logic.

The interface approach extends without touching existing code.

// Good: new payment methods are additive, not modifications
interface PaymentMethod {
  charge(amount: number): Promise<PaymentResult>;
}

class StripePayment implements PaymentMethod {
  async charge(amount: number): Promise<PaymentResult> {
    return await stripe.charge(amount);
  }
}

class PayPalPayment implements PaymentMethod {
  async charge(amount: number): Promise<PaymentResult> {
    return await paypal.pay(amount);
  }
}

// The processor never changes when you add a new method
async function processPayment(method: PaymentMethod, amount: number) {
  return await method.charge(amount);
}

Adding Apple Pay? Write a new class. Don't touch processPayment. Ever.

The payoff shows up the next time requirements shift. With the if/else approach, every new payment method means editing processPayment and risking the branches that already work. With the interface approach, you add a class and touch nothing that was already shipping.

Why Does Liskov Substitution Matter for Repository Patterns?

Liskov Substitution says a subtype must be fully substitutable for its base type. Anywhere you use a Repository, you should be able to drop in a ReadOnlyRepository without things breaking. If you can't, your inheritance hierarchy is wrong.

The violation I've seen most often is a read-only repo that extends a writable one.

// Bad: ReadOnlyRepository breaks the contract of Repository
class Repository<T> {
  async findById(id: string): Promise<T> { ... }
  async save(entity: T): Promise<void> { ... }
}

class ReadOnlyRepository<T> extends Repository<T> {
  async save(entity: T): Promise<void> {
    // This blows up at runtime, violates LSP
    throw new Error("This repository is read-only");
  }
}

Code that accepts a Repository<T> will explode when handed a ReadOnlyRepository<T>. That's a runtime surprise buried in a type-safe-looking codebase.

The fix is separate interfaces, not inheritance.

// Good: separate interfaces, no inherited broken contracts
interface Readable<T> {
  findById(id: string): Promise<T>;
  findAll(): Promise<T[]>;
}

interface Writable<T> {
  save(entity: T): Promise<void>;
  delete(id: string): Promise<void>;
}

interface Repository<T> extends Readable<T>, Writable<T> {}

// ReadOnlyRepository only implements what it can actually do
class ReadOnlyUserRepository implements Readable<User> {
  async findById(id: string): Promise<User> { ... }
  async findAll(): Promise<User[]> { ... }
}

Now the compiler enforces the contract. Pass a Readable<User> where a Readable<User> is expected, TypeScript won't let you accidentally call .save() on it.

Sweeping a fat interface into a single Readable/Writable pair is one of the cleanest fixes for the TypeScript anti-patterns you tend to see in long-lived service code.

Does Interface Segregation Really Matter for a UserService?

It matters more than most developers expect. A fat UserService interface with 8 methods means every consumer, even one that only reads user data, depends on methods it never calls. When the email-sending method changes, the read-only report generator recompiles for no reason.

// Bad: every consumer must implement all 8 methods
interface UserService {
  findById(id: string): Promise<User>;
  findAll(): Promise<User[]>;
  create(data: CreateUserDto): Promise<User>;
  update(id: string, data: UpdateUserDto): Promise<User>;
  delete(id: string): Promise<void>;
  sendWelcomeEmail(user: User): Promise<void>;
  resetPassword(email: string): Promise<void>;
  exportToCsv(): Promise<string>;
}

Split it by what callers actually need.

// Good: focused interfaces, callers depend only on what they use
interface UserReader {
  findById(id: string): Promise<User>;
  findAll(): Promise<User[]>;
}

interface UserWriter {
  create(data: CreateUserDto): Promise<User>;
  update(id: string, data: UpdateUserDto): Promise<User>;
  delete(id: string): Promise<void>;
}

interface UserNotifier {
  sendWelcomeEmail(user: User): Promise<void>;
  resetPassword(email: string): Promise<void>;
}

Your report service accepts UserReader. Your admin panel accepts UserWriter. Your email handler accepts UserNotifier. Changes to one don't ripple through the others.

In my experience, this pattern cuts the blast radius of interface changes by more than half in mid-size codebases.

How Does Dependency Inversion Make TypeScript Services Testable?

Dependency Inversion says high-level modules shouldn't depend on low-level ones, both should depend on abstractions. In practice this means: don't import a concrete logger or database client directly. Accept an interface in the constructor.

The violation is everywhere.

// Bad: hard dependency on a specific logger, untestable and inflexible
import { WinstonLogger } from "./winston-logger";

class OrderService {
  private logger = new WinstonLogger();

  async placeOrder(order: Order): Promise<void> {
    this.logger.info(`Placing order ${order.id}`);
    // ...
  }
}

To test OrderService, you must deal with Winston. To swap to a different logger, you edit every service that uses it.

Constructor injection with an interface fixes both problems.

// Good: depend on the abstraction, inject the concretion
interface Logger {
  info(message: string): void;
  error(message: string, err?: Error): void;
}

class OrderService {
  constructor(private logger: Logger) {}

  async placeOrder(order: Order): Promise<void> {
    this.logger.info(`Placing order ${order.id}`);
    // ...
  }
}

// Production: inject Winston
const service = new OrderService(new WinstonLogger());

// Tests: inject a spy, zero config
const mockLogger: Logger = { info: jest.fn(), error: jest.fn() };
const testService = new OrderService(mockLogger);

This is the principle I'd apply first in any legacy TypeScript codebase. It's the fastest path from "tests are a nightmare" to "tests write themselves."

SOLID in TypeScript isn't textbook theory, it's enforced by interfaces and structural typing. The compiler catches the violations that code reviews miss. Each principle reduces the blast radius when something changes: fewer files touched, fewer regressions, fewer surprises.

Start with one. I'd pick Dependency Inversion. Then look at your TypeScript anti-patterns and you'll spot the rest naturally.

Frequently Asked Questions

Do SOLID principles apply to TypeScript functions, not just classes?
Absolutely. Single Responsibility applies to functions and modules just as much as classes. Open/Closed applies to function composition and higher-order functions. Dependency Inversion shows up whenever a function accepts a callback or an interface instead of importing a concrete implementation. TypeScript's structural typing makes it especially natural to apply SOLID at the function level.
How does TypeScript's structural typing help with Liskov Substitution?
Structural typing means TypeScript checks shape compatibility, not nominal inheritance. If a subtype adds a method that throws unconditionally, TypeScript won't catch that as a type error, but it is a behavioral violation. The solution is to design separate interfaces for read-only and read-write behavior, then TypeScript enforces correct usage by contract rather than by hope.
Is it overkill to apply Interface Segregation to a small project?
It depends on how fast the project grows. I've seen small projects where one fat interface grew to 12 methods in six months, and nobody dared change it because every consumer depended on all of it. Splitting interfaces early is cheap. Splitting them later, when there are 40 call sites, is painful. Start with focused interfaces even if your first implementation happens to implement several of them.
What is the easiest SOLID principle to start with in an existing TypeScript codebase?
Dependency Inversion gives the fastest wins in existing code. Find a class that directly imports a logger, database client, or HTTP client. Extract an interface for that dependency. Inject it via the constructor. You'll immediately be able to swap in a mock during testing, and the change is usually 10-15 lines. Single Responsibility requires more design thinking, start with Dependency Inversion.