You absolutely should prefer composition (including mixins) over single-ancestor class inheritance, so you're on the right track. That said, JavaScript doesn't have private properties as you might know them from other languages. We use closures for data privacy in JS.
For composable prototypes with real data privacy (via closure), what you're looking for is functional mixins, which are functions that take an object and return an object with new capabilities added.
However, in my opinion, it's usually better practice to do your functional inheritance using composable factories (such as stamps). AFAIK, Stampit is the most widely-used implementation of composable factories.
A stamp is a composable factory function that returns object instances based on its descriptor. Stamps have a method called .compose()
. When called the .compose()
method creates new stamp using the current stamp as a base, composed with a list of composables passed as arguments:
const combinedStamp = baseStamp.compose(composable1, composable2, composable3);
A composable is a stamp or a POJO (Plain Old JavaScript Object) stamp descriptor.
The .compose()
method doubles as the stamp’s descriptor. In other words, descriptor properties are attached to the stamp .compose()
method, e.g. stamp.compose.methods
.
Composable descriptor (or just descriptor) is a meta data object which contains the information necessary to create an object instance.
A descriptor contains:
methods
— A set of methods that will be added to the object’s delegate prototype.
properties
— A set of properties that will be added to new object instances by assignment.
initializers
— An array of functions that will run in sequence. Stamp details and arguments get passed to initializers.
staticProperties
— A set of static properties that will be copied by assignment to the stamp.
Basic questions like “how do I inherit privileged methods and private data?” and “what are some good alternatives to inheritance hierarchies?” are stumpers for many JavaScript users.
Let’s answer both of these questions at the same time using init()
and compose()
from the stamp-utils
library.
compose(…composables: [...Composable]) => Stamp
takes any number of composables and returns a new stamp.
init(…functions: [...Function]) => Stamp
takes any number of initializer functions and returns a new stamp.
First, we’ll use a closure to create data privacy:
const a = init(function () {
const a = 'a';
Object.assign(this, {
getA () {
return a;
}
});
});
console.log(typeof a()); // 'object'
console.log(a().getA()); // 'a'
It uses function scope to encapsulate private data. Note that the getter must be defined inside the function in order to access the closure variables.
Here’s another:
const b = init(function () {
const a = 'b';
Object.assign(this, {
getB () {
return a;
}
});
});
Those a
’s are not typos. The point is to demonstrate that a
and b
’s private variables won’t clash.
But here’s the real treat:
const c = compose(a, b);
const foo = c();
console.log(foo.getA()); // 'a'
console.log(foo.getB()); // 'b'
WAT? Yeah. You just inherited privileged methods and private data from two sources at the same time.
There are some rules of thumb you should observe when working with composable objects:
- Composition is not class inheritance. Don't try to model is-a relationships or think of things in terms of parent/child relationships. Instead, use feature-based thinking.
myNewObject
needs featureA
, featureB
and featureC
, so: myNewFactory = compose(featureA, featureB, featureC); myNewObject = myNewFactory()
. Notice that myNewObject
is not an instance of featureA
, featureB
, etc... instead, it implements, uses, or contains those features.
- Stamps & mixins should not know about each other. (No implicit dependencies).
- Stamps & mixins should be small. Introduce as few new properties as possible.
- When composing, you can and should selectively inherit only the props you need, and rename props to avoid collisions.
- Prefer modules for code reuse whenever you can (which should be most of the time).
- Prefer functional programming for domain models and state management. Avoid shared mutable state.
- Prefer higher order functions and higher order components over inheritance of any kind (including mixins or stamps).
If you stick to those guidelines, your stamps & mixins will be immune to common inheritance problems such as the fragile base class problem, the gorilla/banana problem, the duplication by necessity problem, etc...