DEV Community

Niels Søholm
Niels Søholm

Posted on

SOLID in React #3 — The Liskov Substitution Principle

Ever swapped out a service or component and suddenly half your UI broke?
You didn’t change what it did — you only changed how it did it.

That’s a Liskov Substitution Principle (LSP) problem hiding in plain sight.

Subtypes must be usable wherever their base type is expected.

In plain English:

If something claims to implement a contract, you should be able to plug it in anywhere that contract is expected — and everything still works the same.

When we violate LSP, we lose swappability — which makes abstraction pointless.

The smell: a “compatible” replacement that isn’t

Let’s say you abstract your data layer behind a simple UserService so you can later switch between REST and GraphQL.

// userService-rest.ts
export type User = { id: string; name: string };

export interface UserService {
  getUser(id: string): Promise<User>;
}

export const restUserService: UserService = {
  async getUser(id) {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  },
};
Enter fullscreen mode Exit fullscreen mode

Later, you add a GraphQL version:

// userService-graphql.ts
import { UserService } from "./userService-rest";

export const graphqlUserService: UserService = {
  async getUser(id) {
    const res = await fetch("/graphql", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        query: `{ user(id: "${id}") { id name } }`,
      }),
    });
    const { data } = await res.json();
    return data.user; // seems fine…
  },
};
Enter fullscreen mode Exit fullscreen mode

Looks fine — same shape, same types.
But then someone “optimizes” it:

// graphqlUserService.ts (oops)
export const graphqlUserService: UserService = {
  async getUser(id) {
    const res = await fetch("/graphql", { /* ... */ });
    const { data } = await res.json();
    // ⚠️ returns an array instead of a single object
    return [data.user];
  },
};
Enter fullscreen mode Exit fullscreen mode

TypeScript doesn’t complain (the return type is any from fetch),
but the rest of the app now gets [ { id, name } ] instead of { id, name }.

Everything still “compiles,” but consumers start throwing runtime errors.
That’s a textbook LSP violation — the new subtype broke the behavioral contract.

The LSP mindset: define and respect contracts

LSP isn’t about types matching on paper; it’s about semantics being consistent.

If you define a contract like this:

export interface UserService {
  /** 
   * Fetch a user by id.
   * 
   * Resolves to a single user object when found, 
   * rejects with an error if not found or network fails.
   */
  getUser(id: string): Promise<{ id: string; name: string }>;
}
Enter fullscreen mode Exit fullscreen mode

…then every implementation must honor it.
That means:

  • Always resolve to one object (not array or partial)
  • Throw or reject on error, not return null
  • Preserve property names and types

That’s what allows your UI components to stay decoupled and substitutable.

Applying LSP in a React context

Here’s a React component that depends on that contract:

// UserCard.tsx
import React, { useEffect, useState } from "react";
import type { UserService } from "./userService-rest";

type Props = {
  userId: string;
  provider: UserService;
};

export function UserCard({ userId, provider }: Props) {
  const [user, setUser] = useState<{ id: string; name: string } | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    provider.getUser(userId)
      .then(setUser)
      .catch((e) => setError(e.message));
  }, [userId, provider]);

  if (error) return <p>Error: {error}</p>;
  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Now you can freely swap providers:

<UserCard userId="123" provider={restUserService} />
<UserCard userId="123" provider={graphqlUserService} />
Enter fullscreen mode Exit fullscreen mode

If both implementations adhere to the same contract, UserCard doesn’t care where the data came from.
That’s LSP enabling flexibility.

Enforcing LSP with TypeScript and contract tests

TypeScript already enforces structural compatibility — but we can go further by locking down semantics and verifying them with tests.

1. Strengthen the type

Let’s make the interface explicit and strict:

// userService.ts
export interface UserService {
  getUser(id: string): Promise<{ id: string; name: string }>;
}
Enter fullscreen mode Exit fullscreen mode

Then ensure every implementation explicitly declares it:

export const graphqlUserService: UserService = {
  async getUser(id) {
    const res = await fetch("/graphql", { /* ... */ });
    const { data } = await res.json();
    // ✅ return exactly the same shape
    return { id: data.user.id, name: data.user.name };
  },
};
Enter fullscreen mode Exit fullscreen mode

If someone later tries to return an array, TypeScript will immediately flag it.

2. Behavioral contract tests

Even with types, semantics can drift.
So write a small test suite that verifies any implementation of UserService behaves correctly.

// userService.contract.test.ts
import type { UserService } from "./userService-rest";
import { restUserService } from "./userService-rest";
import { graphqlUserService } from "./userService-graphql";

async function assertUserService(provider: UserService) {
  const user = await provider.getUser("123");
  expect(user).toHaveProperty("id");
  expect(user).toHaveProperty("name");
  expect(typeof user.id).toBe("string");
}

test("REST service follows contract", async () => {
  await assertUserService(restUserService);
});

test("GraphQL service follows contract", async () => {
  await assertUserService(graphqlUserService);
});
Enter fullscreen mode Exit fullscreen mode

If either provider breaks the contract (wrong shape, missing fields, bad error behavior), the test catches it immediately — long before runtime.

This enforces behavioral substitutability, not just type compatibility.

Common LSP pitfalls in frontend

  1. Prop contracts that subtly differ
   // Old
   <Input value="abc" onChange={(v) => setValue(v)} />

   // New (breaking)
   <input value="abc" onChange={(e) => setValue(e.target.value)} />
Enter fullscreen mode Exit fullscreen mode

Same props in name, different semantics — callers must now change. That’s an LSP break.

  1. Visual components with mismatched accessibility
   function Button({ onClick }: { onClick: () => void }) {
     return <button onClick={onClick}>Save</button>;
   }

   // “Replacement”
   function LinkButton({ onClick }: { onClick: () => void }) {
     return <a onClick={onClick}>Save</a>; // ❌ breaks keyboard behavior
   }
Enter fullscreen mode Exit fullscreen mode

Props match, but the accessible behavior doesn’t — callers expect a “button,” not a “link.”

  1. Hooks that drift
   // Old
   function useAuth() { return { user, login, logout }; }

   // New
   function useAuth() { return { currentUser: user, signIn: login }; } // ❌ breaks every consumer
Enter fullscreen mode Exit fullscreen mode

Hooks are contracts too — changing shape or semantics breaks substitution.

Quick heuristics

Before swapping a component, service, or hook, ask:

  • Can consumers use it without code changes?
  • Does it behave the same under the same inputs?
  • Would tests for the old one still pass with the new one?

If not — you’ve broken LSP.

Wrap-up

The Liskov Substitution Principle is what makes abstraction work.
It ensures that when you define an interface, every implementation can stand in for it safely — keeping your components, hooks, and services decoupled and interchangeable.

In React, this translates to honoring contracts:

  • Component props mean what they say
  • Hooks return consistent shapes
  • Services behave the same regardless of backend

When every part of your system keeps its promises, refactoring stops feeling risky — and flexibility becomes effortless.

Top comments (0)