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();
},
};
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…
},
};
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];
},
};
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 }>;
}
…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>;
}
Now you can freely swap providers:
<UserCard userId="123" provider={restUserService} />
<UserCard userId="123" provider={graphqlUserService} />
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 }>;
}
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 };
},
};
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);
});
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
- Prop contracts that subtly differ
// Old
<Input value="abc" onChange={(v) => setValue(v)} />
// New (breaking)
<input value="abc" onChange={(e) => setValue(e.target.value)} />
Same props in name, different semantics — callers must now change. That’s an LSP break.
- 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
}
Props match, but the accessible behavior doesn’t — callers expect a “button,” not a “link.”
- Hooks that drift
// Old
function useAuth() { return { user, login, logout }; }
// New
function useAuth() { return { currentUser: user, signIn: login }; } // ❌ breaks every consumer
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)