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>
#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.
So what we need to do is cast it:
const el = /** @type {TitleBar} */ (document.querySelector('title-bar'));
Now we already get auto completion. ๐
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.
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.
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';
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.
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.
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' };
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.
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 = '' } = {}) {
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 = '' } = {}) {
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: '' }) {
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 ๐ช
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 ๐ญ
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.
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: ```
Follow me on [Twitter](https://twitter.com/daKmoR).
If you have any interest in web component make sure to check out [open-wc.org](https://open-wc.org).
Top comments (9)
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:
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
Great tutorial. It's also possible to use a
jsconfig.json
file instead oftsconfig.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 atsconfig.json
by default is a smarter choice? ๐คWhat would be your use-case for it? when would you prefer a
jsconfig.json
over atsconfig.json
?I would push for
jsconfig.json
if my team was unwilling or unable to use TypeScript (maybe mandated by high-ups). Because introducingtsc
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 :-)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.
Awesome post! ๐