Design Pattern
SOLID

Liskov Substitution Principle

Les sous-types doivent être substituables à leurs types parents

Principe

Formulé par Barbara Liskov en 1987 : si S est un sous-type de T, alors tout objet de type T peut être remplacé par un objet de type S sans altérer le comportement du programme.

Autrement dit : une sous-classe (ou une implémentation d'interface) ne doit pas casser le contrat établi par son type parent.

Violation classique

L'exemple canonique est le rectangle/carré :

class Rectangle {
  constructor(protected width: number, protected height: number) {}

  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }

  area() { return this.width * this.height; }
}

class Square extends Rectangle {
  // Un carré impose width === height
  setWidth(w: number) {
    this.width = w;
    this.height = w; // brise le contrat de Rectangle
  }
  setHeight(h: number) {
    this.height = h;
    this.width = h; // brise le contrat de Rectangle
  }
}

function resizeAndCalculate(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(10);
  // Attendu : 50. Avec un Square : 100
  console.log(rect.area());
}

resizeAndCalculate(new Rectangle(2, 2)); // 50
resizeAndCalculate(new Square(2));       // 100 -> comportement inattendu

Square ne peut pas remplacer Rectangle sans changer le comportement. La hiérarchie est incorrecte.

En TypeScript : interfaces et contrats

interface Storage {
  get(key: string): string | null;
  set(key: string, value: string): void;
  remove(key: string): void;
}

class LocalStorage implements Storage {
  get(key: string) { return localStorage.getItem(key); }
  set(key: string, value: string) { localStorage.setItem(key, value); }
  remove(key: string) { localStorage.removeItem(key); }
}

class InMemoryStorage implements Storage {
  private store = new Map<string, string>();
  get(key: string) { return this.store.get(key) ?? null; }
  set(key: string, value: string) { this.store.set(key, value); }
  remove(key: string) { this.store.delete(key); }
}

// Les deux implémentations sont substituables
function saveUserPreference(storage: Storage, key: string, value: string) {
  storage.set(key, value);
}

saveUserPreference(new LocalStorage(), 'theme', 'dark');
saveUserPreference(new InMemoryStorage(), 'theme', 'dark'); // même comportement

En React : composants et props

LSP s'applique aussi aux composants. Un composant qui étend un autre (via les props) ne doit pas casser les attentes.

interface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
  disabled?: boolean;
}

// Button de base
function Button({ onClick, children, disabled }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {children}
    </button>
  );
}

// IconButton doit respecter le contrat de ButtonProps
function IconButton({ onClick, children, disabled, icon }: ButtonProps & { icon: React.ReactNode }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {icon}
      {children}
    </button>
  );
}

// IconButton est substituable à Button partout où ButtonProps est attendu

Signal d'alarme

Une implémentation qui lève une exception ou ignore une méthode de l'interface parent viole LSP.

class ReadOnlyStorage implements Storage {
  get(key: string) { return localStorage.getItem(key); }
  set(key: string, value: string) {
    throw new Error('Read only'); // viole le contrat
  }
  remove(key: string) {
    throw new Error('Read only'); // viole le contrat
  }
}

Si ReadOnlyStorage ne peut pas implémenter Storage sans casser son contrat, c'est que Storage est trop large. C'est un problème d'ISP (principe suivant).