A fast, lightweight, zero-dependency ts/js package designed to restore the rightful throne to the most powerful and elegant aspect of OOP: multiple inheritance.
Simple, flexible, and ready to roll!
Multiple inheritance is the most powerful pattern in object-oriented programming (OOP).
Of course, like any other weapon, it can be dangerous. Sure, you could accidentally shoot yourself with it.
That’s why the creators of many modern programming languages decided, “Hey, let’s take away this dangerous toy from the user, and just to be safe, let’s strap them into a straightjacket!”
Guys, that doesn’t work! Single inheritance — the one thing users (for now) still have — often leads to ugly application architecture. It turns what could’ve been a reasonable representation of reality using classes into the nightmare of a binge-drinking stranger.
If I met the owner of an airline in a flophouse, and he offered me to create an app for his company, the conversation might go like this:
Him: Hey, you seem like a smart person. How about writing an app for my airline?
Me: Well, that’s an unexpected offer, but it sounds interesting. What kind of app do you need?
Him: I want something better than what my competitors have. It should be simple, user-friendly, and, of course, cool!
In the morning, hungover, I remember this conversation and start brainstorming the architecture.
Here’s how we can describe the airplane crew for our app:
Describing this with classes is easier than taking candy from a baby:
class Captain {
scoldPassengers() { return 'This? This is me being polite. Wait till I actually start!'; };
turnOnAutopilot() { return 'Does anyone know where the damn button is?'; };
}
class CoPilot {
playMusic() { return 'Oh, come on, my singing’s fine.'; };
useGPS() { return 'The GPS isn’t working: I left my phone somewhere.'; };
}
class FlightEngineer {
tellJokes() { return 'So, an Irishman walks into a pub…'; };
wakeUpCrew() { return 'First, someone needs to wake me up.'; };
}
"Perfect!" says the company owner after looking at the result of our work. "Just one small comment. You see, my airline isn’t exactly in the top ten global leaders (by the way, will $5 be enough for your work?). So, not all flights have three crew members. For some routes, I hire just one person who can handle all the crew’s duties."
And that’s when we realize our app is heading for a plane crash! Implementing such a simple and natural thing without multiple inheritance... damn, it’d be easier to explain to my mom why I’m still single!
But we’re not here to talk about the problems. We’re here to talk about the solution.
Fortunately, neither our earnings nor our reputation are at risk: the Alchemist package brings simplicity back to these simple things:
import { alchemize } from '@lenka/alchemist';
class Captain {
scoldPassengers() { return 'This? This is me being polite. Wait till I actually start!'; };
turnOnAutopilot() { return 'Does anyone know where the damn button is?'; };
}
class CoPilot {
playMusic() { return 'Oh, come on, my singing’s fine.'; };
useGPS() { return 'The GPS isn’t working: I left my phone somewhere.'; };
}
class FlightEngineer {
tellJokes() { return 'So, an Irishman walks into a pub…'; };
wakeUpCrew() { return 'First, someone needs to wake me up.'; };
}
const SuperHero = alchemize(Captain, CoPilot, FlightEngineer);
const superman = new SuperHero();
superman.scoldPassengers();
superman.playMusic();
superman.tellJokes();
// ...
And that’s it!
So far, nothing too complicated, right?
But there’s still something unclear.
alchemize() is supposed to combine several classes into one. But how is it supposed to do that?
For example, the original classes take some parameters in their constructors:
class Volume {
constructor(private length: number, private width: number, private height: number) {}
calculateVolume(): number {
return this.length * this.width * this.height;
}
}
class Person {
constructor(private firstName: string, private lastName: string) {}
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
class Volume {
#length;
#width;
#height;
constructor(length, width, height) {
this.#length = length;
this.#width = width;
this.#height = height;
}
calculateVolume() {
return this.#length * this.#width * this.#height;
}
}
class Person {
#firstName;
#lastName;
constructor(firstName, lastName) {
this.#firstName = firstName;
this.#lastName = lastName;
}
get fullName() {
return `${this.#firstName} ${this.#lastName}`;
}
}
Obviously, our combined class should also take all those parameters:
const VolumeOfPerson = alchemize(Volume, Person);
const instance = new VolumeOfPerson(25, 18, 38, 'John', 'Dow');
But how will it know what to do with them?
So, what do we usually do in plain old JavaScript when working with single inheritance? In the constructor of the derived class, we call the constructor of the base class (using super(...)) and pass it some of the arguments — the ones it needs for its initialization.
alchemize(...) works in a pretty similar way. By default, when you create an instance (using new Combined(...)), it initializes all the input classes and passes all the received parameters to each constructor.
Obviously, this isn’t always the correct behavior.
This is where recipes come to the rescue, helping us solve this and other challenges. Let’s dive right into them!
To create something extraordinary (a panacea, the sorcerer’s stone, or grandma’s apple pie), every alchemist needs a recipe.
In the Alchemist package, recipe(...) is a function that takes a set of instructions and returns an object with a configured alchemize(...) function:
import { recipe, Recipe } from '@lenka/alchemist';
// For your convenience, Alchemize offers a handy helper
// type called Recipe.
const prescription: Recipe = { instanceOfSupport: true };
class SomeClassA {};
class SomeClassB {};
// You can either chain the call like this...
const CombinedClass =
recipe(prescription).alchemize(SomeClassA, SomeClassB);
// ...or you can save the result into a variable if you plan
// to reuse the same settings in multiple places and call
// alchemize later:
const tunedAlchemize = recipe(prescription).alchemize;
const TheSameCombinedClass =
tunedAlchemize(SomeClassA, SomeClassB);
const instance = new CombinedClass(...);
import { recipe } from '@lenka/alchemist';
const prescription = { instanceOfSupport: true };
class SomeClassA {};
class SomeClassB {};
// You can either chain the call like this...
const CombinedClass =
recipe(prescription).alchemize(SomeClassA, SomeClassB);
// ...or you can save the result into a variable if you plan
// to reuse the same settings in multiple places and call
// alchemize later:
const tunedAlchemize = recipe(prescription).alchemize;
const TheSameCombinedClass =
tunedAlchemize(SomeClassA, SomeClassB);
const instance = new CombinedClass(...);
recipe(...) takes a single parameter – an object with two optional keys:
Unfortunately, even with instanceOfSupport
enabled, the instanceof operator will still return
false for built-in JavaScript classes.
If passOutParamRules is not an empty array, its number of elements must exactly match the number of classes passed as parameters to alchemize(): the n-th element of passOutParamRules determines which arguments will be passed to the constructor of class provided as the n-th parameter to the alchemize() function.
When creating an instance of the combined class, its internal constructor goes through this array from left to right, applying the rules one by one.
Elements of passOutParamRules can be:
All of this might sound intimidating, but in reality, passOutParamRules just describes parameter splitting in the simplest and most straightforward way:
// Let's say we want to alchemize five classes and properly
// distribute the parameters among them:
class A {};
class B {};
class C {};
class D {};
class E {};
// That means our passOutParamRules should have five elements.
// For example:
passOutParamRules: [-2, '...', 1, -1, 0];
// Let's say we initialize an instance of the alchemized class
// with seven parameters:
const ourMegaInstance =
new AlchemizedClass('a', 2, { k: 8}, 10, [9], 'zz', 5);
// Internal AlchemizedClass constructor go through the passOutParamRules
// from left to right, applying the rules one by one:
//
// Step 1 - rule "-2":
// constructor A takes last 2 params ('zz' and 5).
// rest of params after this step: 'a', 2, { k: 8}, 10, [9]
// Step 2 - rule '...':
// this rule is temporarily skipped.
// rest of params after this step: 'a', 2, { k: 8}, 10, [9]
// Step 3 - rule "1":
// constructor C takes 1 first param ('a').
// rest of params after this step: 2, { k: 8}, 10, [9]
// Step 4 - rule "-1":
// constructor D takes 1 last param ([9]).
// rest of params after this step: 2, { k: 8}, 10
// Step 5 - rule "0":
// constructor E takes no parameters.
// rest of params after this step: 2, { k: 8}, 10
// Step 6 - apply postponed '...' rule:
// constructor B takes rest of params (2, { k: 8}, 10)
Here's a detailed example that clearly shows what, where, when, and how everything is done. Open the spoiler to check it out!
import { recipe, Recipe } from '@lenka/alchemist';
/**
* Let's say we need to merge four such classes:
*/
// Class A: takes two arguments
class A {
constructor(private a: number, private b: number) {}
sum(): number { return this.a + this.b}
}
// Class B: takes no arguments
class B {
whereAreYou(): string { return "I'm here!"; }
}
// Class C: takes any number of arguments
class C {
constructor(...args: number[]) {
this.args = args;
}
max(): number { return Math.max(...this.args); }
private args: number[];
}
// Class D: takes one argument
class D {
constructor(private n: number) {}
plus(b: number): number { return this.n + b }
}
/**
* We're going to alchemize them in this order: A, B, C, D.
* Alright, let's get started!
*/
/**
* The first thing we need to do is define the parameter distribution
* rules for the constructors of the original classes.
* Since the constructor of class C can take any number of arguments,
* we want to preserve this flexibility for the combined class as well.
* So, our combined class will be able to accept any number of parameters:
* const combinedInstance = new Combined(a, b, c, d, ..., n);
* At the same time:
* - class A will always receive the first two parameters.
* - class B won’t receive any parameters at all.
* - class D will get the last parameter.
* - class C will take everything else in between.
* So, our rules will look like this:
*/
const passOutParamRules: Recipe['passOutParamRules'] = [2, 0, '...', -1];
/**
* Next, Let's set up a customized alchemize that will create combined
* classes with this behavior:
*/
const customizedAlchemize = recipe({ passOutParamRules }).alchemize;
/**
* And let’s call it to create our combined class:
*/
const CombinedClass = customizedAlchemize(A, B, C, D);
/**
* Note: Of course, we could have done all of this in a single step.
* This directly creates the combined class without needing to
* store customizedAlchemize separately. However, defining alchemize
* beforehand can be useful if we plan to reuse the same rules
* multiple times.
*/
const TheSameCombinedClass = recipe({
passOutParamRules: [2, 0, '...', -1],
}).alchemize(A, B, C, D);
/**
* We’re all set! Now we can create instances of CombinedClass
* and see it in action.
*/
const combo = new CombinedClass(10, 11, 12, 13, 14, 15);
console.log(combo.sum()); // 21 (10 + 11)
console.log(combo.whereAreYou()); // I'm here!
console.log(combo.max()); // 14 (maximum of 12, 13, 14)
console.log(combo.plus(5)); // 20 (15 + 5)
File not found