Reputation: 1942
I am trying to understand mixings from the official TS docs.
https://www.typescriptlang.org/docs/handbook/mixins.html
I have set up a playground here.
My question is: how can i define jump and duck to manipulate x and y properties defined in Sprite?
i.e. In this example I would like jump() and duck() to manipulate x and y in Sprite class. Is that possible? Adding x and y props to jumpable and duckable seems cumbersome and repetitive.
Generally without cross manipulation of properties between mixed in classes I am struggling to see real-world use-cases for TS mixins.
code is:
(() => {
// This can live anywhere in your codebase:
function applyMixins(derivedCtor: any, constructors: any[]) {
constructors.forEach((baseCtor) => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
Object.defineProperty(
derivedCtor.prototype,
name,
Object.getOwnPropertyDescriptor(baseCtor.prototype, name) ||
Object.create(null)
);
});
});
}
// Each mixin is a traditional ES class
class Jumpable {
jump() {
console.log("jump");
}
}
class Duckable {
duck() {
console.log("duck");
}
}
// Including the base
class Sprite {
x = 0;
y = 0;
}
// Then you create an interface which merges
// the expected mixins with the same name as your base
interface Sprite extends Jumpable, Duckable {}
// Apply the mixins into the base class via
// the JS at runtime
applyMixins(Sprite, [Jumpable, Duckable]);
let player = new Sprite();
player.jump();
player.duck();
console.log(player.x, player.y);
})();
Upvotes: 6
Views: 1864
Reputation: 328097
By using declaration merging you are manually telling the compiler what your mixins are doing, because it can't figure that out itself. The documentation calls what you're doing an "alternative pattern", and that "this pattern relies less on the compiler, and more on your codebase to ensure both runtime and type-system are correctly kept in sync."
So you should expect some additional tedium involved in convincing the compiler that your seemingly-independent Jumpable
and Duckable
classes can access x
and y
properties that aren't declared in them. Perhaps the easiest way to do this is to define an interface for something with the right shape:
interface Positioned {
x: number,
y: number
}
And then tell the compiler that the jump()
and duck()
methods are meant to operate on something of that shape, by using a this
parameter:
class Jumpable {
jump(this: Positioned) {
console.log("jump");
this.y += 2;
}
}
class Duckable {
duck(this: Positioned) {
console.log("duck");
this.y -= 1;
}
}
This both lets you modify x
and y
inside the method, and also warns you if you try to use the mixin methods as if they were regular methods:
const j = new Jumpable();
j.jump(); // compiler error
// The 'this' context of type 'Jumpable' is not
// assignable to method's 'this' of type 'Positioned'.
Once you do that, you can demonstrate that things just "work":
class Sprite {
name = "";
x = 0;
y = 0;
constructor(name: string) {
this.name = name;
}
}
interface Sprite extends Jumpable, Duckable { }
applyMixins(Sprite, [Jumpable, Duckable]);
const player = new Sprite("Player");
console.log(player.name, player.x, player.y); // Player 0 0
player.jump(); // jump
console.log(player.name, player.x, player.y); // Player 0 2
player.duck(); // duck
console.log(player.name, player.x, player.y); // Player 0 1
So that's great, if you're going to use the alternative pattern.
The recommended, non-alternative mixin pattern is to use class factory functions, that use standard class inheritance to extend a base class with the mixin. And by constraining the base class to constructors of Positioned
objects, you can give the mixin access to the base class's x
and y
properties:
function Jumpable<TBase extends new (...args: any[]) => Positioned>(Base: TBase) {
return class Jumpable extends Base {
jump() {
console.log("jump");
this.y += 2;
}
};
}
function Duckable<TBase extends new (...args: any[]) => Positioned>(Base: TBase) {
return class Duckable extends Base {
duck() {
console.log("duck");
this.y -= 1;
}
};
}
The class expressions inside the Jumpable
and Duckable
factory functions allow jump()
and duck()
to access this.y
, because the Base
constructor is of type TBase
, which is known to construct some subtype of Positioned
.
And now instead of manually applying the mixin methods to prototypes, you can just call the mixin factory functions on constructors:
class BaseSprite {
name = "";
x = 0;
y = 0;
constructor(name: string) {
this.name = name;
}
}
const Sprite = Jumpable(Duckable(BaseSprite));
And note that no declaration merging is necessary; the compiler will automatically understand that Sprite
instances are Positioned
but also have jump()
and duck()
methods:
const player = new Sprite("Player");
console.log(player.name, player.x, player.y); // Player 0 0
player.jump(); // jump
console.log(player.name, player.x, player.y); // Player 0 2
player.duck(); // duck
console.log(player.name, player.x, player.y); // Player 0 1
Upvotes: 5