loading...

Type-Safe Web Components with JSDoc

dakmor profile image Thomas Allmer Updated on ・11 min read

Writing code is tough and writing it in a way that makes sense to others (or your future self) is even tougher. That's why documentation is a very important part of every software project.

I'm sure we've all found ourselves in the following situation: You're happily coding and just found a nice library that can help you, so you start using it...

import foo from 'foo-lib';

foo.doTheThing(//...

But, did foo.doTheThing() take a string first and then the number or the other way around?

So you head over to http://foo-lib.org and about 5 clicks later you get to the function signature and find out how to use it. First of all, you're already lucky as not many libraries have good documentation 😱

However it already painfully shows that the information is not as close to your workflow as it should be. You have to stop coding and search for the info while it could be directly in your editor. 😊

So we can definitely do better πŸ€— Let's get started with a very simple web component.

Note: We will be assuming the editor in use is VS Code.

If you wanna play along - all the code is on github.

<title-bar>

title-bar

<title-bar>
  #shadow-root (open)
    <h1>You are awesome</h1>
    <div class="dot" style="left: 0px; top: 0px" title="I am dot"></div>
</title-bar>

It's just a little box with a

  • title property
  • darkMode property/attribute
  • formatter function
  • a sidebar property on the left

We will use LitElement to create it.

Note: We use JavaScript here - but for the most part (except for the type casting & definitions) the example would be the same for TypeScript.

import { LitElement, html, css } from 'lit-element';

export class TitleBar extends LitElement {
  static get properties() {
    return {
      title: { type: String },
      darkMode: { type: Boolean, reflect: true, attribute: 'dark-mode' },
      bar: { type: Object },
    };
  }

  constructor() {
    super();
    this.title = 'You are awesome';
    this.darkMode = false;
    this.bar = { x: 0, y: 0, title: 'I am dot' };
    this.formatter = null;
  }

  render() {
    // positioning the bar like this is just for illustration purposes => do not do this
    return html`
      <h1>${this.format(this.title)}</h1>
      <div
        class="dot"
        style=${`left: ${this.bar.x}px; top: ${this.bar.y}`}
        title=${this.bar.title}
      ></div>
    `;
  }

  format(value) {
    // we'll get to this later
  }

  static get styles() {
    // we'll get to this later
  }
}

customElements.define('title-bar', TitleBar);

What you get when you use it

Let's query our newly created element. 😊

const el = document.querySelector('title-bar');

Here our editor can't know what el actually is so there is no way it can help us in writing better code.
That means no code completion for our own properties even though that information is available.

autoCompleteMissing

So what we need to do is cast it:

const el = /** @type {TitleBar} */ (document.querySelector('title-bar'));

Now we already get auto completion. πŸŽ‰

autoCompleteTypes

However we can still write code like

el.foo = 'bar';
el.title = true;

and nobody will complain.

Let's change that πŸ’ͺ

Add type linting

Add a tsconfig.json file to your project

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"],
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "esModuleInterop": true
  },
  "include": [
    "src",
    "test",
    "node_modules/@open-wc/**/*.js"
  ],
  "exclude": [
    "node_modules/!(@open-wc)"
  ]
}

That is all you need to get VS Code to mark the code as having a problem:

Property 'foo' does not exist on type 'TitleBar'.
Type 'true' is not assignable to type 'string'.

You can even go further by doing the linting in the console and your continuous integration.

All you need to do is:

npm i -D typescript

And add this script to you package.json

  "scripts": {
    "lint:types": "tsc"
  }

Then we can execute it as:

npm run lint:types

This will give you the same error as above but with a filepath and line number.

So just by doing these few extra things your IDE can help you to stay type safe.

Honestly, it will not be a gentle reminder - those red curly lines are hard to ignore and if you need some extra motivation you can hit F8 which will just throw the next error in your face :p.

showTypeErrors

How does it work?

If you are like me you are probably wondering how does it know what properties are of which type? I certainly did not define any types yet!

Typescript can make a lot of assumptions based on your ES6 code. The actual magic lays in the constructor:

constructor() {
  super();
  this.title = 'You are awesome';
  this.darkMode = false;
  this.bar = { x: 0, y: 0, title: 'I am dot' };
  this.formatter = null;
}
  • title is obviously a string
  • darkMode a boolean
  • bar an object with x, y as number and title a string

So just by defining your initial values within the constructor most of your types should be good to go. πŸ‘
(Don't worry β€” I did not forget formatter, we'll get to it shortly)

Types are already awesome but we can do even better.

Look at the intellisense in VS Code.

intellisenseTitleTyped

Currently it's really minimal... So let's add some JSDoc:

/**
 * The title to display inside the title bar
 * - should be less then 100 characters
 * - should not contain HTMl
 * - should be between 2-5 words
 *
 * @example
 * // DO:
 * el.title = 'Welcome to the jungle';
 *
 * // DON'T:
 * el.title = 'Info';
 * el.title = 'Welcome to <strong>the</strong> jungle';
 * el.title = 'We like to talk about more then just what sees the eye';
 */
this.title = 'You are awesome';

intellisenseTitleTypedJsDoc

much better 😊

Note: You do not need to add the @type here as it's clear that it's a string and if you add it - it may get out of sync at some point.

Manually set types

If we look at

this.formatter = null;

There is no way to see from this line alone what the property will hold.
You could assign an empty/default function like

this.formatter = value => `${value}`;

but this does not make sense in all case.
In our example, we would like to skip the formatting if there is no formatter function.
Having a default function would defeat its purpose.
In these cases, it's mandatory to provide a @type and you can do so using JSDoc.

/**
 * You can provide a specific formatter that will change the way the title
 * gets displayed.
 *
 * *Note*: Changing the formatter does NOT trigger a rerender.
 *
 * @example
 * el.formatter = (value) => `${value} for real!`;
 *
 * @type {Function}
 */
this.formatter = null;

That way if you provide a wrong type it will show an error.

el.formatter = false;
// Type 'false' is not assignable to type 'Function'.

Also the immediately appearing @example really makes it easy to create your own formatter.

intellisenseFormatterTypedJsDoc

Setup your own types and use them

There is one more property that doesn't look too nice yet, and that is the bar property.

intellisenseBarTyped

Our type safety already works here, which is great, but we only know that x is a number; there is no additional info.
We can improve this with JSDocs as well.

So we define a special type called Bar.

/**
 * This is a visible bar that gets displayed at the appropriate coordinates.
 * It has a height of 100%. An optional title can be provided.
 *
 * @typedef {Object} Bar
 * @property {number} x The distance from the left
 * @property {number} y The distance from the top
 * @property {string} [title] Optional title that will be set as an attribute (defaults to '')
 */

Doing so we can also define certain properties as being optional.
The only thing we need to do then is to assign it.

/**
 * @type {Bar}
 */
this.bar = { x: 0, y: 0, title: 'I am dot' };

intellisenseBarTypedJsDoc

Add types to function parameters

Let's create a simple format function which will allow for prefix/suffix by default and if you need more you can just override the formatter.

Note: this is not a super useful example but good enough for illustration purposes

format(value = '', { prefix, suffix = '' } = { prefix: '' }) {
  let formattedValue = value;
  if (this.formatter) {
    formattedValue = this.formatter(value);
  }
  return `${prefix}${formattedValue}${suffix}`;
}

Again just by using default options it already knows all the types.

intellisenseFormatTyped

So just adding a little documentation is probably all you need.

/**
 * This function can prefix/suffix your string.
 *
 * @example
 * el.format('foo', { prefix: '...' });
 */
format(value = '', { prefix = '', suffix = '' } = {}) {

intellisenseFormatTypedJsDocsOnlyDescription

Or if you want to have a union type (e.g. allow strings AND numbers).
Be sure to only document what you actually need as with this method you override the default types and that means things could get out of sync.

/**
 * This function can prefix/suffix your string.
 *
 * @example
 * el.format('foo', { prefix: '...' });
 *
 * @param {string|number} value String to format
 */
format(value, { prefix = '', suffix = '' } = {}) {

intellisenseFormatTypedJsDoc

If you really need to add very specific descriptions to every object options then you need to duplicate the typings.

/**
 * This function can prefix/suffix your string.
 *
 * @example
 * el.format('foo', { prefix: '...' });
 *
 * @param {string} value String to format
 * @param {Object} opts Options
 * @param {string} opts.prefix Mandatory and will be added before the string
 * @param {string} [opts.suffix] Optional and will be added after the string
 */
format(value, { prefix, suffix = '' } = { prefix: '' }) {

intellisenseFormatTypedJsDocExtraAllOptions

Importing Types across files

Files never live in isolation so there might come a point where you want to use a type within another location.
Let's take our good old friend the ToDo List as an example.
You will have todo-item.js & todo-list.js.

The item will have a constructor like this.

constructor() {
  super();
  /**
   * What you need to do
   */
  this.label = '';

  /**
   * How important is it? 1-10
   *
   * 1 = less important; 10 = very important
   */
  this.priority = 1;

  /**
   * Is this task done already?
   */
  this.done = false;
}

So how can I reuse those type in todo-list.js.

Let's assume the following structure:

<todo-list>
  <todo-item .label=${One} .priority=${5} .done=${true}></todo-item>
  <todo-item .label=${Two} .priority=${8} .done=${false}></todo-item>
</todo-list>

and we would like to calculate some statistics.

calculateStats() {
  const items = Array.from(
    this.querySelectorAll('todo-item'),
  );

  let doneCounter = 0;
  let prioritySum = 0;
  items.forEach(item => {
    doneCounter += item.done ? 1 : 0;
    prioritySum += item.prio;
  });
  console.log('Done tasks', doneCounter);
  console.log('Average priority', prioritySum / items.length);
}

The above code actually has an error in it 😱
item.prio does not exists. Types could have saved us here, but how?

First let's import the type

/**
 * @typedef {import('./todo-item.js').ToDoItem} ToDoItem
 */

and then we type cast it.

const items = /** @type {ToDoItem[]} */ (Array.from(
  this.querySelectorAll('todo-item'),
));

And there we already see the type error πŸ’ͺ

importCast

Use Data Objects to create Custom Elements

In most cases, we do not only want to access an existing DOM and type cast the result but we would like to actually render those elements from a data array.

Here is the example array

this.dataItems = [
  { label: 'Item 1', priority: 5, done: false },
  { label: 'Item 2', priority: 2, done: true },
  { label: 'Item 3', priority: 7, done: false },
];

and then we render it

return html`
  ${this.dataItems.map(
    item => html`
      <todo-item .label=${item.label} .priority=${item.priority} .done=${item.done}></todo-item>
    `,
  )}
`;

How can we make this type safe?

Unfortunately, simply casting it via @type {ToDoItem[]} does not really work out 😭

ElementAsObjectFail

It expects the object to be a full representation of an HTMLElement and of course our little 3 property object does miss quite some properties there.

What we can do is to have a Data Representation of our web component. e.g. define what is needed to create such an element in the dom.

/**
 * Object Data representation of ToDoItem
 *
 * @typedef {Object} ToDoItemData
 * @property {string} label
 * @property {number} priority
 * @property {Boolean} done
 */

We can then import and type cast it

/**
 * @typedef {import('./todo-item.js').ToDoItemData} ToDoItemData
 * @typedef {import('./todo-item.js').ToDoItem} ToDoItem
 */

// [...]

constructor() {
  super();
  /**
   * @type {ToDoItemData[]}
   */
  this.dataItems = [
    { label: 'Item 1', priority: 5, done: false },
    { label: 'Item 2', priority: 2, done: true },
    { label: 'Item 3', priority: 7, done: false },
  ];
}

And πŸŽ‰ type safety for web component AND its data.

ItemDataTypeErrors

Let your users consume your types

One thing that is a little tougher if you have types not as definition files is how you can make them available.

Generally speaking, you will need to ask your users to add a tsconfig.json like this

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"],
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "esModuleInterop": true
  },
  "include": [
    "**/*.js",
    "node_modules/<your-package-name>/**/*.js"
  ],
  "exclude": [
    "node_modules/!(<your-package-name>)"
  ]
}

The important part is the include and not exclude of your package name.

If you think that is a little complicated you are right. There are ideas to improve this flow however it seemed to not have gotten much attention lately - Give it your thumbs up and join the conversation.

For full TypeScript project you might want to do a little more like have 2 tsconfigs.json one for linting and one for buildling (as allowJs prevent automatic creation of definition files).

You can find more details about such an approach at Setup For Typescript on Open Web Components.

Quick recap:

Equipped with these options for properties/functions you should be fine for most web components.

  • Set defaults for properties in constructor and the type will be there automatically
  • If you do not have a default make sure to add @types
  • Add additional information/docs/examples as JSDoc for a nicer developer experience
  • Make sure to type cast your dom results
  • Add type linting via console/continuous integration to make sure they are correct
  • Inform your users how they can consume your types
  • Bookmark the Typescript JSDoc Reference

If you need more information on additional JSDoc features for types take a look at Type Safe JavaScript with JSDoc. I highly recommend reading it!

The full code can be found on github.
To see how your users will get it look at the tests.

What's next?

  • These are steps that can help make web components simpler and saver to use.
  • Not everything here is useful for every situation and there will be definitely situations where we don't have a recipe yet.
  • If you encounter any issues (hopefully + solution) please let us know and we will add it to this "Cookbook for types with web components".
  • VS Code is working on making a way to bring autocomplete to declarative html by having a definition for web components attribute - See the proposal to allow for getting errors if undefined attributes are used:
<my-el undefined-attribute>

Follow me on Twitter.
If you have any interest in web component make sure to check out open-wc.org.

Discussion

markdown guide
 

That's truly awesome!

Did you explore the possibility to generate runtime "validators" for types? I think it can be useful to validate API responses or params from other components.

Also, I might miss something - can it be integrated with markup too?
Like:

return html`<my-element .unExistentProperty=${someVar} />`;

and the linting/VSCode complains about unExistentProperty

 

For runtime validators I'm not really sure if that can bring the functionality you want. Maybe take a look at Type Safety at Runtime where it argues that only you as a developer can truly make it happen.

For validation of API responses there is the OpenAPISpecification or Pact to get a strong contract for responses. I agree it would be nice to get types from the backend to the frontend but I do not know if there is any active development for such a spec. I know that certain full stack frameworks offer such functionality but to as far as I know these are always proprietary solutions with no intention to becoming a spec.

For markup type safety VS Code is currently working on using a web components "declaration file". Currently, it only supports attributes so please join the conversation and lets extend it with properties as well :)

 

Vaadin Connect is pretty cool, it generates TypeScript types for whatever you return from your java backend

 

One more thing to do is to fix this awkward enabling approach. I would like to have a kind of compiler option that allows reading JSDoc from source files without doing tricks with regexes. Is there any issue about it at the Typescript GitHub?

 

There are ideas to improve this flow however it seemed to not have gotten much attention lately - Give it your thumbs up and join the conversation.

 

Great tutorial. It's also possible to use a jsconfig.json file instead of tsconfig.json, this tells VSCode that it's a JavaScript project: twitter.com/yawaramin/status/92916...

 

totally correct - I'm however not sure if there is a benefit in doing this?
You will almost need the exact same settings and it will not allow you to use tsc to do actual type linting - e.g. it will show you the errors in the IDE but you can't "enforce" it. That's about the only difference I can think of - so it seems using a tsconfig.json by default is a smarter choice? πŸ€”

What would be your use-case for it? when would you prefer a jsconfig.json over a tsconfig.json?

 

I would push for jsconfig.json if my team was unwilling or unable to use TypeScript (maybe mandated by high-ups). Because introducing tsc into the build would mean we would want to also introduce it in CI, and that would go against the mandate. If the team was able to adopt TypeScript, I would just do the conversion–probably incrementally. If the team was willing to look at less mainstream alt-JS options, I would push for ReasonML :-)