В реальной жизни
Наследование предполагает иерархическую структуру сущностей, но с такими структурами есть проблемы, например — когда одна из сущностей не вписывается в эту иерархию.
Индикатор такой проблемы — проверки на принадлежность типу или классу перед выполнением какой-то операции или перед возвращением результата.
LSP помогает выявлять проблемные абстракции при проектировании и строить иерархию сущностей с учётом подобных проблем.
Как и принцип открытости-закрытости OCP, LSP подводит к выводу, что большие и сложные иерархии сущностей, основанные на наследовании, — это хрупкий и опасный инструмент, вместо которого лучше использовать композицию.
Иерархия пользователей
В одном из проектов стояла задача построить иерархию пользовательских ролей. Разработчики столкнулись с проблемой, когда один из типов пользователей не вписывался в существовавшую иерархию.
В проекте был класс User, который описывал сущность пользователя приложения. В нём были методы для работы с сессией, определением прав этого пользователя и обновлением профиля:
class User {
  constructor() {
    // ...
  }
  getSessionID(): ID {
    return this.sessID
  }
  hasAccess(action: Actions): boolean {
    // ...
    return access
  }
  updateProfile(data: Profile): CommandStatus {
    // ...
    return status
  }
}
Класс покрывал собой все роли пользователей, которые существовали в начале проекта: админ, руководитель группы пользователей, обычный пользователь.
В какой-то момент в приложении появился «гостевой режим». У гостей были ограниченные права, и не было профиля. Из-за отсутствия профиля в классе Guest метод updateProfile усиливал своё предусловие:
// Гости наследуются от пользователей:
class Guest extends User {
  constructor() {
    super()
  }
  hasAccess(action: Actions): boolean {
    // Описываем логику доступов для гостей:
    return access
  }
  updateProfile(data: Profile): CommandStatus {
    // А вот тут проблема: у гостей профиля нет,
    // из-за чего приходится выбрасывать исключение.
    // Гостевой режим как бы заставляет нас учитывать большее количество
    // обстоятельств, прежде чем выполнить обновление профиля:
    throw new Error(`Guests don't have profiles`)
  }
}
Применяем LSP
Попробуем решить проблему, применив LSP. Согласно принципу Guest должен быть заменяем на класс, от которого он наследуется, а приложение при этом не должно взрываться.
Введём общий интерфейс User, который будет содержать всё общее, что есть у гостей и пользователей.
interface User {
  getSessionID(): ID
}
Для описания доступов и работы с данными профиля создадим отдельные интерфейсы: UserWithAccess и UserWithProfile:
// Здесь всё, что относится к доступам:
interface UserWithAccess {
  hasAccess(action: Actions): boolean
}
// Здесь всё, что относится к профилю:
interface UserWithProfile {
  updateProfile(data: Profile): CommandStatus
}
Опишем базовый класс; от него будут наследоваться остальные классы гостей и пользователей:
class BaseUser implements User {
  constructor() {
    // ...
  }
  getSessionID(): ID {
    return this.sessID
  }
}
// У обычных пользователей добавляем методы
// для работы с профилем и для работы с доступами:
class RegularUser extends BaseUser implements UserWithAccess, UserWithProfile {
  constructor() {
    super()
  }
  hasAccess(action: Actions): boolean {
    // ...
    return access
  }
  updateProfile(data: Profile): CommandStatus {
    // ...
    return status
  }
}
// Для гостей же достаточно описать только доступы:
class Guest extends BaseUser implements UserWithAccess {
  constructor() {
    super()
  }
  hasAccess(action: Actions): boolean {
    // ...
    return access
  }
}
Теперь обновлять профиль мы можем только у сущностей, которые реализуют интерфейс UserWithProfile. Из-за этого проверять, является ли пользователь гостем, перед обновлением данных профиля не нужно, ведь гости не реализуют этот интерфейс, а значит такой функциональности у них нет.
Композиция или наследование
ООП — не про наследование и классы, а про отношение между сущностями и их поведение. В нём вполне успешно можно применять композицию — когда разные свойства объектов сочетаются в новом объекте.
При описании класса RegularUser в примере выше мы указали, что он реализует два интерфейса UserWithAccess и UserWithProfile. Каждый из интерфейсов отвечает за какую-то часть функциональности, которую мы сочетаем в RegularUser — это и есть композиция.
Преимущество композиции — в более высокой абстрактности, которая позволяет строить более гибкие отношения между сущностями.
React и JSX
Ещё один пример LSP — это React-компоненты, а точнее их реализация в JSX (JavaScript XML). Синтаксис JSX построен таким образом, что мы можем писать разметку компонентов в HTML-подобном виде:
interface ComponentProps {
  title: string
}
// Пример React-компонента:
const ExampleReactComponent: FunctionComponent<ComponentProps> = ({ title }) => (
  <div>
    <h1>{title}</h1>
    <OtherComponent />
  </div>
)
В примере выше можно заметить, что наряду с «обычными HTML-тегами» (<div> и <h1>) также рендерится и <OtherComponent /> — другой React-компонент.
Возможность использовать компоненты точно так же, как «обычные теги», — это тоже реализация принципа подстановки Лисков. Мы можем заменить «обычный тег» на компонент, потому что и те и другие — это реализация ReactElement, который описывает, как именно они должны себя вести.
Материалы к разделу
- Liskov Substitution Principle, Hackernoon
 - Liskov Substitution Principle, Maksim Ivanov
 - How does strengthening of preconditions and weakening of postconditions violate Liskov substitution principle?
 - Composition over inheritance
 - Liskov Substitution Principle and the Composition Root - A Perspective
 - Introducing JSX