LSP defines what it means to be a subtype; it's not defined in terms of inheritance per se. It says that an instance of some type can be considered a subtype of some other type if providing that instance where this other type is expected doesn't produce surprises or breaks the code (in either subtle or obvious ways) - the paper from which it stems gives a more precise definition.
So it's not about the act of replacing stuff, it's about conforming to an abstraction defined by something else. So, whenever you're using inheritance or composition to plug into some other component, or library, or framework, you're applying LSP. Same if you're building some code that other components can extend or be plugged into. The notion of "replacement" is more in the sense that different implementations can be plugged in (e.g. a test double can be replaced with an actual implementation).
When an object is used and it is needed to replace it with an object of its subtype, it often goes along with a change in feature, so a change in behavior is expected
The behavior that LSP talks about is the abstract behavior as defined by the supertype (what it is, what it does and/or represents at that level of abstraction). It's not about the detailed behavior of the subtype, but about making sure that the subtype doesn't break the expectations set by the supertype.
Perhaps I haven't seen it because the inheritance is not always explicit?
That's a good hunch.
It's perhaps not obvious, but in dynamic languages, the abstraction may not even have an explicit representation, it could be entirely specified in the docs (or rather, the docs publicize the expectations of the (private) implementation details). Consider various array methods JavaScript provides, like map
or filter
or reduce
. These functions are written so that anyone can "plug into" the service they offer; you do it by providing your own function that conforms to certain expectations. E.g., for map
you have to map the given element to some object and return it. For filter
you have to return a boolean that indicates if the element should be kept. For reduce
you, have to return the accumulated/reduced value. Each of these specifies (1) what the function signature must look like, and (2) what the function itself does or represents, in the context of each array method. That's a type, that's the abstraction that you must confirm to. It's like a single-function interface, if you like.
Now, in this particular example, the abstractions are pretty general, and you can make use of them in all kinds of ways without breaking them. Obviously, if you don't confirm to the function signature required, they won't even work, so that's a blatant Liskov violation right there. But you can break them in more subtle ways - if you do something that technically works but that's too far outside these specs, you may end up introducing surprising behavior and bugs into your own code. Especially in a team setting, where people may have different assumptions about what someone else's code does. E.g., it would be bad if an innocuous-looking function someone passed to map
had undocumented side effects that only become apparent after examining the implementation1.
In frameworks like Angular, if you want to plug into lifecycle events of a component, you have to implement certain methods (e.g. ngAfterContentInit
). If you don't, that particular bit of functionality is unavailable to you. That's an also an example of LSP. In React functional components, you are expected to understand the fact that useState
relies on the call order (so they can't be in conditionals), and it's part of the "contract" that you shouldn't mutate the state, but that you should return a modified copy instead. That's also an abstraction2 you have to conform to.
The assumption that you (or anyone else) will confirm to these expectations in the sense of LSP, is what allows framework authors to write their code without knowing anything about your (or anyone else's) code.
Of course, you can use the same principles internally in your own projects (and you likely already have).
1 The problem is that, usually, we can't express everything we'd like about a type/abstraction using the features of the language itself, so we have to describe some of the assumptions and constraints in the documentation. (And even if we could, there's always the danger of overspecifying/overconstraining.) The Liskov & Wing paper is a somewhat abstract/mathy computer science paper that explores, among other things, some of the ways a language can be designed so that its users can express these constraints in code. In practice, if you're building these abstractions, you'll likely have to document some of the expectations outside of the public part of the code (the public interfaces, the public API). Conversely, if you're conforming to an abstraction, you'll have to take these accompanying semantics into account.
2 There's a reason all these principles use the word "abstraction", and not just "interface" or "abstract class" - "abstraction" is a more general term, it is any kind of "contract" between you some other reusable component. In general, considering other languages as well, this contract may be enforced by the compiler (e.g inheritance), perhaps in conjunction with the design (e.g. using some pattern, like Strategy), it may be based on conventions (like using a certain naming convention or doing things a certain way, and making use of libraries or tools that rely on that), etc.