L Liskov Substitution Principle LSP · How it works

1 min read
Mid-level2 min read
Rapid 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.

See also