My FeedDiscussionsHeadless CMS
New
Sign in
Log inSign up
Learn more about Hashnode Headless CMSHashnode Headless CMS
Collaborate seamlessly with Hashnode Headless CMS for Enterprise.
Upgrade ✨Learn more
Rusty Composition in TypeScript

Rusty Composition in TypeScript

Marco Alka's photo
Marco Alka
·Nov 8, 2018

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) :)