Skip to content

Instantly share code, notes, and snippets.

@web-padawan
Last active July 23, 2020 13:46
Show Gist options
  • Save web-padawan/4d33a039c73ca213eeb5124358d7be40 to your computer and use it in GitHub Desktop.
Save web-padawan/4d33a039c73ca213eeb5124358d7be40 to your computer and use it in GitHub Desktop.

Using TypeScript definitions

Vaadin components come with TypeScript definitions helping to use web components in TypeScript views. The type definitions are d.ts files generated every time when new release is published to npm.

Support for TypeScript definitions is added in Vaadin 17. In most of cases, it does not require any changes to the code. At the same time, using proper types for the web components helps to make the client side views more reliable. Depending on the IDE you use, TypeScript definitions can also give additional benefits like better code completion and auto import.

If you are using Visual Studio Code, we recommend to install lit-plugin which provides syntax highlighting and type checking support for LitElement templates. You can also use lit-analyzer CLI tool by the same author for type checking.

Importing type declarations

Every TypeScript definition file exports type declarations, which describe the public API provided by the web component. In order to use the type declarations in your client side views, you need to import them using type-only import syntax:

import type { DialogElement } from '@vaadin/vaadin-dialog';

The type declarations are only used by the TypeScript compiler to statically analyze the code and collect information about types. They are removed during webpack compilation step and will not end up in the resulting JavaScript bundle.

Using type declarations

In some cases you might need to get a reference to the web component, and use it to set properties or call methods. Below you will find examples of how to improve developer experience in such cases by applying proper types.

@query decorator

The suggested way of storing a reference in LitElement based view is @query decorator. When using it, you need to provide a correct type for the component you are referring to:

@query('#dialog')
private dialog!: DialogElement;

Note the ! sign used in the property declaration. It is a definite assignment assertion operator. This way we are telling the TypeScript compiler that dialog property is initialized indirectly, and therefore it does not need a default value.

Renderer functions

Some Vaadin components like Grid and Dialog allow to pass the <template> element and then use it to stamp the content. This API is not compatible with lit-html, so in LitElement-based views we recommend using renderer functions instead.

Renderer function is a class method that accepts one or several arguments. In order to access them without TypeScript warnings, you need to get them properly typed by importing and using corresponding type declarations:

protected indexRenderer(root: HTMLElement, column: GridColumnElement, model: GridItemModel) {
  render(html`<div>${model.index}</div>`, root);
}

Here we use GridItemModel type declaration exported by the @vaadin/vaadin-grid npm package. It is a TypeScript interface describing the properties available on the model, including index. We also use GridColumnElement declaration.

See also the full example of using renderer functions on the Grid columns, including some aspects related to making this work in renderers. There are similar examples for Select, Dialog, ContextMenu and Notification components.

Event listeners

When using event listeners in LitElement-based views, we might want to get the reference to the event target and then access its value. One common case for this is handling a change event.

While we typically only use one listener per element, the lit-html syntax for binding listeners does not let TypeScript know what type of component it is. So we should care about it ourselves:

render() {
  return html`
    <vaadin-text-field @change="${this.onChange}"></vaadin-text-field>
  `
}

onChange(event: Event) {
  const field = event.composedPath()[0] as TextFieldElement;
  // <vaadin-text-field> has a value property.
  console.log(field.value);
}

We use as syntax, which is a type assertion, often called "type cast". It is a hint for the TypeScript compiler forcing it to use the type that we provide. In this case we are confident about the type, but generally type casts should be avoided.

Registering elements

When creating your own custom elements for using with client side views, you might want to instruct TypeScript to use your definitions. This is not strictly required, but sometimes it improves developer experience and allows to write less code.

As an example, let's look into using querySelector and querySelectorAll methods with your own custom elements. These methods return Element, so the easiest workaround would be probably to use a type cast:

const items = this.renderRoot.querySelectorAll('color-item') as ColorItem[];
items.forEach(item => {
  // access item properties
});

However, this approach isn't clean, as it requires to write as ColorItem[] every time the method is called. There is a better alternative: registering a class corresponding to the HTML tag name in the built-in HTMLElementTagNameMap interface:

declare global {
  interface HTMLElementTagNameMap {
    'color-item': ColorItem;
  }
}

Now, every time when you call querySelector or querySelectorAll with a corresponding tag name, TypeScript compiler will infer the proper type automatically, making the type cast no longer necessary:

const items = this.renderRoot.querySelectorAll('color-item');
items.forEach(item => {
  // access item properties
});

The TypeScript definitions for Vaadin components provide these registrations, so you don't have to use type casts. Apart from the query methods, this applies to few other methods, such as createElement and closest.

Limitations

The current implementation of Vaadin components has limitations related to using TypeScript definitions. They are partially caused by the fact that the components are written in JavaScript, and the d.ts files are generated from JSDoc comments.

Custom events

Vaadin components dispatch custom events, such as value-changed. These events are different from the native DOM events like click. Firstly, they have detail property, which has type of any. This means that TypeScript does not have any information about the properties that might exist on the detail object for the particular custom event.

Another problem affecting developer experience is the fact that custom events can not be used in addEventListener directly. Attempting to do it would result in the TypeScript compilation error "No overload matches this call".

The possible workaround for it would be using another built-in interface called HTMLElementEventMap. You can add the following code to prevent TypeScript from complaining about incorrect addEventListener usage:

declare global {
  interface HTMLElementEventMap {
    'value-changed': CustomEvent;
  }
}

The challenge is that different Vaadin components might use different types for the same value property. So this is not something we currently support. We consider this an enhancement and not a bug. See also the tracking issue.

items property

Certain Vaadin components, namely Grid, ComboBox and CRUD, support setting items property as an array of objects. Typically, when using a component, we know what type of objects we expect, and we prefer to only declare it once.

In TypeScript, this could be achieved using generic types. However, because of the way the components are implemented, we would preferably need to infer the items type also in the renderer functions, as the model.item argument type.

This feature appears to be non-trivial, keeping in mind that we generate type definitions from JSDoc. So we decided to use unknown[] for the items property type, and then use type cast in the renderers:

nameRenderer(root: HTMLElement, column: GridColumnElement, model: GridItemModel) {
  const user = model.item as User;
  render(html`<div>${user.firstName} ${user.lastName}</div>`, root);
}

While using type casts is not the best idea in terms of type safety and developer experience, we do not have a better option at the moment. So for now we recommend this approach. Please see the issue where this enhancement is being tracked.

Examples

We are working on improving our documentation to provide more components examples and recipes in TypeScript. While this work is in progress, check out TypeScript Vaadin examples project for live demos of using Vaadin components.

If you would like to request a code example that is missing from the live demos, feel free to submit an issue and describe your problem. We aim to make the developer experience with TypeScript definitions as smooth as possible.

@bennypowers
Copy link

Should those be Generics in that case?

@web-padawan
Copy link
Author

IMO generics is a different issue. I'm still thinking what would be the best way to organise exports for components with multiple elements.
This mainly affects @vaadin/vaadin-grid and @vaadin/vaadin-text-field. I will create an issue tomorrow to discuss it further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment