L Liskov Substitution Principle LSP · How it works
1 min readRapid overview
How it works
L — Liskov Substitution Principle (LSP)
"Derived classes/implementations should be substitutable for their base classes/interfaces."
Implementations must behave consistently with their abstraction. Consumers should be able to use any implementation without special handling.
❌ Bad example (React/TypeScript):
interface DataSource {
fetch(): Promise<Data>;
refresh(): Promise<void>;
}
class ApiDataSource implements DataSource {
async fetch(): Promise<Data> {
const response = await fetch('/api/data');
return response.json();
}
async refresh(): Promise<void> {
// Re-fetch data
await this.fetch();
}
}
class StaticDataSource implements DataSource {
async fetch(): Promise<Data> {
return { items: [] }; // Static data
}
async refresh(): Promise<void> {
throw new Error('Static data cannot be refreshed'); // ❌ Violates LSP!
}
}
// Client code breaks when using StaticDataSource
function useData(source: DataSource) {
const handleRefresh = async () => {
await source.refresh(); // This might throw!
};
}
❌ Violates LSP — a StaticDataSource cannot refresh, so substituting it breaks code that expects all DataSource implementations to support refresh.
✅ Good example (React/TypeScript):
// Split into focused interfaces
interface DataSource {
fetch(): Promise<Data>;
}
interface RefreshableDataSource extends DataSource {
refresh(): Promise<void>;
}
class ApiDataSource implements RefreshableDataSource {
async fetch(): Promise<Data> {
const response = await fetch('/api/data');
return response.json();
}
async refresh(): Promise<void> {
await this.fetch();
}
}
class StaticDataSource implements DataSource {
async fetch(): Promise<Data> {
return { items: [] };
}
// No refresh method - doesn't claim to support it
}
// Client code uses appropriate interface
function useData(source: DataSource) {
const isRefreshable = (s: DataSource): s is RefreshableDataSource => {
return 'refresh' in s;
};
const handleRefresh = async () => {
if (isRefreshable(source)) {
await source.refresh();
}
};
}
✅ Better example with proper abstraction (React):
// Base behavior
interface Storage {
get(key: string): string | null;
set(key: string, value: string): void;
}
class LocalStorage implements Storage {
get(key: string): string | null {
return localStorage.getItem(key);
}
set(key: string, value: string): void {
localStorage.setItem(key, value);
}
}
class SessionStorage implements Storage {
get(key: string): string | null {
return sessionStorage.getItem(key);
}
set(key: string, value: string): void {
sessionStorage.setItem(key, value);
}
}
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get(key: string): string | null {
return this.store.get(key) ?? null;
}
set(key: string, value: string): void {
this.store.set(key, value);
}
}
// All implementations are perfectly substitutable
function useStorage(storage: Storage) {
const save = (key: string, value: string) => {
storage.set(key, value); // Works with any implementation
};
const load = (key: string) => {
return storage.get(key); // Works with any implementation
};
return { save, load };
}
✅ Good example (Angular):
// Base abstraction
export abstract class Logger {
abstract log(message: string): void;
abstract error(message: string): void;
}
// All implementations honor the contract
@Injectable()
export class ConsoleLogger extends Logger {
log(message: string): void {
console.log(message);
}
error(message: string): void {
console.error(message);
}
}
@Injectable()
export class RemoteLogger extends Logger {
constructor(private http: HttpClient) {
super();
}
log(message: string): void {
this.http.post('/api/logs', { level: 'info', message }).subscribe();
}
error(message: string): void {
this.http.post('/api/logs', { level: 'error', message }).subscribe();
}
}
// Silent logger for tests - still honors contract
@Injectable()
export class SilentLogger extends Logger {
log(message: string): void {
// No-op, but doesn't throw
}
error(message: string): void {
// No-op, but doesn't throw
}
}
// Client can use any logger
@Component({...})
export class MyComponent {
constructor(private logger: Logger) {}
doSomething() {
this.logger.log('Action performed'); // Works with any implementation
}
}
❌ Bad example (violates behavioral contract):
interface Cache<T> {
get(key: string): T | undefined;
set(key: string, value: T): void;
}
class InMemoryCache<T> implements Cache<T> {
private store = new Map<string, T>();
get(key: string): T | undefined {
return this.store.get(key);
}
set(key: string, value: T): void {
this.store.set(key, value);
}
}
class ReadOnlyCache<T> implements Cache<T> {
constructor(private data: Map<string, T>) {}
get(key: string): T | undefined {
return this.data.get(key);
}
set(key: string, value: T): void {
throw new Error('Cannot modify read-only cache'); // ❌ Violates LSP!
}
}
// Client expects all Cache implementations to support set
function cacheUser(cache: Cache<User>, user: User) {
cache.set(user.id, user); // Breaks with ReadOnlyCache!
}
✅ Good example (separate read/write concerns):
interface ReadableCache<T> {
get(key: string): T | undefined;
}
interface WritableCache<T> extends ReadableCache<T> {
set(key: string, value: T): void;
}
class InMemoryCache<T> implements WritableCache<T> {
private store = new Map<string, T>();
get(key: string): T | undefined {
return this.store.get(key);
}
set(key: string, value: T): void {
this.store.set(key, value);
}
}
class ReadOnlyCache<T> implements ReadableCache<T> {
constructor(private data: Map<string, T>) {}
get(key: string): T | undefined {
return this.data.get(key);
}
}
// Clients use appropriate interface
function readUser(cache: ReadableCache<User>, id: string): User | undefined {
return cache.get(id); // Works with both
}
function cacheUser(cache: WritableCache<User>, user: User): void {
cache.set(user.id, user); // Only accepts writable caches
}
💡 In frontend applications:
- React hooks: Custom hooks implementing the same interface should behave consistently.
- Services/APIs: All implementations of an API client should honor the same contract (return types, error handling).
- Components: Components accepting props/interfaces should work correctly with all valid implementations.
- Validation: All validators for a field type should follow the same contract (return ValidationError | null).
- State management: Different store implementations should maintain the same behavioral guarantees.