Rusty Composition in TypeScript

This article describes how to use strict composition in TypeScript in a similar way to how it is used in Rust.

To be honest, for years I read about composition over inheritance everywhere, however I never truely understood how to make use of it or even how to change my coding habits. However, since the moment I finally understood Rust's composition system, I loved it and started to miss it in every single other place. Need some functionality? Just require it and don't care about the actual types. That's... WOW! I think, the composition system is also one of the success factors of Rust, even though it takes a bit of getting used to for anyone coming from another language without composition.

Since I started playing around in TypeScript more and more, not being able to do composition has always been a thorn in my eye. Now, I want to take a moment to introduce you to my results. I hope I can motivate you to go composition over inheritance, and maybe improve what I was able to create :)

What is Composition?

First of all, though, let's take a step back. What exactly is composition? Most of you should be familiar with TypeScript's inheritance model.

class Animal {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

class Cat extends Animal {
    talk() {
        console.log('nyan~');
    }
}

class Dog extends Animal {
    talk() {
        console.log('wan wan, wan!');
    }
}

function makeSound(animal: Animal) {
    animal.talk();
}

const cat = new Cat('Lili');
console.log(cat.name);
makeSound(cat);

So, we have the classes Cat and Dog, which extend Animal, thereby inheriting functionality from them. Except for declaration duplication (you define talk() on both, Cat and Dog), all of that is good, until you come to a point, where you would like to inherit from multiple classes. That's not possible in TypeScript and would force you to use Mixins. Mixins are nice, because they pull in functionality from several sources, but they are FLAWED. What if multiple classes define the same method. You'd have to start deciding on a case-by-case basis, which to inherit. What if you don't even want all the functionality from the classes which you logically want to extend? Inheriting functionality is bad. It is flawed and you should not do it.

That's where composition comes in. With composition, you basically mix several classes, which define behavior/functionality/abilities instead of establishing a relation between things. In the above example, we wouldn't implement an Animal class, but instead define an interface for everything making up an animal. Plus everything making up a cat or dog, and then implement a class, which shares all the interfaces which make up the cat or dog. I can then create new functions easily, which take any animal which can make a sound, for example. You could do something like that with mixins, however you will realize pretty quickly, that you would create dependencies and inter-link everything in an unmaintainable way.

So, say hello to using composite declarations (instead of defined mixins):

interface IName {
    name: string;
}

interface ITalk {
    talk(): void;
}

class Cat implements IName, ITalk {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    talk() {
        console.log('nyan~');
    }
}

class Dog implements IName, ITalk {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    talk() {
        console.log('wan wan, wan!');
    }
}

class Fish implements IName {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

function makeSound(animal: ITalk) {
    animal.talk();
}


const cat = new Cat('Lili');
const dog = new Dog('Hudson');
const fish = new Fish('Kaiser');

makeSound(cat);
makeSound(dog);
// makeSound(fish); <-- does not even compile

First gain: we can now just ask for the actual functionality we need in a function instead of a type; that opens up a lot of new possibilities and more fain-grained control over what is available and what not.

Second gain: security! A fish cannot talk, so it does not implement ITalk. We cannot pass it to makeSound, even though it is an animal. With the first example, we might have accidentally passed Fish, which should extend Animal, to makeSound, which would have resulted in a runtime exception. With declaration composition, it's something we can prevent at compile-time, before even running the code. That's AWESOME!

Taking Full Advantage of Composition

However, that's not the end. Composition can do even more <3 Let's say, we need more than one functionality on a function parameter. How to do that? TypeScript has you covered!

interface IAge {
    age: number;
}

interface IName {
    name: string;
}

class Human implements IAge, IName {
    age: number;
    name: string;

    constructor(name: string, age: number) {
        this.age = age;
        this.name = name;
    }
}

function introduce(person: IAge & IName) {
    console.log(`Hello, my name is ${person.name} and I am ${person.age} years old.`);
}

const person = new Human ('Sam', 24);

introduce(person);

With the intersection-operator (&), we can declare that the parameter must implement both interfaces.

Using that, we can request an object, which exactly defines certain behavior - not more and not less. Anything we receive will always have the required composition of functionalities, and we can only pass stuff which implements all of the required functionalities. Since we have to composite it ourselves, we can tailor each object exactly to what it should do and what it actually is.

Don't-s of Composition

As for what you should never (again) do is fall back to inheritance:

interface IName {
    name: string;
}

class Human implements IName {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    introduce() {
        console.log('Hello, my name is ' + this.name);
    }
}

class Thief extends Human {
    steal(thing: string) {
        console.log(`Hi, I am ${this.name}, and I just stole your ${thing}!`);
    }
}

const person = new Thief ('Sam');

person.introduce();
person.steal('car');

Will compile, but it is a clear step backwards, because now you will have to create strange connections again in order to create a thieving cat. That's not how composition should work. One possible solution would be:

interface IName {
    name: string;
}

interface ISteal {
    steal(thing: string);
}

class Human implements IName {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    introduce() {
        console.log('Hello, I am ' + this.name);
    }
}

class Thief implements IName, ISteal {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    steal(thing: string) {
        console.log(`Hi, I am ${this.name}, and I just stole your ${thing}!`);
    }
}

const person = new Thief ('Sam');

person.steal('car');

Yes, there is code duplication, but since a Human and a Thief are different things, they will likely work differently, so they have to be re-implemented. It's, again, safety, because if you need to change some method on Human in a few months, you would likely forget that you also change the behavior on other classes, which results in funny bugs. Don't go bug-hunting. Keep things together. Thief-code is not Human-code, even if they use the same interfaces and even if a human can be a thief ;) If you want to share code, create a neutral function outside the classes, which can be reused by anything in all situations.

Afterthought

I know that is is hard to throw away old principles and start coding in a new way, and especially in such a different way. So, what I hope is that you play around with the above pattern and start to incorporate it into your products wherever you see an opportunity.

This is one of the instances in which Rust helped me become a better developer overall, not just better at using Rust. I wholeheartedly recommend checking out this amazing language with all it has to offers. Even if you are not a system developer, Rust will help you become a better dev. Also it might prepare you for future tasks (WASM) :)

Comments (13)

Willi K.'s photo

You really got it to the point, I like the idea.

Do you have a good example where this is already in use?

Marco Alka's photo

Software Engineer & Mentor

Do you have a good example where this is already in use?

I think there are two parts here. One, Composition over Inheritance. Composition itself can be used in many languages, like JavaScript, TypeScript, Java, C++, Rust, etc. It is a generalization of Inheritance, not only pulling in one (parent) component, but mixing many different ones and as such being superior by nature. One important source to mention in this context is the Design Patterns book by the Gang of Four.

Two, Traits instead of Mixins. I think, this part is a lot more opinion-based than the first one. My basis here is that modern languages (Rust, Go, TypeScript, Elexir,...) all use interfaces, traits, or whatever they are called in the language, which declare behavior which has to be implemented by each and every adopter structure. So, while I personally would also prefer traits over mixins, there is actual backup by many modern language designers.

For me, the most prominent use of Traits at the moment would be in the Rust programming language. They have traits for many things - directly in the stdlib, like Add, ToString, Default and many more. Rust actually promotes the usage of Traits and no one can escape their clutches when writing even simple programs.

j's photo

I would go even further and create a @decorator that takes an array of closures to registers attributes and behaviours to the class. so your talk() is an external anonymous function that gets composed into the compiled method.

I am not quite sure if typescript can do this because one thing is runtime the other is compile-time.

I only like the take full advantage part because, that's to me is the base level of composition and I have strong opinion towards software design, coupling, patterns and compositions.

The rest to me is classic interface contract enforcing which as I mentioned i probably would move into a trait but since there are no traits in TS I would hack the decorator. Also I hate the combination of functionality and state .... it's so easy to go wrong if you do it. anyhow nice article :)

Show all replies
j's photo

stuff ;)

Marco Alka no "semmel" because she curls herself like one and she has the color of one.

I think, sticking to best practices is important, because it makes onboarding easier for others and usually guarantees a certain quality level.

I think best practices are vital too, the are very important for the industry that's why I stick to clean code, the pragmatic programmer, the GoF as well as the idiomatic approaches within the community where they define standards at work. it allows us to avoid needless discussions and get better results also a standardized approach as you mentioned makes onboarding way easier (in theory)

Yes, I often deviate from best practices, however usually in complex code which others and newcomers will likely never touch. I use plenty of comments to describe what is going on and why I did so, because I might have to touch it some time later again... which is when I am happy about any pointers about what I was thinking.

me too if I code for myself i go down the rabbit-hole, if I code for a business I programmer for the other developers -> how can I express things easily. I also enjoy getting corrected because I want to improve all the time.

In my free time however on such a theoretical topic where we mix different languages / paradigms and systems I always wanna go as far as possible because I'm a firm believer of failure to learn and if everything works fine I obviously didn't challenge myself enough.

Nevertheless, imho it is often a good idea to play the novice and question my own knowledge and decisions. Since I never formally learned best-practices. Never tried it, because who'd remember all of the theory anyway? I don't really count my studies, because they were so short and mostly covered the basics of programming. Too many things to teach :) I mostly just use what I find out there in code or in some documentation when I read something else up. Has its ups and downs :)

I agree I like to be challenged and to challenge myself ;D ... often I just switch the side and disprove myself or find the maximum amount of possible options to do one thing. Otherwise how can I say I understood something if I cannot see the flaws?

But that's all very anecdotal .... To the point ... i think I see where you coming from :)