@rbxts/object-composer

A simple, light-weight multiple inheritance object composition library

Usage no npm install needed!

<script type="module">
  import rbxtsObjectComposer from 'https://cdn.skypack.dev/@rbxts/object-composer';
</script>

README

object-composer

object-composer ships a single compose function, which combines a series of callbacks which each return an object into a single function which returns the combination of all returned objects. Each callback function can optionally take a single state parameter which will be passed into each constructor function during object instantiation. Property collisions can occur at run-time if two constructors each attempt to write to the same property (key) with conflicting types (see bottom of readme).

Here is a demo, taking inspiration from this video: https://youtu.be/wfMtDGfHWpA

export {};
import compose from "@rbxts/object-composer";

const Pooper = () => ({
    poops: 0,
    poop() {
        this.poops++;
    },
});

const Barker = ({ name }: { name: string }) => ({
    bark() {
        print(`Woof, I am ${name}`);
    },
});

const Driver = ({ position: _position = 0, speed: _speed = 0 }) => ({
    /** @private */
    _position,
    /** @private */
    _speed,
    drive() {
        return (this._position += this._speed);
    },
});

const Killer = () => ({
    kills: 0,
    kill<T extends { kills: number }>(this: T, target: { TakeDamage(amount: number): void }) {
        target.TakeDamage(1 / 0);
        this.kills++;
        return this; // returns the full `this`, not just the `Killer` this
    },
});

type CleaningRobot = ReturnType<typeof CleaningRobot>;
const CleaningRobot = compose(
    Driver,
    () => ({
        clean() {},
    }),
);

type MurderousRobot = ReturnType<typeof MurderousRobot>;
const MurderousRobot = compose(
    Driver,
    Killer,
);

type Dog = ReturnType<typeof Dog>;
const Dog = compose(
    Pooper,
    Barker,
);

type MurderousRobotDog = ReturnType<typeof MurderousRobotDog>;
const MurderousRobotDog = compose(
    Barker,
    Driver,
    Killer,
);

// We don't need a state argument for this construction, since there are defaults for every property in Driver and Killer
const robot = MurderousRobot();

// Error! We need a state argument here, since `name` is required
const dog = Dog();

// Good!
const dog2 = Dog({ name: "pup" });
dog2.bark();

MurderousRobotDog({
    position: 10,
    speed: 30,
    name: "Mr. Wolf",
});

MurderousRobotDog({
    name: "Hello", // We don't need speed or position arguments, since those have defaults
});

// With the above pattern, you can use `Dog` as a type
function f(o: Dog) {
    o.bark();
}

A cool feature of TypeScript is that you can define the this property to make compile-time checks on the call site. For example, if you wanted to define a method that should only work if called on an object with a particular property, you can do so! With proper object composition you shouldn't need to do this, but I thought it was cool regardless.

export {};
import compose from "@rbxts/object-composer";

const ToStringHaver = () => ({
    toString(): string {
        let result = "{\n";
        for (const [prop, value] of Object.entries(this)) result += `\t${prop}: ${value},\n`;
        return result + "\n}";
    },
});

const ThemeHaver = ({ theme: _theme = "Light" }: { theme?: "Light" | "Dark" }) => ({
    /** @private */
    _theme,

    /** Get the theme. */
    getTheme() {
        return this._theme;
    },

    /** Set the theme. */
    setTheme<T extends { _theme: NonNullable<typeof _theme> }>(this: T, theme: NonNullable<typeof _theme>) {
        this._theme = theme;
        return this;
    },
});

const SizeHaver = ({ size: _size = new Vector3() }) => ({
    /** @private */
    _size,

    /** Get the size property. */
    getSize() {
        return this._size;
    },

    /** Set the size property. */
    setSize<T extends { _size: Vector3 }>(this: T, size: Vector3) {
        this._size = size;
        return this;
    },

    /** Gets the TextSize, if it exists */
    getTextSize<T extends { _size: Vector3; _text: string }>(this: T) {
        const { X, Y } = this._size;
        return new Vector2(this._text.size() * X, Y);
    },
});

const TextHaver = ({ text: _text = "" }) => ({
    /** @private */
    _text,

    /** Get the text property. */
    getText() {
        return this._text;
    },

    /** Set the text property. */
    setText<T extends { _text: string }>(this: T, text: string) {
        this._text = text;
        return this;
    },
});

const TextObject = compose(
    ToStringHaver,
    ThemeHaver,
    SizeHaver,
    TextHaver,
);

const text = TextObject();

// By typing `this` as the full call location on each `set` function, we can chain!
text.setSize(new Vector3())
    .setText("Hello")
    .setTheme("Dark");

text.getTextSize();

const SizedTheme = compose(
    SizeHaver,
    ThemeHaver,
);
const sizedTheme = SizedTheme();

// error! sizedTheme does not have a `_text` property!
sizedTheme.getTextSize();
Note: Index signatures are not unsupported, but they aren't "supported" either. TypeScript may only error at run-time if you use index signatures (and why would you do that? Just use a Map)
Note: For the purposes of this readme, "conflicting types" between a and b are defined as typeof(a) ~= typeof(b) or typeof(a) == "table" or typeof(a) == "userdata