How can I "augment" a JS class with methods from another class, without extending it?

253 Views Asked by At

I'm writing an app that manages playlists. Basically, my actual logic looks like

//define playlist props
class Playlist{
    public tracks = [];
}

class ApiPlaylist extends Playlist{
    //fill playlist withs (single page) data from API
    public async loadPage(paginationSettings){
        const pageTracks = await...
        this.tracks = [...this.tracks,...pageTracks]; //concat tracks
    }
}

class Paginated extends ApiPlaylist{
    private iterations = 0;
    private endReached = false;
    public async loadAll(){
        while (!this.endReached){
            this.loadNext();
        }
    }
    public async loadNext(){
        this.iterations++;
        await this.loadPage(); //call ApiPlaylist method
        if(...){
            this.endReached = true; //stop iterating
        }
    }

}

const playlist = new Paginated();
playlist.loadAll();

It works.

But what if I have different others paginated datas to get, that are not related to playlists ?

I would like to use the mechanism from PaginatedPlaylist with another classes, without having to duplicate it.

Acually, Paginated extends ApiPlaylist. Is there a simple way to implement the methods from Paginated to ApiPlaylist without using extends ?

Something like

class ApiPlaylist [implements] Paginated{}
class ApiPost [implements] Paginated{}
class ApiPage [implements] Paginated{}

Thanks for your help !

2

There are 2 best solutions below

4
Peter Seliger On

As it already has been pointed out, the correct pattern to use is mixin or trait based composition.

The next provided example code demonstrates the usage of a function-based mixin which implements and applies the feature/behavior of "loadable tracks".

async function mockedFetch(paginationSettings) {
  console.log({ paginationSettings });

  console.log('... fetching ...');

  return new Promise(
    resolve => setTimeout(resolve, 1_500, ['foo', 'bar', 'baz']),/*
    reject => { handle reject },*/
  );
}


// implementation of the context-aware actual `loadPage` method.
async function fetchTracksAndPushIntoBoundTrackList(paginationSettings) {
  const pageTracks = await mockedFetch(paginationSettings);

  this.tracks.push(...pageTracks);
}
// function based mixin.
function withLoadableTracks() {
  this.loadPage = fetchTracksAndPushIntoBoundTrackList.bind(this);
}


// base class.
class Playlist {
  constructor () {

    this.tracks = [];

    // further `Playlist` specific implementation.
  }
}

// subclassing ...
class ApiPlaylist extends Playlist {
  constructor () {

    // inheritance.
    super();

    // composition ... applying the function based mixin.
    withLoadableTracks.call(this);

    // further `ApiPlaylist` specific implementation.
  }
}

// no subclassing ... straightforward mixin application.
class ApiPost {
  constructor () {
    this.tracks = [];

    // composition ... applying the function based mixin.
    withLoadableTracks.call(this);

    // further `ApiPost` specific implementation.
  }
}
class ApiPage {
  constructor () {
    this.tracks = [];

    // composition ... applying the function based mixin.
    withLoadableTracks.call(this);

    // further `ApiPage` specific implementation.
  }
}


(async () => {

  const playlist = new ApiPlaylist;
  
  // const post = new ApiPost;
  // const page = new ApiPage;

  console.log({ tracks: playlist.tracks });

  await playlist.loadPage({ tracks: 3 });

  console.log({ tracks: playlist.tracks });

  await playlist.loadPage({ tracks: 3 });

  console.log({ tracks: playlist.tracks });

})();
.as-console-wrapper { min-height: 100%!important; top: 0; }

The above example code did not provide a full scale implementation of what the OP wants to achieve. Trying the latter shows design flaws in the OP's modeling.

In my opinion, the sole loadPage method of the OP's ApiPlaylist implementation, where the latter extends Playlist, collides with the loadNext method of the OP's Paginated implementation, where Paginated extends ApiPlaylist. loadNext forwards to loadPage and is all of a sudden smart enough of being able to provide additional informations about possibly next loadable tracks.

But actually this information can only be provided from the API-call's return value; therefore loadPage which triggers this call already has all the necessary data first hand. Thus, loadPage needs to implement some, if not all characteristics that come with the OP's original loadNext which renders the latter method obsolete.

The next provided code uses a more advanced variant of a function based mixins which is capable of privately sharing locally encapsulated, mutable state (thus the latter can not be accessed from outside).

Any of the features, intended by the OP, are implemented within and applied through this mixin which therefore is named withLoadablePaginatedTracks.

Relying on bind naturally supports default configurations for the OP's paginationSettings parameter which gets passed to loadPage.

In addition one too can pass said pagination configuration to any constructor function which is going to apply this mixin. In case loadPage and loadAll do not receive such a parameter, the default settings (passed at instantiation time or from the internal defaults) take place.

const mockedTrackList = [
  'foo', 'bar', 'baz', 'biz', 'buzz',
  'the', 'quick', 'brown', 'fox', 'jumps',
  'over', 'the', 'lazy', 'dog',
];

async function mockedFetch(paginationSettings) {
  console.log({ paginationSettings });
  console.log('... fetching ...');

  const { trackCount } = paginationSettings;
  
  const tracks = mockedTrackList.splice(0, trackCount);
  const hasNext = mockedTrackList.length > 0;

  return new Promise(
    resolve => setTimeout(resolve, 1_500, { tracks, hasNext }),/*
    reject => { handle reject },*/
  );
}


// implementation of the context and state aware actual `loadPage` method.
async function fetchAndStoreTrackBatch(
  sharedPrivateState, paginationSettings
) {
  let success = false;

  if (this.hasNext()) {

    const { tracks, hasNext } = await mockedFetch(
      paginationSettings ?? sharedPrivateState.paginationSettings
    );
    sharedPrivateState.fetchCount++;
    sharedPrivateState.hasNext = hasNext;

    if (tracks.length > 0) {

      this.tracks.push(tracks);

      success = true;
    }
  }
  return success;
}

// implementation of the context and state aware actual `loadAll` method.
async function fetchAndStoreAllTrackBatches(
  sharedPrivateState, paginationSettings
) {
  let success = false;

  while (this.hasNext()) {

    // trigger api call.
    await this.loadPage(
      paginationSettings ?? sharedPrivateState.paginationSettings
    );
    success = true;
  }
  return success;
}

// - function based mixin which implements all features
//   of "loadable paginated tracks" upon its context and
//   some mutable, encapsulated (private), but shared state.
function withLoadablePaginatedTracks(
  paginationSettings = { trackCount: 3 },
) {
  const sharedPrivateState = {
    fetchCount: 0, hasNext: true, paginationSettings,
  };

  // always assure a public `tracks` property.
  if (!Array.isArray(this.tracks)) {

    this.tracks = [];
  }

  // assign/implement the behavior.

  this.hasNext = () => sharedPrivateState.hasNext;

  this.loadPage =
    fetchAndStoreTrackBatch.bind(this, sharedPrivateState);

  this.loadAll =
    fetchAndStoreAllTrackBatches.bind(this, sharedPrivateState);

  // - there is no need for an extra implementation of
  //   `loadNext` as originally intended by the OP since
  //   the `loadPage` method already has to take care of
  //   almost all of such a method's implementation details.
}


// base class.
class Playlist {
  constructor () {

    this.tracks = [];

    // further `Playlist` specific implementation.
  }
}

// subclassing ...
class ApiPlaylist extends Playlist {
  constructor (paginationSettings) {

    // inheritance.
    super();

    // composition ... applying the function based mixin.
    withLoadablePaginatedTracks.call(this, paginationSettings);

    // further `ApiPlaylist` specific implementation.
  }
}

// no subclassing ... straightforward mixin application.
class ApiPost {
  constructor (paginationSettings) {
    this.tracks = [];

    // composition ... applying the function based mixin.
    withLoadablePaginatedTracks.call(this, paginationSettings);

    // further `ApiPost` specific implementation.
  }
}
class ApiPage {
  constructor (paginationSettings) {
    
    // - intentionally omit the public `tracks` property since it
    //   lazyly gets assigned  by the mixin in case it's missing.

    // this.tracks = [];

    // composition ... applying the function based mixin.
    withLoadablePaginatedTracks.call(this, paginationSettings);

    // further `ApiPage` specific implementation.
  }
}


(async () => {

  const playlist = new ApiPlaylist({ trackCount: 3 });

  // const post = new ApiPost;
  // const page = new ApiPage({ trackCount: 5 });


  console.log({ tracks: playlist.tracks });

  await playlist.loadPage();

  console.log({ tracks: playlist.tracks });

  await playlist.loadPage({ trackCount: 2 });

  console.log({ tracks: playlist.tracks });


  console.log('\n+++ loadAll +++\n');

  await playlist.loadAll({ trackCount: 4 });

  console.log({ tracks: playlist.tracks });


  console.log('... done ...');

})();
.as-console-wrapper { min-height: 100%!important; top: 0; }

The above presented example code exclusively composes features through a function based mixin where the mixin-function is responsible for the delegation.

Though it is a lean approach for JavaScript's pure vanilla flavor, the pattern is not suitable for TypeScript. So called "TypeScript Mixins" are based on dynamically sub-typed/subclassed classes, thus on pure inheritance.

Another answer to the OP's Q. already did mention this pattern, just missing on its correct technical terminology.

The next provided example code straightforwardly refactors the mixin-function based composition from above into a pure inheritance driven solution. And for this special pattern I'd like to coin the term "Interpositioned Dynamic Subclassing" which is supposed to replace the formerly used, less specific "dynamic subclassing/sub-typing".

(with the end of logging one gets presented an overview of the entire inheritance involved)

const mockedTrackList = [
  'foo', 'bar', 'baz', 'biz', 'buzz',
  'the', 'quick', 'brown', 'fox', 'jumps',
  'over', 'the', 'lazy', 'dog',
];

async function mockedFetch(paginationSettings) {
  console.log({ paginationSettings });
  console.log('... fetching ...');

  const { trackCount } = paginationSettings;
  
  const tracks = mockedTrackList.splice(0, trackCount);
  const hasNext = mockedTrackList.length > 0;

  return new Promise(
    resolve => setTimeout(resolve, 1_500, { tracks, hasNext }),/*
    reject => { handle reject },*/
  );
}


// implementation of the context and state aware actual `loadPage` method.
async function fetchAndStoreTrackBatch(
  sharedPrivateState, paginationSettings
) {
  let success = false;

  if (this.hasNext()) {

    const { tracks, hasNext } = await mockedFetch(
      paginationSettings ?? sharedPrivateState.paginationSettings
    );
    sharedPrivateState.fetchCount++;
    sharedPrivateState.hasNext = hasNext;

    if (tracks.length > 0) {

      this.tracks.push(tracks);

      success = true;
    }
  }
  return success;
}

// implementation of the context and state aware actual `loadAll` method.
async function fetchAndStoreAllTrackBatches(
  sharedPrivateState, paginationSettings
) {
  let success = false;

  while (this.hasNext()) {

    // trigger api call.
    await this.loadPage(
      paginationSettings ?? sharedPrivateState.paginationSettings
    );
    success = true;
  }
  return success;
}


// - dynamic subclassing ... dynamically created, state sharing,
//   `LoadablePaginatedTracks` class, derived from either the
//   passed class or by an internal last fallback to the most
//   basic `TracksDefault`.
// - it implements all features of "loadable paginated tracks"
//   upon its context and the mutable, privately shared state.
function asLoadablePaginatedTracks(
  SuperType = class TracksDefault { tracks = []; }
) {
  class LoadablePaginatedTracks extends SuperType {

    #sharedState;

    constructor(paginationSettings = { trackCount: 3 }) {

      const sharedState = {
        fetchCount: 0, hasNext: true, paginationSettings,
      };
      super();

      // always assure a public `tracks` property.
      if (!Array.isArray(this.tracks)) {

        this.tracks = [];
      }
      this.#sharedState = sharedState;
    }

    // prototypal implementation of any behavior.

    hasNext() {
      return this.#sharedState.hasNext;
    }
    loadPage(paginationSettings) {
      return fetchAndStoreTrackBatch
        .call(this, this.#sharedState, paginationSettings);
    }
    loadAll(paginationSettings) {
      return fetchAndStoreAllTrackBatches
        .call(this, this.#sharedState, paginationSettings);
    }
  }
  return LoadablePaginatedTracks;
}


// +++ The wording of the OP's original Q. will be used partially +++
//
// - `Playlist`    ... comes/stays as is.
//
// - `ApiPlaylist` ... which extends (not implements) dynamically created
//                     **loadable paginated tracks** functionality which
//                     itself extends `Playlist`.


// base class.
class Playlist {
  constructor () {

    this.tracks = [];

    // further `Playlist` specific implementation.
  }
}

// subclassing ... (actually dynamic sub-subclassing)
class ApiPlaylist extends asLoadablePaginatedTracks(Playlist) {
  constructor (paginationSettings) {

    // inheritance.
    super(paginationSettings);

    // further `ApiPlaylist` specific implementation.
  }
}

// - other ways of creating extended classes like
//   the OP intends for e.g. `ApiPost` and `ApiPage` ...

class Post {} // - going to be extended.
class Page {} // - will not get extended.

// ... either by directly dynamically extending
//     a very basic `Post` class ...
const ApiPost = asLoadablePaginatedTracks(Post);

// ... or even by directly dynamically extending just
//     the internal fallback of a basic track type ...
const ApiPage = asLoadablePaginatedTracks(
  /* intentionally omit e.g. `Page` */
);


function assemblePrototypeChainGraph(value) {
  let result = '';
  let depth = 0;

  while (value = Object.getPrototypeOf(value)) {
    result = [

      result,
      '\n',
      Array(depth++).fill('  ').join(''),
      '=> ',
      value.constructor.name,

    ].join('');
  }
  return result;
}


(async () => {

  const playlist = new ApiPlaylist({ trackCount: 3 });

  const post = new ApiPost;
  const page = new ApiPage({ trackCount: 5 });


  console.log({ tracks: playlist.tracks });

  await playlist.loadPage();

  console.log({ tracks: playlist.tracks });

  await playlist.loadPage({ trackCount: 2 });

  console.log({ tracks: playlist.tracks });


  console.log('\n+++ loadAll +++\n');

  await playlist.loadAll({ trackCount: 4 });

  console.log({ tracks: playlist.tracks });


  console.log('... done ...');


  console.log(
    'prototype chain of `playlist` ...',
    assemblePrototypeChainGraph(playlist),
  );

  console.log(
    'prototype chain of `post` ...',
    assemblePrototypeChainGraph(post),
  );
  console.log(
    'prototype chain of `page` ...',
    assemblePrototypeChainGraph(page),
  );

})();
.as-console-wrapper { min-height: 100%!important; top: 0; }

Conclusion

Though some might see it as a merely personal opinion, it seems that any mixin approach is better suited for solving any of the OP's problems.

The advantage comes with a clear dependency free implementation of specific functionality. In case an object needs to have specific behavior it gets applied/mixed-in ("has a" indicates composition).

In comparison, an inheritance driven solution, regardless whether sub-typing is involved or not, depends on a correctly build inheritance/prototype chain, thus it is less free when it comes to the implementation and especially application of behavior because it comes with implicitly made assumptions or to be met requirements which are not that obvious at first.

In addition, the reading of how one creates and names sub-typed classes appears clumsy, because one finds oneself in the need of having to more specifically to describe the class name, which actually should be a noun (a class or instance of it "is a" thing) like Paginator, as soon as it acquires more specialized skills. All of a sudden Paginator or Pagination specializes towards LoadablePaginatedTracks being created by a process called asLoadablePaginatedTracks .

There is not such an overhead with any of the established mixin patterns that are true composing patterns.

3
Matt Timmermans On

There's a pattern for this in other OO languages called CRTP (curiously recurring template pattern), and you can do it in JavaScript with a function that creates classes.

I think it's a lot easier to understand than the various ways of doing mixins, and causes fewer problems:

function PaginateClass(Base) {
    class Paginated extends Base {
        paginatedMethod() {
            return `paginated ${this.baseMethod()}`;
        }
    }
    return Paginated;
}

class Base {
    baseMethod() {
        return 'baseMethod';
    }
}

const PaginatedBase = PaginateClass(Base);

const instance = new PaginatedBase();

console.log(instance.paginatedMethod());
.as-console-wrapper { min-height: 100%!important; top: 0; }