L Liskov Substitution Principle LSP

8 min read
Rapid overview

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.

Questions & Answers

  • Implementations that throw errors for base interface methods
  • Type guards checking specific implementations before calling methods
  • Components that check instanceof or type properties before using props
  • Tests that fail when swapping one implementation for another
Q: How do you detect LSP violations in React/TypeScript?

A: Watch for:

Q: How does LSP relate to prop contracts in React?

A: Components accepting interfaces/types must work correctly with all valid props. Don't add runtime checks like if (props.type === 'special') that break substitutability. Use separate component types or composition instead.

  • Require stricter input (e.g., non-null when base accepts null)
  • Provide weaker output (e.g., returning undefined when base guarantees a value)
  • Throw different exceptions than documented in the base contract
Q: How can preconditions/postconditions break LSP in TypeScript?

A: Derived types shouldn't:

Q: When should you refactor inheritance into composition in frontend code?

A: When implementations need to disable base behavior or add flags to skip inherited logic. Use composition with focused interfaces instead:

// Bad: inheritance with disabled methods
class BaseForm extends Component {
  submit() { /* ... */ }
  reset() { /* ... */ }
}

class ReadOnlyForm extends BaseForm {
  submit() { throw new Error('Read-only'); } // ❌
}

// Good: composition
interface Form {
  render(): ReactNode;
}

interface SubmittableForm extends Form {
  submit(): void;
}
Q: How do you test for LSP compliance in frontend code?

A: Create contract tests that run against the base interface:

describe('Storage contract', () => {
  const implementations = [
    new LocalStorage(),
    new SessionStorage(),
    new MemoryStorage()
  ];

  implementations.forEach(storage => {
    it(`${storage.constructor.name} honors contract`, () => {
      storage.set('key', 'value');
      expect(storage.get('key')).toBe('value');
    });
  });
});
Q: How does LSP affect React component prop types?

A: All components accepting the same prop interface should work identically. Don't create components that require specific prop combinations that violate the interface contract:

// Bad
interface ButtonProps {
  onClick?: () => void;
  disabled?: boolean;
}

// This component violates LSP if it requires onClick when not disabled
function Button({ onClick, disabled }: ButtonProps) {
  if (!disabled && !onClick) {
    throw new Error('onClick required'); // ❌
  }
}

// Good - make requirements explicit in the type
interface ButtonProps {
  onClick: () => void;
  disabled?: boolean;
}
Q: How does LSP influence error handling in TypeScript?

A: Implementations should not introduce unexpected errors. If the base promises to handle a case, implementations shouldn't throw:

// Base contract: always returns a value
interface Parser {
  parse(input: string): ParsedData;
}

// Bad: violates LSP by throwing
class StrictParser implements Parser {
  parse(input: string): ParsedData {
    if (!input) throw new Error('Empty input'); // ❌
  }
}

// Good: honors contract
class StrictParser implements Parser {
  parse(input: string): ParsedData {
    if (!input) return { valid: false, data: null };
    // Parse and return
  }
}
Q: What design smell indicates an LSP issue in React?

A: Component code checking specific implementation types:

// Bad - LSP violation
function DataList({ source }: { source: DataSource }) {
  if (source instanceof ApiDataSource) {
    // Special handling for API source ❌
  } else if (source instanceof CachedDataSource) {
    // Different handling for cached source ❌
  }
}

// Good - polymorphic behavior
function DataList({ source }: { source: DataSource }) {
  const data = source.fetch(); // Works for all implementations ✅
}
Q: How does LSP tie into React hooks?

A: Custom hooks with the same interface should be substitutable:

// Both hooks must honor the same contract
function useLocalState(key: string): [string, (value: string) => void];
function useRemoteState(key: string): [string, (value: string) => void];

// Component works with either
function MyComponent() {
  const [value, setValue] = useLocalState('key'); // or useRemoteState
  // Usage is identical
}
Q: How does LSP apply to Angular dependency injection?

A: All implementations of an injected abstract class or interface token must be substitutable. Use dependency injection to swap implementations without breaking consumers:

// Abstract token
export abstract class ApiClient {
  abstract get<T>(url: string): Observable<T>;
}

// Implementations must honor contract
@Injectable()
export class HttpApiClient extends ApiClient {
  get<T>(url: string): Observable<T> {
    return this.http.get<T>(url);
  }
}

@Injectable()
export class MockApiClient extends ApiClient {
  get<T>(url: string): Observable<T> {
    return of(mockData as T);
  }
}

// Component works with any implementation
constructor(private api: ApiClient) {}