Glimmer Component args & Typescript

I’ve been porting an addon to use Typescript, and ran into an issue I haven’t found a solution for - maybe somebody here can help me out.

How do I specify typings for this.args in a @glimmer/component?

I started from this baseline (slightly simplified, but it shows the issue):

import Component from '@glimmer/component';
import { assert } from '@ember/debug';

export default class TextInput extends Component {
  constructor() {
    super(...arguments);

    assert(`onChange must be set`, typeof this.args.onChange === 'function');
  }
}

Which gives the error Property "onChange" does not exist on type "Object".

Next I tried this:

export default class TextInput extends Component {
  args: {
    onChange: Function
  };

  constructor() {
    super(...arguments);

    assert(`onChange must be set`, typeof this.args.onChange === 'function');
  }
}

Which fixed the Typescript Error, but lead to args always being undefined when running the app.

Finally I tried this:

interface ArgsInterface {
  args: {
    onChange: Function;
  };
}

export default class TextInput extends Component implements ArgsInterface {
  constructor() {
    super(...arguments);

    assert(`onChange must be set`, typeof this.args.onChange === 'function');
  }
}

Which results in a TS error Component TextInput incorrectly impelements ArgsInterface. Types of property args are incompatible..

Has anybody managed to get this to work properly?

Hopefully we will have docs for this soon, but the trick here is that Glimmer components are generic over their arguments. So you write it like this:

import Component from '@glimmer/component';
import { assert } from '@ember/debug';

interface Args {
  onChange: Function;
}

export default class TextInput extends Component<Args> {
  constructor(owner: unknown, args: Args) {
    super(...arguments);

    assert(`onChange must be set`, typeof args.onChange === 'function');
  }
}

This will also make this.args work as expected throughout.


Note that validating types manually this way in constructor, while better than nothing, won’t help if the arguments to the component change over time. We’re still evaluating the best ways to help with that. Long term, type-checked template invocations will solve it, but @pzuraq and @dfreeman and I have played a bit with ideas around helpers to do this kind of thing nicely with zero runtime costs. Probably won’t have anything to show till late this year, but hopefully we will have something to show then.

That works just fine - thanks! No idea why, but I’m also just starting with Typescript, so maybe that will become clearer at some point in the future :wink:

The assert() stuff used to be in didReceiveAttrs, but having it in constructor is “good enough” for most of our use cases. :slight_smile:

1 Like

I highly recommend the TypeScript Deep Dive book as a reference, if you’re just getting started! You’ll find its chapter on generics to be quite helpful on this specific bit.

In short, though, the type of Glimmer Component looks something like this (there’s a bit more, but this is close enough for our purposes):

class Component<Args extends {} = {}> {
  args: Args;

  constructor(owner: unknown, args: Args) {
    // sets up the component
  }
}

That means that the component always has a property named args

  • with the type Args
  • which can be anything that extends the type {} – an object
  • and defaults to being just an empty object – = {}

This is analogous to the type of Array: since you can have an array of string, or an array of number or an array of SomeFancyObject, the type of array is Array<T>, where T is the type of thing in the array, which TypeScript normally figures out for you automatically at compile time:

let a = [1, 2, 3];  // Array<number>
let b = ["hello", "goodbye"]; // Array<string>

In the case of the Component, we have the types the way we do so that you can’t accidentally define args as a string, or undefined, or whatever: it has to be an object. Thus, Component<Args extends {}>. But we also want to make it so that you can just write extends Component, so that needs to have a default value. Thus, Component<Args extends {} = {}>.

I’ll try to expand this pair of answers into a blog post later this week, and also to work them into our documentation before Octane officially ships. :sweat_smile:

4 Likes

Thanks very much for that - that definitely helps to understand what’s going on! Looking forward to your blog post :wink:

1 Like