Stimulus: Typing targets with TypeScript and babel 7 with class properties proposal

Created on 17 Jan 2019  Â·  11Comments  Â·  Source: hotwired/stimulus

Setup:

  • using Stimulus with TypeScript and babel 7
  • TypeScript is providing type checking and compiling to esnext-level JS
  • Resulting JS is then transpired by babel down to targets based on @babel/preset-env
  • Babel configured to use @babel/plugin-proposal-class-properties

There is a conflict in how TypeScript expects class properties to be typed, and how babel treats class properties, which is resulting in an error in Stimulus.

Consider the example from the handbook implemented with TypeScript:

<div data-controller="hello">
  <input data-target="hello.name" type="text">
  <button data-action="click->hello#greet">Greet</button>
</div>
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "name" ]
  readonly nameTarget!: Element

  greet() {
    const element = this.nameTarget
    const name = element.value
    console.log(`Hello, ${name}!`)
  }
}

When using TypeScript with babel 7 and the @babel/plugin-proposal-class-properties plugin, the resulting JavaScript ends up with the class property nameTarget being initialised with void 0, as per the spec. This results in the following error from Stimulus:

Cannot set property nameTarget of #<Controller> which has only a getter

This is perfectly reasonable behaviour from Stimulus: you wouldn't want the nameTarget being overwritten by calling code, since it is managed by Stimulus.

Digging deeper into the issue, it appears that Stimulus defines the *Target properties when the controller is first loaded into the application, based on the static targets property. Babel's assignment of this.nameTarget = void 0 occurs when the controller instance is created - by this time, the *Target properties have already been defined (without setters) and so the error is seen when the controller is instantiated.

Unfortunately I can't see any way around this, other than

a) not typing the *Target properties, and ignoring/suppressing the TypeScript errors
b) reverting back to using regular JavaScript

I'd really like to use TypeScript, especially for targets as the autocompletion tools for HTMLElements are such a timesaver. Can there be any way to solve this issue?

Most helpful comment

Bumped into this issue myself. Most certainly a tricky one to solve. Maybe decorator? Too bad it's still in the proposal stage.

It certainly makes sense that the spec tells that extending a class and declaring a property initializes such property, overriding whatever the base class has.

FWIW, I used the following technique to fool the TS compiler:

class HelloBaseController extends Controller {
  public nameTarget!: HTMLInputElement;
}

export class HelloController extends (Controller as typeof HelloBaseController) {
  public static targets = ['name'];
}

All 11 comments

I've carried out some further investigation by implementing the 'Hello Stimulus' example from the handbook in TypeScript, which can be seen in this repo. In this minimal repro, I'm seeing slightly different behaviour to that listed above (I was initially setting the loose option on the babel plugin to true, which means that the property initialisation is performed with assignment, instead of using Object.defineProperty), but broadly speaking the issue is still present.

Running yarn build in the repo above produces a bundle.js file, which I've reproduced in this gist. You can see the following behaviour which is conflicting with Stimulus:

  • The hello_controller constructor begins at line 2370.
  • The TypeScript typing readonly nameTarget!: HTMLInputElement causes the transpiled code to include the following line:
 _defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "nameTarget", void 0);
  • Since I'm not setting loose to true in this example, the babel plugin produces code which uses Object.defineProperty to overwrite nameTarget with the result of the class property initialiser - since this has been omitted, babel assumes you want to set it to void 0 (undefined).
  • The overall result is that hello_controller is instantiated with this.nameTarget as undefined. This is obviously undesirable as when the greet() method is later called, trying to read this.nameTarget.value results in an error.

I hope this helps to provide a bit of context around the issue.

Bumped into this issue myself. Most certainly a tricky one to solve. Maybe decorator? Too bad it's still in the proposal stage.

It certainly makes sense that the spec tells that extending a class and declaring a property initializes such property, overriding whatever the base class has.

FWIW, I used the following technique to fool the TS compiler:

class HelloBaseController extends Controller {
  public nameTarget!: HTMLInputElement;
}

export class HelloController extends (Controller as typeof HelloBaseController) {
  public static targets = ['name'];
}

@fiznool, does it work correctly if you remove the readonly from readonly nameTarget!: Element?

I know that the following works correctly with TypeScript itself, but I'm not familiar with Babel's TypeScript support.
https://github.com/stimulusjs/stimulus/blob/7f58b5597292e88423c3fa9ccd0e6f25ffc83192/packages/%40stimulus/core/src/tests/target_controller.ts#L3-L9

@javan

does it work correctly if you remove the readonly from readonly nameTarget!: Element?

I’m afraid not. AFAIK this isn’t really an underlying issue with TypeScript, it’s the babel plugin that is assigning the class property to void 0. It doesn’t matter how it is annotated in TypeScript, the end result is an uninitialised class property, which the babel plugin assigns as per the spec.

I know that the following works correctly with TypeScript itself, but I'm not familiar with Babel's TypeScript support.

When using TypeScript with babel 7, TypeScript is configured to compile down to the latest esnext. This results in modern JS code which is then transpired by babel and its plugins. I’m guessing that babel is therefore seeing esnext JS code with class properties, which the plugin is then acting upon.

This is therefore an incompatibility with how TypeScript expects class properties to be typed and babel’s processing of them. Couple all of this with Stimulus’ ‘magic’ class properties, generated from the targets static, we have a bad time!

@guzart

FWIW, I used the following technique to fool the TS compiler

Thank you for this. I will adopt so I can continue using TS in my controllers. 💜

Closing this because it doesn’t seem to be an issue with Stimulus, but please feel free to continue discussing TypeScript and Babel compatibility over on the community forum.

Hi ! Just to let you know that I'm having the same issue after upgrading webpacker to 5.1 which stops using ts-loader and uses @babel/preset-typescript instead.

@fiznool did you find another workaround since then ? Thanks !

@Intrepidd I did find a workaround - you can use the declare keyword to define the class property. On seeing a declared property, Babel will strip this from the generated code, as it considers declare as type information only.

So the original example can be rewritten as follows:

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "name" ]
  declare readonly nameTarget: Element

  greet() {
    const element = this.nameTarget
    const name = element.value
    console.log(`Hello, ${name}!`)
  }
}

Note that you'll need to opt-in to this behaviour with Babel 7 via the allowDeclareFields option to @babel/plugin-transform-typescript. I had to use the transform directly as the option didn't seem to work using the preset. Babel 8 will make this the default behaviour.

Thanks a lot ! On my end I discovered that entirely removing the line readonly nameTarget: Element works for me now, when it didn't when using ts-loader

Yes, that will work now as when using with Babel, TypeScript is relegated to type checking only, meaning that type errors will not prevent compilation (since Babel is in charge of compiling). You’ll lose type safety by not declaring the class property, however - when calling this.nameTarget from a method, TS can’t provide any additional information for autocomplete purposes, etc.

Makes a lot of sense! I'll go with your workaround then, thanks!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

JanaDeppe picture JanaDeppe  Â·  4Comments

saneef picture saneef  Â·  4Comments

ngan picture ngan  Â·  3Comments

pherris picture pherris  Â·  3Comments

jenkijo picture jenkijo  Â·  3Comments