Web Dev Drops

SOLID Principles in TypeScript (with Real Examples)

Douglas Matoso
Douglas Matoso
Published
Reading time: 16 min.

Hey, folks! The SOLID principles are five object-oriented design rules that help you write code that's easier to change and to test. The letters stand for Single responsibility, Open/closed, Liskov substitution, Interface segregation and Dependency inversion. In short: give each module one reason to change, add behavior without editing working code, make subtypes keep the promises of their parent, keep interfaces small, and make business logic depend on abstractions instead of databases and APIs.

The words SOLID Principles over a blue background, with the TypeScript logo on top of a stack of five white blocks

In this post we'll apply each principle to the same piece of code, an online store's checkout, instead of the usual Animal and Shape classes. I'll also point out where TypeScript helps you and where it lets a violation compile without a warning.

The examples were tested with TypeScript 7.0.2 in strict mode and Node.js 24.21, on Windows 11.

What are the SOLID principles?

The principles come from Robert C. Martin (Uncle Bob), who described them in his 2000 paper "Design Principles and Design Patterns". The acronym came a few years later, around 2004, from Michael Feathers. Two of the ideas are older than that: the open/closed principle is from Bertrand Meyer's 1988 book Object-Oriented Software Construction, and the substitution principle is from Barbara Liskov's 1987 keynote "Data Abstraction and Hierarchy".

Here is the short version of each one, using the wording Martin himself used in a 2020 review of the principles:

PrincipleIn one sentenceSmell that breaks it
Single responsibility (SRP)Gather together the things that change for the same reasons. Separate things that change for different reasons.One class touched by every kind of change request
Open/closed (OCP)A module should be open for extension but closed for modification.A switch you edit every time a new option shows up
Liskov substitution (LSP)A program that uses an interface must not be confused by an implementation of that interface.A subclass that throws "not supported"
Interface segregation (ISP)Keep interfaces small so that users don't end up depending on things they don't need.Test mocks full of empty methods
Dependency inversion (DIP)Depend in the direction of abstraction. High level modules should not depend upon low level details.new PostgresClient() inside business logic

They were written with classes in mind, but they work the same way with TypeScript interfaces, modules and plain functions. Let's see each one in practice.

The starting point: an order service

This is the code we'll refactor. It places an order: validates it, calculates the total with sales tax, saves it to the database and sends a confirmation email.

type OrderItem = { sku: string; price: number; quantity: number }
type Order = { id: string; customerEmail: string; items: OrderItem[] }

class OrderService {
  async placeOrder(order: Order) {
    if (order.items.length === 0) {
      throw new Error('Order has no items')
    }

    const subtotal = order.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    )
    const tax = subtotal * 0.08
    const total = subtotal + tax

    await db.query('INSERT INTO orders (id, email, total) VALUES ($1, $2, $3)', [
      order.id,
      order.customerEmail,
      total,
    ])

    await sendEmail(
      order.customerEmail,
      `Thanks for your order! Total: $${total.toFixed(2)}`,
    )
  }
}

It works, and for a small project it may be fine. The problems show up when the code starts to change.

Single responsibility principle (SRP)

The single responsibility principle is often summed up as "a class should do one thing". That's not quite what Martin wrote. His definition is that a module should have one reason to change, and in his post about SRP he makes it clear that the reasons are people: "It is people who request changes."

Look at placeOrder with that in mind. At least three different people could ask for changes to it:

  • Finance wants a different tax rule per state.
  • Whoever owns the database wants to rename a column.
  • Marketing wants a new text in the confirmation email.

All three edits land in the same method, so a change to the email copy can break the tax calculation in a merge. Let's separate the parts that change for different reasons:

function calculateTotal(items: OrderItem[], taxRate = 0.08): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  return subtotal * (1 + taxRate)
}

The pricing rule is now a pure function. It has no database or email in it, so testing it is one line: calculateTotal([{ sku: 'mug', price: 12.5, quantity: 2 }]) returns 27.

Saving and emailing go to their own modules too. The service keeps only the workflow, the order in which things happen:

class OrderService {
  async placeOrder(order: Order): Promise<number> {
    if (order.items.length === 0) {
      throw new Error('Order has no items')
    }

    const total = calculateTotal(order.items)
    await this.store.save(order, total)
    await this.mailer.send(
      order.customerEmail,
      `Thanks for your order! Total: $${total.toFixed(2)}`,
    )
    return total
  }
}

Where this.store and this.mailer come from is the subject of the last principle.

A module with three tangled cables split into three separate modules labeled Pricing, Storage and Email

Tip: SRP doesn't mean one method per class or one function per file. A module with ten functions that all change for the same reason is fine. Splitting things that always change together only spreads one change across more files.

Open/closed principle (OCP)

The checkout also needs shipping quotes. The first version usually looks like this:

function shippingQuote(carrier: string, weightKg: number): number {
  switch (carrier) {
    case 'ups':
      return 8 + weightKg * 1.5
    case 'usps':
      return weightKg <= 1 ? 5 : 5 + (weightKg - 1) * 2
    default:
      throw new Error(`Unknown carrier: ${carrier}`)
  }
}

Every new carrier means editing this function, retesting the carriers that already work, and in a real project each case grows into API calls and error handling. The open/closed principle says the module should be open for extension (you can add carriers) and closed for modification (you don't edit it to do so).

In TypeScript we get there with an interface and one class per carrier:

type Parcel = { weightKg: number; destinationZip: string }

interface ShippingCarrier {
  readonly id: string
  quote(parcel: Parcel): number
}

class UpsCarrier implements ShippingCarrier {
  readonly id = 'ups'
  quote(parcel: Parcel) {
    return 8 + parcel.weightKg * 1.5
  }
}

class UspsCarrier implements ShippingCarrier {
  readonly id = 'usps'
  quote(parcel: Parcel) {
    return parcel.weightKg <= 1 ? 5 : 5 + (parcel.weightKg - 1) * 2
  }
}

The calculator only knows the interface:

class ShippingCalculator {
  private readonly carriers = new Map<string, ShippingCarrier>()

  register(carrier: ShippingCarrier) {
    this.carriers.set(carrier.id, carrier)
    return this
  }

  quote(carrierId: string, parcel: Parcel): number {
    const carrier = this.carriers.get(carrierId)
    if (!carrier) {
      throw new Error(`Unknown carrier: ${carrierId}`)
    }
    return carrier.quote(parcel)
  }
}

Adding DHL is now a new class and one register call. ShippingCalculator, UPS and USPS stay untouched:

class DhlCarrier implements ShippingCarrier {
  readonly id = 'dhl'
  quote(parcel: Parcel) {
    return 12 + parcel.weightKg
  }
}

const shipping = new ShippingCalculator()
  .register(new UpsCarrier())
  .register(new UspsCarrier())
  .register(new DhlCarrier())

shipping.quote('dhl', { weightKg: 2, destinationZip: '10001' }) // 14

When a switch is still the right call

Not every switch breaks OCP. When the set of cases is closed and belongs to your domain, like the status of a payment, a union type with an exhaustive switch is simpler than a class per case. TypeScript can make sure you handle every case with a never check (exhaustiveness checking in the handbook):

type PaymentStatus = 'pending' | 'paid' | 'refunded'

function statusLabel(status: PaymentStatus): string {
  switch (status) {
    case 'pending':
      return 'Waiting for payment'
    case 'paid':
      return 'Paid'
    case 'refunded':
      return 'Refunded'
    default: {
      const unreachable: never = status
      throw new Error(`Unhandled status: ${unreachable}`)
    }
  }
}

If someone adds 'failed' to the union and forgets this function, the build fails:

error TS2322: Type '"failed"' is not assignable to type 'never'.

Use the interface when new cases come from outside (carriers, payment providers, plugins). Use the union when the list rarely changes and you want the compiler to point at every place that needs updating.

Liskov substitution principle (LSP)

The Liskov substitution principle says that code written against a type must keep working with any subtype of it. Barbara Liskov and Jeannette Wing formalized it in 1994 with a few rules: a subtype can't demand more from its inputs (preconditions can't be strengthened), can't deliver less (postconditions can't be weakened), and must keep the parent's invariants.

TypeScript checks that the method names and types match. It doesn't check behavior, and that's where LSP gets broken. Here's a common case in payments:

interface PaymentMethod {
  charge(amount: number): Promise<string>
  refund(transactionId: string, amount: number): Promise<void>
}

class GiftCard implements PaymentMethod {
  async charge(amount: number) {
    return `gift_tx_${amount}`
  }

  async refund() {
    throw new Error('Gift cards cannot be refunded')
  }
}

This compiles. But any code that refunds an order through a PaymentMethod now explodes at runtime when the customer paid with a gift card, and the only fix is an if (method instanceof GiftCard) in the caller, which is exactly the coupling the interface was supposed to remove.

The fix is to stop promising what some implementations can't deliver. Split the capability into its own interface:

interface PaymentMethod {
  charge(amount: number): Promise<string>
}

interface RefundablePaymentMethod extends PaymentMethod {
  refund(transactionId: string, amount: number): Promise<void>
}

class CreditCard implements RefundablePaymentMethod {
  async charge(amount: number) {
    return `card_tx_${amount}`
  }

  async refund(transactionId: string, amount: number) {
    // call the card processor's refund API
  }
}

class GiftCard implements PaymentMethod {
  async charge(amount: number) {
    return `gift_tx_${amount}`
  }
}

async function refundOrder(method: RefundablePaymentMethod, txId: string, amount: number) {
  await method.refund(txId, amount)
}

Now the mistake is a compile error instead of a production error:

error TS2741: Property 'refund' is missing in type 'GiftCard' but required in type 'RefundablePaymentMethod'.

A TypeScript trap: method parameters are bivariant

There's one LSP violation TypeScript accepts even in strict mode. Look at this subclass, which narrows the parameter type of the method it overrides:

type Payment = { amount: number }
type CardPayment = Payment & { cardToken: string }

class PaymentProcessor {
  process(payment: Payment): string {
    return `processed ${payment.amount}`
  }
}

class CardProcessor extends PaymentProcessor {
  process(payment: CardPayment): string {
    return `charged ${payment.cardToken.slice(-4)}`
  }
}

const processor: PaymentProcessor = new CardProcessor()
processor.process({ amount: 50 })

CardProcessor demands more than its parent (a cardToken), which is a strengthened precondition. It compiles without errors and crashes when it runs:

TypeError: Cannot read properties of undefined (reading 'slice')

The reason is in the strictFunctionTypes docs: the strict check "only applies to functions written in function syntax, not to those in method syntax". Methods keep the old, bivariant behavior for compatibility. If you declare the member as a function property (process: (payment: Payment) => string), the same narrowing becomes an error. So don't count on the compiler here: when you override a method, accept the same parameter type as the parent or a wider one.

Interface segregation principle (ISP)

The interface segregation principle says that clients shouldn't be forced to depend on methods they don't use. In a TypeScript backend the usual suspect is the repository that grows a method for every screen:

interface OrderRepository {
  findById(id: string): Promise<Order | undefined>
  findByCustomer(email: string): Promise<Order[]>
  save(order: Order, total: number): Promise<void>
  delete(id: string): Promise<void>
  exportCsv(from: Date, to: Date): Promise<string>
}

Now a function that resends a receipt asks for the whole OrderRepository, even though it only reads one order. Its tests need a fake with five methods, four of them empty, and any change to exportCsv shows up as a change to code that has nothing to do with CSV.

You could split this into OrderReader, OrderWriter and so on, and for a public library that's often the right move. Inside an app, TypeScript has a lighter option: ask only for the part you use with the Pick utility type.

async function resendReceipt(
  orders: Pick<OrderRepository, 'findById'>,
  mailer: Mailer,
  orderId: string,
) {
  const order = await orders.findById(orderId)
  if (!order) {
    throw new Error(`Order ${orderId} not found`)
  }
  await mailer.send(order.customerEmail, `Here is your receipt for order ${order.id}`)
}

The real repository still fits, because TypeScript types are structural. In a test you can pass a plain object:

await resendReceipt(
  { findById: async () => order },
  fakeMailer,
  'order-1',
)

The same idea applies to React components: a component that receives a whole user object to show the name depends on every field of user. Passing name alone, or typing the prop as Pick<User, 'name'>, keeps the component reusable. Form components are a good place to try it, like the ones in React forms with React Hook Form.

Dependency inversion principle (DIP)

Back to OrderService. In the first version it imported the database client and the email function directly. The business rule (how an order is placed) depended on details (which database, which email provider). The dependency inversion principle says it should be the other way around: high-level modules shouldn't depend on low-level modules; both should depend on abstractions.

In practice, the order module declares what it needs as interfaces:

interface OrderStore {
  save(order: Order, total: number): Promise<void>
}

interface Mailer {
  send(to: string, body: string): Promise<void>
}

And the service receives implementations in the constructor:

class OrderService {
  private readonly store: OrderStore
  private readonly mailer: Mailer

  constructor(store: OrderStore, mailer: Mailer) {
    this.store = store
    this.mailer = mailer
  }

  // placeOrder() as shown in the SRP section
}

The detail that makes this an inversion is who owns the interface. OrderStore lives next to OrderService, and the Postgres code implements it. The arrow now points from the database code to the business code, not the other way:

Diagram of dependency inversion: before, OrderService imports PostgresOrderStore; after, OrderService uses the OrderStore interface and PostgresOrderStore implements it

The real implementations get wired once, at the entry point of the app (often called the composition root):

// main.ts
const service = new OrderService(
  new PostgresOrderStore(pool),
  new SesMailer(sesClient),
)

And tests get fakes, with no mocking library and no database:

class InMemoryOrderStore implements OrderStore {
  readonly saved: Array<{ order: Order; total: number }> = []
  async save(order: Order, total: number) {
    this.saved.push({ order, total })
  }
}

class FakeMailer implements Mailer {
  readonly sent: Array<{ to: string; body: string }> = []
  async send(to: string, body: string) {
    this.sent.push({ to, body })
  }
}

const store = new InMemoryOrderStore()
const mailer = new FakeMailer()
const service = new OrderService(store, mailer)

await service.placeOrder({
  id: 'order-1',
  customerEmail: '[email protected]',
  items: [{ sku: 'mug', price: 12.5, quantity: 2 }],
})

console.log(store.saved.length) // 1
console.log(mailer.sent[0].body) // Thanks for your order! Total: $27.00

Note: I declared the fields explicitly instead of using parameter properties (constructor(private readonly store: OrderStore)). Both are valid TypeScript, but Node.js can now run .ts files directly by stripping types, and that mode rejects parameter properties with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. If you compile with tsc or use a bundler, either style works.

Dependency inversion vs dependency injection

The two names get mixed up a lot:

  • Dependency inversion is the design principle: business code depends on abstractions it owns, and details implement them.
  • Dependency injection is a technique: an object receives its dependencies from outside (constructor, parameter) instead of creating them.

Passing dependencies to the constructor, as we did, is dependency injection by hand. It's the most common way to get dependency inversion, but they're not the same: you can inject a concrete PostgresOrderStore and still have the business code depend on Postgres. You also don't need a DI container. Frameworks like NestJS have one built in, and it's handy in big apps, but a constructor and a main.ts are enough for most projects.

When not to apply SOLID

SOLID principles are guidelines for code that changes. Some code doesn't:

  • A script you'll run once doesn't need interfaces.
  • An interface with exactly one implementation and no test fake is extra indirection with no gain. Add it when the second implementation (or the test) shows up.
  • A switch with three stable cases is easier to read than three classes and a registry.

A good way to decide is to look at your change history. If the same file keeps showing up in unrelated pull requests, it's an SRP problem. If adding a provider means editing a dozen if statements, it's OCP. If tests need a database to run, start with DIP.

FAQ

Are SOLID principles still relevant in 2026?

Yes. Most tutorials still use old class hierarchies as examples, but the ideas work just as well with TypeScript modules and React components, as we saw above. Martin revisited all five in 2020 and restated them in shorter words, the ones I used in the table at the top of this post.

Do SOLID principles apply to functional programming and JavaScript?

Mostly, yes. In functional code an interface is a function type, and passing a function as a parameter is dependency injection. SRP and ISP apply directly. LSP and OCP matter less when you don't use inheritance, but they still apply to any function that accepts different implementations of the same type. In plain JavaScript you lose the compiler checks shown in this post, so the contracts live in documentation and tests.

Which SOLID principle is the most important?

There isn't an official ranking. In everyday TypeScript code, SRP and DIP tend to give the most return: SRP keeps changes contained, and DIP is what makes business logic testable without a database or network.

What's the difference between SOLID and design patterns?

SOLID principles are rules for judging a design. Design patterns (strategy, adapter, factory) are reusable solutions that often follow those rules. The shipping carriers in the OCP section, for example, are the strategy pattern.

Conclusion

The SOLID principles pay off on code that's already changing. Split a service when different people ask for different changes to it, and add an interface when a second implementation (or a test fake) shows up. TypeScript's interfaces and Pick cover a lot of the work. What the compiler won't catch is a subclass that throws "not supported" or narrows a method parameter, so keep an eye on those in code review.

If you're starting a TypeScript backend to try these ideas, see Node.js with TypeScript and, for a small project to practice on, how to build an MCP server in TypeScript.

See you next time!