When should you prefer inheritance?
Like S.Lotti said when it has an clear and unambigous 'IS-A' relationship with its parent:
in other cases should avoid it.
A formal criterion apart of commonsense is Liskov Substitution. If it semantically makes sense, you need all the methods of the parent and also satisfies Liskov then it is a candidate for behavioral subtyping, which can be expressed via an IS-A'
For the same reason you avoid inheritance in non-dynamic languages.
Inheritance is the strongest coupling relationship. Often times too strong, inflexibly so.
What you're expressing is that the child class must have an is-a relationship with the parent and what you're implying is that type: child is a subset of type: parent, therefore the parent can be substituted with the child without the potential for crashes or incorrect behaviour.
Otherwise it violates the Liskov Principle.. which means you can't safely call polymorphic methods; there's no guarantee they would always work, correctly.
When an is-a relationship is established, it's expected that you should be able to treat all items sharing that relationship, uniformly, through their polymorphic interface without the potential for issues; otherwise an is-a relationship is incorrect and pretty much pointless.
The classroom example is the Circle/Ellipse problem:
Example:
I have an Ellipse class and assume a Circle is
just an Ellipse with a fixed rx/ry so I model it as: Circle extends Ellipse
class Ellipse {
constructor(rx, ry) {
this.rx = rx
this.ry = ry
}
resize(rx, ry) {
this.rx = rx
this.ry = ry
return this
}
}
class Circle extends Ellipse {
constructor(radius) {
super(radius, radius)
}
resize(radius) {
this.resizeWidth(radius)
this.resizeHeight(radius)
return this
}
}
// not really relevant,
// assume they are implemented
class Rectangle {}
class Triangle {}
Now suppose I have this list of shapes:
const shapes = [
new Ellipse(20, 30),
new Rectangle(20, 10),
new Triangle(10, 5, 10),
new Circle(10),
new Ellipse(20, 20)
]
I decide to resize all Circles:
shapes
.filter(shape => shape instanceof Circle)
.map(circle => circle.resize(50))
// Circle rx: 50, ry: 50
All good, no issues here.
But if I were to attempt a method on the parent:
shapes
.filter(shape => shape instanceof Ellipse)
.map(ellipse => ellipse.resizeWidth(50))
// Ellipse rx: 50, ry: 30
// Circle rx: 50, ry: 10 <-- oops
// Ellipse rx: 50, ry: 20
ok, so it didn't throw an Error.
... but the Circle was stretched horizontally;
therefore it's no longer a Circle.
It's invariant was violated and the program is now incorrect.
All I've done is a run-of-the-mill call of a method assumed to be polymorphic. So an is-a relationship is not correct, even though it looks very appropriate. A different & more complex hierarchy needs to be built if you need to squeeze as much code reuse & as much polymorphism as possible.
Mixins allow attaching behaviour without creating this is-a relationship.
They allow breaking up the parent type into multiple, smaller & more specific parent types from which you can selectively subtype, so the above principle isn't violated.
For example:
Circle: extendsShape&RadialShapeEllipse: extendsShape&RadialShape
Note that you can make sure the Liskov principle isn't violated by simply avoid subtyping altogether.
However that misses one of the major points of OOP, polymorphism; which is the ability to treat all subsets of a type in a uniform method.
The point is to find a middle ground where the minimal amount of code is duplicated, maximum polymoprhism by having a model with the minimal amount of types & at the same avoid caveats such as this one.