Subclassable components (TypeScript)

The TypeScript documentation about writing subclassable components mentions writing a MyList<T> component that accepts any child Component of type T.

Next to this, another usecase is about writing a flexible base component, MyBaseComponent<TemplateSpecType, TypeConfig>. It has a basic code sample, which describes how to set it up.

I’m interested in the first usecase, but I’m having troubles setting it up. What I’m looking for is the structure of a base page, which includes a Header & Content component. And I would like to use generics to ensure type checking.

This is my current code:

interface IPageTemplateSpec extends Lightning.Component.TemplateSpec {
    Header: typeof Header;
    Content: object;
}

interface IPageTypeConfig extends Lightning.Component.TypeConfig {
    IsPage: true;
}

class Page
    extends Lightning.Component<IPageTemplateSpec, IPageTypeConfig>
    implements Lightning.Component.ImplementTemplateSpec<IPageTemplateSpec>
{
    static override _template(): Lightning.Component.Template<IPageTemplateSpec> {
        return {
            w: (w: number) => w,
            h: (h: number) => h,
            rect: true,
            color: 0xff0e0e0e,

            Header: {
                type: Header,
            },
            Content: {},
        };
    }

    protected Content = this.getByRef("Content")!;
}

Then I create a child Discovery page which extends the base Page:

export class Discovery extends Page {
    static override _template(): Lightning.Component.Template<IPageTemplateSpec> {
        const pageTemplate = super._template();

        return pageTemplate;
    }
}

Then I apply generics to the IPageTemplateSpec interface, in order to use any child component like this:

interface IPageTemplateSpec<T extends Lightning.Component = Lightning.Component>
    extends Lightning.Component.TemplateSpec {
    Header: typeof Header;
    Content: T;
}

class Page<T extends Lightning.Component = Lightning.Component>
    extends Lightning.Component<IPageTemplateSpec<T>, IPageTypeConfig>
    implements Lightning.Component.ImplementTemplateSpec<IPageTemplateSpec<T>>
{
    static override _template(): Lightning.Component.Template<
        IPageTemplateSpec<Lightning.Component>
    > {
        return {
            w: (w: number) => w,
            h: (h: number) => h,
            rect: true,
            color: 0xff0e0e0e,

            Header: {
                type: Header,
            },
            Content: {},
        };
    }

    protected Content = this.getByRef("Content")!;
}

export class Discovery extends Page<List> {
    static override _template(): Lightning.Component.Template<
        IPageTemplateSpec<List>
    > {
        const pageTemplate = super._template();

        pageTemplate.Content = {
            type: List,
        };

        return pageTemplate;
    }
}

With this code I get 2 errors:

  • line protected Content = this.getByRef("Content")! shows:
    Argument of type ‘string’ is not assignable to parameter of type ‘keyof TemplateSpecRefs<IPageTemplateSpec>’.
    Type ‘“Content”’ is not assignable to type ‘“Header” | (TransformPossibleElement<P, IPageTemplateSpec[P], never> extends never ? never : P) | ((T extends Constructor<…> ? InstanceType<…> : Element<…>) extends never ? never : “Content”)’.ts(2345)
  • line pageTemplate.Content = { type: List, }; shows:
    *Type ‘{ type: any; }’ is not assignable to type Template<CompileElementTemplateSpecType<InlineElement<Component<TemplateSpecLoose, TypeConfig>>, TypeConfig>>'.
    Object literal may only specify known properties, and ‘type’ does not exist in type 'Template<CompileElementTemplateSpecType<InlineElement<Component<TemplateSpecLoose, TypeConfig>>, TypeConfig>>.

What am I not doing correct in here?

Hi @p_eijkelhardt Thanks for the post!

I took a deep dive into this today and I have a solution that should work. The article you referenced is obviously lacking details on how to create subclassible components where the type parameter will be used directly as a Ref in the TemplateSpec. This use case also seems to identify a bug with getByRef(). Also for the sake of simplicity, I’m going to assume you are happy with only needing to supply as Component here and not any arbitrary Element.

There’s a few things that need to change from your example to get it to work:

1. Use Lightning.Component.Constructor

The generic type parameter for IPageTemplateSpec should be changed to Lightning.Component.Constructor:

export interface IPageTemplateSpec<
  T extends Lightning.Component.Constructor = Lightning.Component.Constructor,
> extends Lightning.Component.TemplateSpec {
  Header: typeof Header
  Content: T
}

This is the way to constrain the parameter to any Component type.

Also make the same change in the Page class.

2. Leave the Content key in Page._template() undefined

This may not be strictly required, but it’s more sound. From the perspective of Page it has no idea what types of components its subclasses will use so it shouldn’t make any assumptions. Using {} here defines an empty Element. Which in the case of the Discovery page will not be valid, since it is required to be a List. undefined is always a valid value for a ref, no matter what type is expected.

export class Page<T extends Lightning.Component.Constructor = Lightning.Component.Constructor>
  extends Lightning.Component<IPageTemplateSpec<T>, IPageTypeConfig>
  implements Lightning.Component.ImplementTemplateSpec<IPageTemplateSpec<T>>
{
  static override _template(): Lightning.Component.Template<IPageTemplateSpec> {
    return {
      w: (w: number) => w,
      h: (h: number) => h,
      rect: true,
      color: 0xff0e0e0e,

      Header: {
        type: Header,
      },
      Content: undefined,
    }
    // ...
  }

3. Use tag() instead of getByRef() (Bug!!)

There seems to be a bug using getByRef() for TemplateSpec keys with generic values. We’re looking into it and should have a solution soon. Luckily enough, tag() works without any issue.

4. use typeof List as the argument value in Discovery

Just like you would use typeof List in a TemplateSpec directly, you should also now pass that as the generic type argument for Page in Discovery.

export class Discovery extends Page<typeof List> {
  // ...
}

5. Assert super._template() as specific template

Because class generics in TypeScript can’t be applied to static methods, the call to super._template() is unaware that the template should be using typeof List for the Content ref. To best fix the error (and make it as type sound as possible), you need to assert the return value of that call to the specific Template type needed by Discovery.

export class Discovery extends Page<typeof List> {
  static override _template(): Lightning.Component.Template<IPageTemplateSpec<typeof List>> {
    const pageTemplate = super._template() as Lightning.Component.Template<
      IPageTemplateSpec<typeof List>
    >

    pageTemplate.Content = {
      type: List,
    }

    return pageTemplate
  }

  // ...
}

I put together the solution above into a GitHub repo. The components are not actually used in the app, but the type checking should be accurate.

Let me know how this works out for you. We’ll try to get this documented also soon.

Hi @frank , thanks for the detailed explanation!

I’m still in the research phase, but I think using Components instead of any Element seems to be fine. I was just a matter of finding the correct way of achieving this.

I’ll make a note about the getByRef() bug, so we’ll be using tag() instead.

Furthermore, you’ve added a List component, which seems a valid way of creating a new Component. However, I was trying to use the List component from the UI library (@lightningjs/ui). Due to the lack of TypeScript support, I’ve used it like this (without typeof). I think I need to write my own type definitions file to support it properly. Perhaps there’s already such file present for future release that you know of?

We have it on our roadmap to create types for @lightningjs/ui but I’m unsure of the priority right now. You might want to stub some out as needed for now.

In other news, I’ve drafted some new documentation for the above scenario: docs(TypeScript): Add example of basic subclassing by frank-weindel · Pull Request #446 · rdkcentral/Lightning · GitHub

1 Like