JS: OO/FP Composition Styles
"haha javascript go brrrrrrr"
Preface
Point 13: There should be one– and preferably only one –obvious way to do it.
- The Zen of Python
We JavaScript developers get it - There’s a lot of different ways to write JavaScript code for any given task, perhaps too many ways 😩
As writing code involves - to a very large extent - composing (ie. combining) code in certain ways, let us explore how this looks like in JavaScript for both Object-Oriented (OO) and Functional Programming (FP) styles.
Important Concepts
Under the hood, JavaScript only uses Prototypal Inheritance.
- JavaScript doesn’t have real Class-based Inheritance - JavaScript emulates Class-based Inheritance through the use of the prototype chain.
Attribution
This article draws heavily on Eric Elliott’s excellent writeup on Common Misconceptions About Inheritance in JavaScript. I highly recommend that you read it too! Also, thanks Eric!
Context
The code examples are built around this problem space:
- Declare an
Animal
base type/parent class that supportsname:string
getName: () => string
- Declare a
Dog
type/subclass that supportsAnimal
interactionstricks: string[]
getTricks: () => string[]
- Declare a
Cat
type/subclass that supportsAnimal
interactionsisSprayed: boolean
checkIsSprayed: () => boolean
Language: TypeScript
Example code: oo-fp-composition
JS: OO Styles
1. Delegation: ES6 class
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
getName(): string {
return this.name;
}
}
class Dog extends Animal {
tricks: string[];
constructor(name: string, tricks: string[]) {
super(name);
this.tricks = tricks;
}
getTricks(): string[] {
return this.tricks;
}
}
class Cat extends Animal {
isSprayed: boolean;
constructor(name: string, isSprayed: boolean) {
super(name);
this.isSprayed = isSprayed;
}
checkIsSprayed(): boolean {
return this.isSprayed;
}
}
export const jetDog = new Dog("Jet", ["fetch"]);
export const jonCat = new Cat("Jon", true);
This is the canonical OO Class-based Inheritance syntax - No surprises here.
The class
syntax is actually a huge boon for everyone, since it hides away the complexities and standardizes the implementation of Class-based Inheritance in JavaScript - Developers no longer have to handwrite this, which can be an error-prone process.
2. Delegation: proto
const animalProto = {
getName(): string {
return (this as any).name;
},
};
const dogMixin = {
getTricks(): string[] {
return (this as any).tricks;
},
};
const catMixin = {
checkIsSprayed(): boolean {
return (this as any).isSprayed;
},
};
const makeDog = (name: string, tricks: string[]) => {
const dog = Object.assign(Object.create(animalProto), dogMixin, {
name,
tricks,
});
return dog;
};
const makeCat = (name: string, isSprayed: boolean) => {
const cat = Object.assign(Object.create(animalProto), catMixin, {
name,
isSprayed,
});
return cat;
};
export const jetDog = makeDog("Jet", ["fetch"]);
export const jonCat = makeCat("Jon", true);
Here’s what’s going on:
- Use
Object.create()
to create a new object withanimalProto
as its prototype, thereby reusing thegetName()
logic. - Assign
dogMixin/catMixin
to new object to add instance methods to object - Assign instance variables (eg.
name
,tricks,
isSprayed`) to make instance variables available to instance methods.
Key points to note:
- Instance methods access instance variables through the own object’s
this
reference. - For
getName()
theDog
/Cat
objects access its functionality through climbing down the prototype chain. - For
getTricks()
/checkIsSprayed()
, theDog
/Cat
objects access its functionality through the object’s own immediate property (no delegation required)
3. Mixin - Object.assign()
const animalMixin = {
getName(): string {
return (this as any).name;
},
};
const dogMixin = {
getTricks(): string[] {
return (this as any).tricks;
},
};
const catMixin = {
checkIsSprayed(): boolean {
return (this as any).isSprayed;
},
};
const makeDog = (name: string, tricks: string[]) => {
const dog = Object.assign({}, animalMixin, dogMixin, { name, tricks });
return dog;
};
const makeCat = (name: string, isSprayed: boolean) => {
const cat = Object.assign({}, animalMixin, catMixin, { name, isSprayed });
return cat;
};
export const jetDog = makeDog("Jet", ["fetch"]);
export const jonCat = makeCat("Jon", true);
The key difference between OO Delegate proto
and OO Mixin is that the getName()
functionality is no longer accessed by prototype chain, but as a method with the object’s own property.
4. Functional Inheritance
const animalMixin = (name: string) => {
return {
getName(): string {
return name;
},
};
};
const dogMixin = (tricks: string[]) => {
return {
getTricks(): string[] {
return tricks;
},
};
};
const catMixin = (isSprayed: boolean) => {
return {
checkIsSprayed(): boolean {
return isSprayed;
},
};
};
const makeDog = (name: string, tricks: string[]) => {
const dog = Object.assign({}, animalMixin(name), dogMixin(tricks));
return dog;
};
const makeCat = (name: string, isSprayed: boolean) => {
const cat = Object.assign({}, animalMixin(name), catMixin(isSprayed));
return cat;
};
export const jetDog = makeDog("Jet", ["fetch"]);
export const jonCat = makeCat("Jon", true);
This example takes the OO Mixin a step further by implementing data hiding - external code can no longer access the instance variables of the objects. In fact, the objects don’t have instance variables, but just instance methods!
JS: FP Styles
1. Pointfree
export type AnimalType = { name: string };
export type DogType = AnimalType & { tricks: string[] };
export type CatType = AnimalType & { isSprayed: boolean };
export const getName = (a: AnimalType): string => a.name;
export const getTricks = (d: DogType): string[] => d.tricks;
export const checkIsSprayed = (c: CatType): boolean => c.isSprayed;
const makeDog = (name: string, tricks: string[]): DogType => {
return {
name,
tricks,
};
};
const makeCat = (name: string, isSprayed: boolean): CatType => {
return {
name,
isSprayed,
};
};
export const jetDog: DogType = makeDog("Jet", ["fetch"]);
export const jonCat: CatType = makeCat("Jon", true);
In pointfree style, objects are pure data structures with neither instance methods nor mutating methods.
All methods are implemented as functions that accept interfaces.
Note: Ok, I know that strictly speaking, it should be prop('tricks')
, and not d.tricks
. Man, it’s hard being in the FP club 😢
2. Inline Handlers
export type AnimalType = { name: string, getName(): string };
export type DogType = AnimalType & { tricks: string[], getTricks(): string[] };
export type CatType = AnimalType & {
isSprayed: boolean,
checkIsSprayed(): boolean,
};
const makeGetNameHandler = (name: string) => () => name;
const makeDog = (name: string, tricks: string[]): DogType => {
return {
name,
tricks,
getName: makeGetNameHandler(name),
getTricks: () => tricks,
};
};
const makeCat = (name: string, isSprayed: boolean): CatType => {
return {
name,
isSprayed,
getName: makeGetNameHandler(name),
checkIsSprayed: () => isSprayed,
};
};
export const jetDog: DogType = makeDog("Jet", ["fetch"]);
export const jonCat: CatType = makeCat("Jon", true);
This pattern of attaching functions to objects enjoys high cohesion, and is quite a common pattern in JS codebases.
Also, this approach is almost a 1-1 match to the OO Mixin style 🧐
Observations & Takeaways
- OO Mixin and OO FI styles are strikingly similar to FP styles, which surprised me 🤔 (gets off my FP high-horse 😂)
- OO Delegation ES6, in the long run, suffers from leaky abstraction and fragile base class problems, as already alluded to in Eric Elliott’s article.
- FP styles + OO Mixin + OO FI styles avoid the two aforementioned problems because they allow you to include only the exact functionality in an object. Composition over Inheritance QED 🤩
-
FP Pointfree is a better option in terms of refactoring, as you’d be able to refactor methods (which are smaller code units) into their own files eg.
getDogTricks.ts
, instead of refactoring handler generators to their own files eg.makeDogMethods.ts
. This is useful for scenarios when methods get too big. Alas, we’re nitpicking here. Potato, Po-tah-toh 🤷♂️- I initially thought that FP Pointfree and FP Inline Handlers would turn out to be around the same, but alas, a worked example has given us better clarity 👍
-
In my humble opinion, reusing functionality via Mixin style or FP styles are a clearer means of doing so vis-a-vis Delegation style, owing to a ‘flatter’ dependency structure.
Why bother with the prototype chain when you can just create an instance method as a property that exists at the same level of the object?
How about achieving the same outcome by declaring methods that exist outside of the object, ala pointfree style?