3

I'm writing stimulus controllers in TypeScript and I feel like there has to be a better way of declareing all of the has*Target/*Target/*Targets properties than declaring each and every one.

Does anyone know if what I'm looking for is even possible? Thanks in advance

Here's some code I've been playing around with trying to get it to work

import { Controller } from '@hotwired/stimulus';

namespace Transform {
  export type HasTarget<T> = {
    [K in keyof T as `has${Capitalize<string & K>}Target`]: boolean;
  };
  export type Target<T> = {
    [K in keyof T as `${string & K}Target`]: T[K];
  };
  export type Targets<T> = {
    [K in keyof T as `${string & K}Targets`]: Array<T[K]>;
  };
}

const targets = [
  'button',
  'container',
] as const;

type TargetList = typeof targets;

interface Targets extends Record<TargetList[number], HTMLElement> {
  button: HTMLButtonElement;
  container: HTMLDivElement;
}

type ControllerWithTarget<T> =
  & Transform.HasTarget<T>
  & Transform.Target<T>
  & Transform.Targets<T>;


// Trying to achieve something like this
export default class extends Controller implements ControllerWithTarget<Targets> {
  static targets = [...targets];

  // declare readonly hasButtonTarget: boolean;
  // declare readonly buttonTarget: HTMLButtonElement;
  // declare readonly buttonTargets: Array<HTMLButtonElement>;

  // declare readonly hasContainerTarget: boolean;
  // declare readonly containerTarget: HTMLDivElement;
  // declare readonly containerTargets: Array<HTMLDivElement>;

  disableButton(): void {
    if ( this.hasButtonTarget ) {
      this.buttonTarget.disabled = true;
    }
  }
}

EDIT The compiler says Class 'default' incorrectly implements interface 'ControllerWithTarget<Targets>'.

M. Wyatt
  • 160
  • 2
  • 10

2 Answers2

0

Here is one way to generically declare the has/target/targets props in TypeScript for a Stimulus controller:

import { Controller } from '@hotwired/stimulus'

type TargetName = 'button' | 'container';

const targets = [
  'button',
  'container'
] as const;

interface Targets {
  [key in TargetName]: HTMLElement;
}

type ControllerProps<T extends TargetName> = {
  [P in `has${Capitalize<string & T>}Target`]: boolean;
} & {
  [P in `${T}Target`]: Targets[T];
} & {
  [P in `${T}Targets`]: Targets[T][];
};

export default class extends Controller {
  static targets = targets;

  constructor() {
    super();

    for (let target of targets) {
      this[`${target}Target`] = null; 
      this[`${target}Targets`] = null;
      this[`has${capitalize(target)}Target`] = false;
    }
  }

  private getProps<T extends TargetName>(): ControllerProps<T> {
    return {
      // extra props here
    }
  }
}

The key aspects:

Define types for targets, props Loop through targets on init and set props Use generics to get strongly typed props for each target This avoids needing to explicitly declare each prop.

Masoud
  • 146
  • 3
  • 17
  • A few things: The interface `Targets` fails to compile as well as the `for` loop. This removes the capability for the different `*Target`/`*Targets` to be different types (`HTMLButtonElement` vs `HTMLElement`). With Stimulus, you cannot set the `*Target` etc properties this way because they are getters instead of just properties – M. Wyatt Aug 28 '23 at 13:44
-1

Heres an example of how i would implement it:

import { Controller } from '@hotwired/stimulus';

namespace Transform {
  export type HasTarget<T> = {
    [K in keyof T as `has${Capitalize<string & K>}Target`]: boolean;
  };
  export type Target<T> = {
    [K in keyof T as `${string & K}Target`]: T[K];
  };
  export type Targets<T> = {
    [K in keyof T as `${string & K}Targets`]: Array<T[K]>;
  };
}

const targets = [
  'button',
  'container',
] as const;

interface Targets extends Record<typeof targets[number], HTMLElement> {
  button: HTMLButtonElement;
  container: HTMLDivElement;
}

type ControllerWithTarget<T> =
  & Transform.HasTarget<T>
  & Transform.Target<T>
  & Transform.Targets<T>;

export default class extends Controller implements ControllerWithTarget<Targets> {
  static targets = [...targets];

  disableButton(): void {
    if (this.hasButtonTarget) {
      this.buttonTarget.disabled = true;
    }
  }
}
jagmitg
  • 4,236
  • 7
  • 25
  • 59
  • This 99% exactly the same as my code. The only difference is the removal of comments and inlined the `TargetList` type. TS says `Class 'default' incorrectly implements interface 'ControllerWithTarget'.` – M. Wyatt Aug 14 '23 at 17:18