Laurence Fass
Laurence Fass

Reputation: 1942

Typescript mixins: how to access properties between mixed in classes?

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

Answers (1)

jcalz
jcalz

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

Playground link to code

Upvotes: 5

Related Questions