2
\$\begingroup\$

The purpose of this code is to provide a type-safe approach to apply mixins to classes. It also prevents duplicate mixins. The code depends on ts-toolbelt. It is divided into multiple files, but I've included it in one chunk here for simplicity.

Here is the code I am asking to be reviewed.

//////////////////////////////
// ts-toolbelt
//////////////////////////////

type List<A = any> = ReadonlyArray<A>;
type Class<P extends List = any[], R extends object = object> = { new (...args: P): R };
type Function<P extends List = any, R extends any = any> = (...args: P) => R;

// In following code,
//   `L.List` refers to ts-toolbelt's `List`,
//   `C.Class` refers to ts-toolbelt's `Class`, and
//   `F.Function` refers to ts-toolbel's `Function`.

//////////////////////////////
// Utilities
//////////////////////////////

/**
 * Predicate function with varying parameters.
 */
export type VarPredicate<T extends L.List = any> = F.Function<T, boolean>;

/**
 * Predicate function with one parameter.
 */
export type Predicate<T> = VarPredicate<[T]>;

/**
 * Gets the prototype chain of an object.
 *
 * @template T
 * @param {T} obj The object.
 * @param {Predicate<any>} stopAfterPredicate If returns `true`, the function will stop searching for more prototypes and return.
 *
 * @returns {readonly any[]} An array of each prototype. Starts with `obj`.
 */
// The `stopAfterPredicate` parameter is a bit messy, but exists for performance.
function getPrototypeChain<T extends object>(obj: T, stopAfterPredicate?: Predicate<any>): readonly any[] {
  const chain: any[] = [];

  while(obj) {
    chain.push(obj);

    if(stopAfterPredicate && stopAfterPredicate(obj)) break;

    obj = Object.getPrototypeOf(obj);
  }

  return chain;
}

//////////////////////////////
// Mixin
//////////////////////////////

/**
 * Represents a mixin.
 */
// This could be simplified to a `type`? Any reasons why it should stay an `interface`?
interface Mixin<T extends C.Class, R extends C.Class, A extends L.List = any[]> {
  (ctor: T, ...args: A): T & R;
}

/**
 * Represents a class that has a mixin or mixins applied to it.
 */
export default interface Mixed<T extends C.Class, A extends L.List = any[]> {

  /**
   * Applies a mixin to `this`.
   *
   * @template T
   * @template R
   * @param {Mixin<T, R>} mixin The mixin.
   *
   * @return {Mixed<ReturnType<Mixin<T, R>>> & ReturnType<Mixin<T, R>>} A new class to which `mixin` is applied.
   */
  with<R extends C.Class>(mixin: Mixin<T, R>, ...args: A): Mixed<ReturnType<Mixin<T, R>>> & ReturnType<Mixin<T, R>>;

}

/**
 * Tracked applied mixins.
 */
const appliedMixins = new WeakMap();

/**
 * Checks if a mixin was already applied to a class.
 *
 * @template T
 * @template R
 * @param {Mixin<T, R>} mixin The mixin.
 * @param {C.Class} superClass The class.
 *
 * @return {boolean} `true`, if `mixin` was already applied to `superClass`.
 *     `false`, otherwise.
 */
function wasApplied<T extends C.Class, R extends C.Class>(mixin: Mixin<T, R>, superClass: C.Class): boolean {
  // Get to which `mixin` is already applied.
  const entries = appliedMixins.get(mixin);

  // If no value in the map was set for the key `mixin`,
  // then we know that the mixin has not been applied to any classes.
  if(!entries) return false;
  
  // Check if the mixin was already applied to `superClass`.

  let result: boolean = false;

  getPrototypeChain(superClass, proto => (result = entries.has(proto)));

  return result;
}

/**
 * Tracks that a mixin was applied to a class.
 *
 * @template T
 * @template R
 * @param {Mixin<T, R>} mixin The mixin.
 * @param {C.Class} superClass The class.
 */
function applyMixin<T extends C.Class, R extends C.Class>(mixin: Mixin<T, R>, superClass: C.Class): void {
  // Get to which `mixin` is already applied.
  let entries = appliedMixins.get(mixin);

  // If no value in the map was set for the key `mixin`,
  // then create a new set for it.
  if(!entries) {
    entries = new WeakSet();

    appliedMixins.set(mixin, entries);
  }

  // Track that `mixin` was applied to `superClass`.
  entries.add(superClass);
}

/**
 * Mixin builder.
 */
class MixinBuilder<T extends C.Class> {

  public constructor(superClass: T) {
    this.superClass = superClass;
  }

  private readonly superClass: T;

  /**
   * See {@link Mixed#with} for docs.
   * This function does not permit duplicates.
   */
  with<R extends C.Class>(mixin: Mixin<T, R>, ...args: A): Mixed<ReturnType<Mixin<T, R>>> & ReturnType<Mixin<T, R>> {
    if(wasApplied(mixin, this.superClass)) return (this.superClass as Mixed<ReturnType<Mixin<T, R>>> & ReturnType<Mixin<T, R>>);

    const mixed = mixin(this.superClass, args);

    applyMixin(mixin, mixed);

    return (class extends mixed {
      static with<K extends C.Class>(mixin: Mixin<typeof mixed, K>, ...args: A) {
        return new MixinBuilder(mixed).with(mixin, args);
      }
    } as any);
  }

}

function mix<T extends object>(superClass: C.Class<any[], T>): MixinBuilder<C.Class<any[], T>> {
  return new MixinBuilder(superClass);
}

Here is an example mixin using the above code.

/**
 * Public declarations for elements that have the `disabled` property.
 */
interface DisabledStateInterface {

  /**
   * The `disabled` property.
   */
  disabled: boolean;

  /**
   * Function is called when the element is clicked.
   *
   * @implSpec Super should not be called if {@link disabled} is `true`.
   */
  click(): void;

}

/**
 * Declarations for elements that have the `disabled` property.
 */
class DisabledStateClass {

  /**
   * Sets the `aria-disabled` attribute.
   *
   * @param {boolean} disabled The value of the `disabled` property.
   *
   * @protected
   */
  protected _setAriaDisabled?(disabled: boolean): void;

}

/**
 * Mixin that provides the `disabled` property and sets the `aria-disabled` attribute accordingly.
 * 
 * @template T
 * @param {T} superClass The superclass.
 * 
 * @return {ReturnType<Mixin<T, C.Class<any[], DisabledStateInterface>>>}
 * 
 * @constructor
 */
function DisabledStateMixin<T extends C.Class<any[], LitElement & DisabledStateClass>>(
    superClass: T
): ReturnType<Mixin<T, C.Class<any[], DisabledStateInterface>>> {
  class DisabledState extends superClass {

    @property({type: Boolean, reflect: true})
    public disabled: boolean = false;

    protected override _setAriaDisabled(disabled: boolean): void {
      this._ariaDisabledElement.setAttribute('aria-disabled', disabled.toString());
    }

    protected override updated(properties: PropertyValues): void {
      super.updated(properties);

      // If `disabled` was updated, set the `aria-disabled` attribute accordingly.
      if(properties.has('disabled')) this._setAriaDisabled(this.disabled);
    }

    /**
     * Gets the element to which to apply the `aria-disabled` attribute.
     *
     * By default, returns `this`.
     *
     * @returns {HTMLElement} The element to which to apply the `aria-disabled` attribute.
     *
     * @protected
     */
    protected get _ariaDisabledElement(): HTMLElement {
      return this;
    }

  }

  return DisabledState;
}

Finally, here is an example usage.

class MyElement extends mix(LitElement).with(DisabledStateMixin) {

  protected override render(): HTMLTemplateResult {
    return html`
      Hello, World!
    `;
  }

}

Thank you for your time!

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.