DEV Community

Cover image for Understanding Liskov Substitution Principle (LSP) in TypeScript and React
Akbar Julian Khatibi
Akbar Julian Khatibi

Posted on

Understanding Liskov Substitution Principle (LSP) in TypeScript and React

Welcome to another article in the series where we demystify the SOLID principles. Today we'll focus on the Liskov Substitution Principle (LSP), an essential part of writing maintainable, robust code in object-oriented programming.

What is Liskov Substitution Principle?

The Liskov Substitution Principle, named after Barbara Liskov, states that objects in a program should be replaceable with instances of their subtypes without altering the correctness of the program.

In simpler terms, it means that a class should be able to reference a base or derived class without affecting its behavior.

Why is LSP important?

When properly implemented, the Liskov Substitution Principle promotes code reusability and enhances the robustness of the software system. LSP reduces the likelihood of bugs and makes your code easier to understand and maintain.

Liskov Substitution Principle in TypeScript

Let's start with a simple example in TypeScript to illustrate the Liskov Substitution Principle:

class Bird {
    fly() {
        console.log('The bird is flying');
    }
}

class Duck extends Bird {}

const makeBirdFly = (bird: Bird) => {
    bird.fly();
}

makeBirdFly(new Bird()); // Output: The bird is flying
makeBirdFly(new Duck()); // Output: The bird is flying

Enter fullscreen mode Exit fullscreen mode

In this example, we have a Bird class and a Duck class that extends Bird. The makeBirdFly function takes a Bird instance as a parameter and invokes the fly method. As per the LSP, you can replace the Bird instance with a Duck instance without affecting the correctness of the makeBirdFly function.

Applying LSP in React

Now, let's consider a scenario where the Liskov Substitution Principle is violated: We can create a Clickable type that includes an onClick prop:

type ClickableProps = {
  onClick: () => void;
};

const Button: React.FC<ClickableProps> = ({ onClick }) => {
  return <button onClick={onClick}>Button</button>;
};

// Label doesn't have an onClick prop, because it's not meant to be clickable
const Label: React.FC = () => {
  return <label>Label</label>;
};

Enter fullscreen mode Exit fullscreen mode

Now, we might have some logic that requires handling a click event on a component:

type ClickHandler = (components: React.FC<ClickableProps>[]) => void;

const handleClick: ClickHandler = (components) => {
  components.forEach((Component) => {
    // We'll render the component into a "virtual DOM" to trigger the onClick
    const { onClick } = render(<Component />);
    onClick();
  });
};

Enter fullscreen mode Exit fullscreen mode

This would work fine with Button, but if we try to use Label in this way, TypeScript won't compile because Label doesn't have an onClick prop. This demonstrates the Liskov Substitution Principle: Label cannot be used in place of Button because it doesn't fulfil the contract of having an onClick handler.

We've prevented the issue at compile-time, thanks to TypeScript's type system. This prevents us from running into a runtime error where our function tries to call onClick on a Label.

Note: We're using a render function to "virtually" trigger the onClick in this contrived example. In a real-world scenario, you would more likely pass these components around, include them in JSX, and let the user interact with them to trigger any click handlers.

Let's consider another example of a base Button component:

class Button extends React.Component {
  onClick() {
    console.log('Button clicked');
  }

  render() {
    return <button onClick={this.onClick}>Button</button>
  }
}

Enter fullscreen mode Exit fullscreen mode

Now, if we were to create a SubmitButton that extends Button:

class SubmitButton extends Button {
  onClick() {
    console.log('Submitting...');
    super.onClick();
  }
}

Enter fullscreen mode Exit fullscreen mode

According to LSP, any instance where Button is used, SubmitButton should also be able to be used without issues:

const App = () => {
  return (
    <div>
      <Button />
      <SubmitButton />
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Applying LSP in React with Functional Components

Let's consider the base Button component:

import React from 'react';

const Button = ({ onClick, children }) => {
  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
};

export default Button;

Enter fullscreen mode Exit fullscreen mode

This Button component is a simple functional component that receives an onClick callback and children as props, and renders a button.

Now, we'll create a SubmitButton that extends Button in behavior:

import React from 'react';
import Button from './Button';

const SubmitButton = ({ onClick, children }) => {
  const handleClick = () => {
    console.log('Submitting...');
    onClick && onClick();
  };

  return (
    <Button onClick={handleClick}>
      {children}
    </Button>
  );
};

export default SubmitButton;

Enter fullscreen mode Exit fullscreen mode

In this SubmitButton, we have an additional console.log in the handleClick function which will run before any other onClick behavior passed to the SubmitButton.

According to LSP, any place where Button is used, SubmitButton should also be able to be used without causing any issues:

import React from 'react';
import Button from './Button';
import SubmitButton from './SubmitButton';

const App = () => {
  const handleClick = () => {
    console.log('Button clicked');
  };

  return (
    <div>
      <Button onClick={handleClick}>Button</Button>
      <SubmitButton onClick={handleClick}>SubmitButton</SubmitButton>
    </div>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

Here, both Button and SubmitButton are used interchangeably, and SubmitButton performs some additional behavior before running the passed onClick behavior, following the Liskov Substitution Principle.

Flaws

The Liskov Substitution Principle (LSP) is a useful guideline in object-oriented programming that helps ensure that a subclass can replace its superclass without causing incorrect behavior or errors. However, like all principles, it's not without its limitations or criticisms. Here are some of the potential issues:

1. Overly Restrictive: LSP can be seen as too restrictive, especially in dynamic languages or those with more flexible type systems. Sometimes, certain subclasses inherently have different behaviors or properties that make them incompatible with the superclass, even if they share a majority of their characteristics. Strict adherence to LSP can limit the design of such classes.

2. Misinterpretation and Over-Engineering: LSP is often misunderstood, and a strict interpretation can lead to over-engineering. Developers might be tempted to write more complex code just to fulfill the LSP when simpler, more straightforward code could solve the problem more efficiently.

3. Doesn't Fully Solve Inheritance Problems: Even with LSP, problems with inheritance can still occur. For instance, a subclass might need only part of the superclass's functionality, or it might require a different implementation that doesn't fit neatly into the superclass's method signatures.

Consider an application that has a Button component with a click handler. Now, you want to add a DisabledButton that looks the same as Button, but doesn't respond to clicks.

interface ButtonProps {
  onClick: () => void;
}

const Button: React.FC<ButtonProps> = ({ onClick }) => {
  return <button onClick={onClick}>Click me</button>;
};

const DisabledButton: React.FC<ButtonProps> = ({ onClick }) => {
  return <button disabled onClick={onClick}>Can't click me</button>;
};

// Using the components
<Button onClick={() => console.log('Button clicked!')} />
<DisabledButton onClick={() => console.log('Disabled button clicked!')} />

Enter fullscreen mode Exit fullscreen mode

This is where it gets tricky with the Liskov Substitution Principle. According to LSP, a subclass should be substitutable for its superclass without causing any issues. In this case, even though DisabledButton and Button share the same interface (ButtonProps), they behave differently. If you were to replace Button with DisabledButton in your code, the onClick prop wouldn't function as expected, which violates the LSP.

Wrapping Up

The Liskov Substitution Principle is a powerful principle that helps us to write more robust and maintainable code. By adhering to this principle, we ensure that our codebase stays flexible and less prone to bugs.

Remember, SOLID principles are guidelines, not hard rules. There will be times when following these principles might not be practical. However, understanding these principles can guide you towards writing better code.

Top comments (0)