For reasons beyond my control I'm working with Polymer 2 at the moment. Although the idea of web components is great, the choice for HTML imports that comes with Polymer 2 makes integration into a modern development stack cumbersome, as will become clear soon. Also, HTML imports are not widely supported by browsers and although polyfills exist, only Chrome (surprise!) will have native support for the foreseeable future.
Using TypeScript seems like a good choice, because static typing helps prevent runtime errors. Additionally it would be a good opportunity to try out the Scala-TS-interfaces project by my colleagues, that can generate TypeScript from a Scala domain model. Unfortunately, adding TypeScript to a Polymer 2 development stack proves to be difficult, whereas using it with Polymer 3 seems trivial. Polymer 3 is currently in preview so it is not a viable option for me for the moment, but it will exchange HTML imports for ES6 Modules. This will make integrating it into a modern development stack much easier. An example already exists, by Paolo Ferretti and follows normal conventions for a TypeScript project.
If you're adventurous, don't need any existing Polymer 2 elements and don't need to run production; stop reading here and use Polymer 3. If you need Polymer 2 read on, but be warned that it won't be pretty.
TL;DR to use TypeScript with Polymer 2 use typescript-batch-compiler or even better twc.
For this experiment I will use my existing Polygram project and the result will be available in the TypeScript branch.
The first challenge is to use Webpack with Polymer 2. Although not strictly necessary for TypeScript compilation, it would make sense for importing HTML as modules. Fortunately, Rob Dodson himself wrote an article How to use Polymer with Webpack. It even mentions TypeScript! The article introduces the Webpack loader https://github.com/webpack-contrib/polymer-webpack-loader and explains how it extracts the JavaScript from the HTML of Polymer elements and eventually packages everything into one JavaScript file. I was basically able to copy the webpack.config.js and index.ejs from his demo project, place it into Polygram and that would compile. I moved my custom elements from the root of the project to the src dir and I had to modify the paths to the bower_components and it would basically work.
The most important exception is Redux, the redux-mixin.html can't resolve the PolymerRedux.html dependency (in bower_components/polymer-redux/polymer-redux.html). The polymer-webpack-loader should resolve this, but runtime it logs Uncaught ReferenceError: PolymerRedux is not defined
. E.g. for src/polygram-app.html, the loader seems to import the HTML elements that are used in the template
element, but not the JavaScript variables that are used in the script
element.
The PolymerRedux code is distributed as JavaScript wrapped in a script
tag in mainly one file, so it would be easy to extract it to a JavaScript file. Or even to import polymer-redux/src/index.js
instead of polymer-redux/polymer-redux.html
(although index.js is uncompiled and misses external dependencies that are not installed in bower_components because they are development dependencies of polymer-redux). For now, I just comment out the Redux dependencies.
It is already clear now that the result from Webpack will be one huge bundle.js that inlines all JavaScript and HTML dependencies. This means using the PRPL pattern will not be possible in this workflow, nor will it be possible to have standalone Polymer components and the accompanying Polymer demo pages.
Normally, to migrate a Webpack project from JavaScript to TypeScript, it would be enough to add the ts-loader to the Webpack config and to rename the JavaScript files to TypeScript files.
So I started with changing the extension for the bootstrapping index.js and adding .ts as a resolved extension:
// webpack.config.js
...
entry: path.resolve(__dirname, 'src/index.ts'),
...
resolve: {
extensions: ['.ts', '.js'],
...
And adding this rule:
// webpack.config.js
{
test: /\.ts?$/,
use: [
{ loader: 'ts-loader' }
]
}
And creating a tsconfig.json: javascript { "compilerOptions": { "sourceMap": true, } }
Everything still compiles, but the JavaScript for the Polymer components is embedded in the HTML and therefore ignored by the new rule with the ts-loader.
Adding the ts-loader to the rule for the HTML files breaks compilation:
// webpack.config.js
{
test: /\.html$/,
use: [
{ loader: 'babel-loader' },
{ loader: 'ts-loader' }, // <--
{ loader: 'polymer-webpack-loader' }
]
},
Even without changing any of the code itself, compilation fails with:
ERROR in ./src/polygram-app.html Module build failed: Error: Could not find file: '/home/me/polygram/src/polygram-app.html'.
Well, that just doesn't look healthy. I filed a bug and almost 2 months after my report the maintainers closed the issue commenting that the root cause is with Webpack, so I don't see this will be resolved any time soon.
For now, I will try to work around it by extracting the TypeScript code to a separate file.
After removing the ts-loader
line from the HTML rule in the webpack.config.js I set out to extract the TypeScript to a separate file so it can be compiled with the rule that matches ts files.
Roughly, the main entry point for the Polymer elements polygram-app.html
contains:
// imports
<link rel="import" href="../bower_components/polymer/polymer-element.html">
...
<link rel="import" href="polygram-details.html">
<link rel="import" href="polygram-searchbox.html">
<dom-module id="polygram-app">
<template>
<!-- Style -->
<style include="iron-flex iron-flex-alignment"></style>
<!-- Markup -->
<div class="layout vertical">
...
</div>
...
</template>
<script>
// Script
import format from 'date-fns/format';
class PolygramApp extends Polymer.Element {
static get is() { return 'polygram-app'; }
static get properties() {
return {
today: {
type: String,
value: function() {
return format(new Date(), 'MM/DD/YYYY');
}
}
}
}
}
window.customElements.define(PolygramApp.is, PolygramApp);
</script>
</dom-module>
Since I know the import
statement in the script tag works, I can use this to my advantage. Lets create a companion TypeScript file for polygram-app.html named PolygramApp.ts.
// PolygramApp.ts
import format from 'date-fns/format';
export default class PolygramApp extends Polymer.Element {
static get is() { return 'polygram-app'; }
static get properties() {
return {
today: {
type: String,
value: function() {
return format(new Date(), 'MM/DD/YYYY');
}
}
}
}
}
It would be possible to import PolygramApp.ts with <script src="PolygramApp.ts"></script
, but I like the standard ES6 module structure of PolygramApp.ts without the added responsibility of registering itself to customElements, so I import it like this:
<!-- polygram-app.html -->
...
<dom-module id="polygram-app">
...
<script>
// Script
import PolygramApp from './polygramApp';
window.customElements.define(PolygramApp.is, PolygramApp);
</script>
</dom-module>
The result is a failed compilation with 3 types of errors. Let's deal with them one by one.
The is
and properties
getters require a specifically set target ECMAScript version, the compilation error is: error TS1056: Accessors are only available when targeting ECMAScript 5 and higher
. It surprises me that the default ES target is ES3, but it's not a problem to use ES5 or even ESNext here, because the babel-loader will transpile it back to ES5.
Adding "target": "ESNext"
to compilerOptions in the tsconfig.json fixes this error.
Polymer
can't be found for the extends
. This is the most difficult of these errors to solve, because it is caused by the preferred module architecture of Polymer 2: because HTML imports are used, it is not possible to use import Polymer from '../bower_components/polymer/polymer-element.html'
because this polymer-element does not export Polymer
as an ES6 module. The webpack-polymer-loader can resolve HTML imports, but using import '../bower_components/polymer/polymer-element.html'
results in an error TS2304: Cannot find name 'Polymer'
.
For the moment, I'm just removing the extends Polymer.Element
from PolygramApps.ts and window.customElements.define(PolygramApp.is, PolygramApp);
from polygram-app.html.
To be able to continue resolving the compilation errors, I add a log statement to polygram-app.html:
<!-- polygram-app.html -->
...
<dom-module id="polygram-app">
...
<script>
// Script
import PolygramApp from './polygramApp';
console.log(PolygramApp.properties.today.value());
</script>
</dom-module>
The import of date-fns originally failed in the TypeScript compilation with error TS1192: Module ''date-fns/format'' has no default export.
but at this point that has two different behaviors:
TS2307 Cannot find module date-fns
Uncaught TypeError: format_1.default is not a function(…)
I first thought that this was caused by missing typings for the date-fns library, so I tried npm install @types/date-fns
but this logs that date-fns actually provides typings.
Eventually I was able to fix the Uncaught TypeError by changing the import in PolygramApp.ts from
import format from 'date-fns/format';
to
import { format } from 'date-fns';
And the IDE warning by adding "moduleResolution": "node"
to the compilerOptions in tsconfig.json.
At this point, although nothing is rendered, because of the added log statement the current date is logged to the browser console.
Now the import succeeds and it is clear that the TypeScript compiler correctly processes PolygramApp.ts, it is time to try to fix the import of the Polymer
module in PolygramApp.ts.
A possible workaround will be to not try to import HTML imports in the TypeScript file, but instead to supply those dependencies through the HTML that is importing the TypeScript file. To do this, I change the respective files to:
<!-- polygram-app.html -->
...
<dom-module id="polygram-app">
...
<script>
// Script
import PolygramAppFactory from './PolygramApp';
const PolygramApp = PolygramAppFactory.create(Polymer);
window.customElements.define(PolygramApp.is, PolygramApp);
</script>
</dom-module>
// PolygramApp.ts
import { format } from 'date-fns';
const label: string = 'Current Date: ';
function create(Polymer) {
return class PolygramApp extends Polymer.Element {
static get is() { return 'polygram-app'; }
static get properties() {
return {
today: {
type: String,
value: function() {
return label + format(new Date(), 'YYYY-MM-DD');
}
}
}
}
}
}
export default { create }
Now everything compiles without errors and the custom elements are rendered again!
Note here that I also added a string
type to const label
to see if typings work.
Earlier, Redux was disabled to test Webpack. It was failing with the runtime error Uncaught ReferenceError: PolymerRedux is not defined
To re-enable it, I convert the polymer-redux/polymer-redux.html
from bower_components to a local PolymerRedux.js, by just removing the script
tags.
Because redux-mixin.html, action.html, and reducer.html actually are already JavaScript wrapped in script
tags, I just convert them to TypeScript files, for example:
// ReduxMixin.ts
import {combineReducers, compose, createStore} from 'redux';
const PolymerRedux = require('exports-loader?PolymerRedux!./PolymerRedux');
...
export const ReduxMixin = PolymerRedux(reduxStore);
To use it in PolygramApp.ts, it can now be imported like a normal ES6 module:
// PolygramApp.ts
import { format } from 'date-fns';
const label: string = 'Current Date: ';
import {ReduxMixin, reduxStore} from './ReduxMixin';
function create(Polymer) {
return class PolygramApp extends ReduxMixin(Polymer.Element) {
static get is() { return 'polygram-app'; }
static get properties() {
// Added Redux code here
...
}
ready() {
// Added Redux code here
...
}
}
}
export default { create }
After making similar modifications for polygram-searchbox, the Redux events work again as before introducing TypeScript.
At this point PolymerRedux is loaded from a custom PolymerRedux.js that I made in the previous step by removing the <script>
tags from the file in bower_components. Although this works, it would be better to use the file in bower_components directly because it will be easier to handle updates to this external package.
Currently I import the custom PolymerRedux.js in state/ReduxMixin.ts with:
const PolymerRedux = require('exports-loader?PolymerRedux!./PolymerRedux');
To load the HTML from the bower_components, I expect to have to use the polymer-webpack-loader to extract the JavaScript from the script
tags:
const PolymerRedux = require('exports-loader?PolymerRedux!polymer-webpack-loader!../../bower_components/polymer-redux/dist/polymer-redux.html');
This fails to compile with the message that PolymerRedux is undefined, so I add the debug-loader to investigate what the result of each step looks like:
const PolymerRedux = require('exports-loader?PolymerRedux!polymer-webpack-loader!debug-loader?id=raw!../../bower_components/polymer-redux/dist/polymer-redux.html');
Thanks to debug-loader it is immediately clear that already before going into the polymer-webpack-loader the script
tags have been stripped. Just using require without any loaders turns something likes this <script>foo()</script>
into foo()
and webpack-polymer-loader is not needed in this case. I do think this only works when the file is completely self contained and does not have dependencies with other Polymer HTML files.
This is the final working import:
const PolymerRedux = require('exports-loader?PolymerRedux!../../bower_components/polymer-redux/dist/polymer-redux.html');
Although there is a polymer-linter, it is advised to use Polymer Linter combined with other linters, and an obvious choice is TSLint.
The way that TSLint is configured with Webpack means that it will only lint TypeScript that is not embedded in HTML:
// webpack.config.js
new TSLintPlugin({
files: ['./src/**/*.ts'] // So, this requires none of the TS to be inline in HTML?
})
Before I started with this experiment I thought this might be a problem. But now almost all script has been extracted to separate TypeScript files anyway, so this works quite well.
Of course it is also still possible to run TSLint manually for a file, e.g. ./node_modules/.bin/tslint --config tslint.json polygram-marvel-details.ts
It is still required to run polymer lint
manually. As far as I know there is no integration for Webpack yet.
I want to see if I can use ES decorators, because decorators conceptually fit with the Mixin pattern used in Polymer for e.g. class MyElement extends ReduxMixin(Polymer.Element)
. It would be tidy if we could write this as a decorator, especially if more mixins would need to be combined:
@ReduxMixin
class MyElement extends Polymer.Element
...
As a test, I just add an example decorator to the class in polygram-details.ts:
@readonly
foo() {
// This works, but prepends a polyfill to the output
return 'just testing a decorator';
}
And in the same file, but outside the class, the definition of the decorator:
function readonly(target, key, descriptor) {
descriptor.writable = false;
return descriptor;
}
The compiler fails with: error TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option to remove this warning.
This flag can be added to the compilerOptions section of the tsconfig.json: 'experimentalDecorators': true
. The compilation now succeeds, but prepends a small polyfill for decorator
to the output. Take this into account when using decorator in many files, because it will cause an overhead that might be avoided by using a third party library that is imported globally.
It is one thing to compile Typescript for a Polymer app, but another thing to use TypeScript for reusable Polymer components.
The next step will be to compile the Polymer components in this test project separately. As a result, each converted component should both be loaded into its own demo page and to be composed into a Polymer app.
The demo pages should be accessible by running polymer serve
, conform the normal Polymer workflow.
When just running a Webpack build for the current project with ./node_modules/.bin/webpack --config webpack.config.js
, it will build a dist
dir containing amongst others an index.html and a bundle.js. This is a standalone app, but this would not be a good workflow to distribute a Polymer component because:
dom-module
The polyfills and libraries need to be kept separate, so that they can be loaded once per project instead of once for every component. The bundle.js is already 2.8MB in size (unminified) / 347kB (minified).
Would it be possible to make a Polymer component that uses the <script src="foo.js">
style import and then do a "naive" compilation from foo.ts to foo.js? Let's first make a minimal example where the JavaScript is extracted from an Polymer component:
/polygram-details.html
(this is the original, that the TypeScript+Webpack version in /src/ was based on) and /demo/polygram-details
(already importing /polygram-details)polymer serve
and test the demo page<script>... code ...</script>
by <script src="polygram-details.js"></script>
, extract the JavaScript to polygram-details.js and test the demo page again: this works.Now to TypeScript:
polygram-detail.js
but leave the reference in polygram-details.html
to point to the JavaScript version: <script src="polygram-details.js"></script>
typescript
was already installed as a dependency, so use tsc
: ./node_modules/.bin/tsc polygram-details.ts
. This gives errors, but does generate code. The resulting code does not run.The first errors are:
polygram-details.ts(8,31): error TS2304: Cannot find name 'Polymer'.
polygram-details.ts(33,14): error TS2339: Property '_searchResult' does not exist on type 'PolygramDetails'.
polygram-details.ts(35,18): error TS2339: Property '_searchIAUrl' does not exist on type 'PolygramDetails'.
polygram-details.ts(42,18): error TS2339: Property '_searchResult' does not exist on type 'PolygramDetails'.
Adding declare const Polymer: any;
fixes these 4 errors. It tells TypeScript a global variable Polymer
can be expected.
This leaves the following errors:
polygram-details.ts(9,16): error TS1056: Accessors are only available when targeting ECMAScript 5 and higher.
polygram-details.ts(13,16): error TS1056: Accessors are only available when targeting ECMAScript 5 and higher.
The current compilation seems to ignore the tsconfig.json, because a similar error was solved earlier by adding "target": "ESNext"
in the config. The target can be specified with a flag: ./node_modules/.bin/tsc --target ES6 polygram-details.ts
. This runs without errors and works in the browser!
This much simpler approach without Webpack seems to provide a more realistic workflow. Can we afford to leave Webpack out entirely? Let's reiterate its purpose:
As mentioned before we don't need Babel for transpilation, the TypeScript compiler can be set to ES6 or ES5.
Hot Module Replacement is mainly to ease development, but we can use livereload combined with polyserve instead which would be acceptable for this use case.
We can do without ES6 modules or packaging other resources like images as JavaScript modules, because we already have to deal with Polymer Elements as a component platform. We have to distribute the end result as Polymer Elements to be able to add it to the catalog.
Although Polymer 3 will use ES6 modules, a tool is supposed to become available that can migrate from elements from Polymer 2 to Polymer 3 syntax.
Without Webpack we lose the module polyfill that is injected per file, which potentially saves a significant size overhead, whilst staying closer to the concept of Polymer Element development.
Global JavaScript variables from external modules can be made accessible with the declare
placeholder, and it is still possible to use import
to import from node_modules
. However, when module is set to none
in the tsconfig.json, the variable will just be put onto the "global" scope. This is not the true global scope, because it is still contained within the Polymer element, so the variable will be on the Polymer Element scope, and should not leak to the actual global scope.
Import
should still be used with caution: it will lead to code duplication if 2 Polymer+TypeScript elements import the same dependency. In that case it would be better to import that dependency via HTML import because the Polymer compiler can deduplicate it.
Webpack can also be used to package CSS as modules, but for encapsulating CSS in Polymer the Shadow DOM can be used. This is actually an aspect of web components that is very well executed.
Without Webpack, it is unpractical that for every change to a TypeScript file a manual transformation is needed. Following the example in the previous section, each time polygram-details.ts
changes, ./node_modules/.bin/tsc --target ES6 polygram-details.ts
must be run. Let's try to automate this without using Webpack.
First I make a new tsconfig named tsconfig.inline.json
for this use case:
{
"compilerOptions": {
"sourceMap": true,
"target": "ES6"
},
"include": [
"*.ts"
]
}
To compile run ./node_modules/.bin/tsc -w -p tsconfig.inline.json
. The -w
flag keeps the process running and watches for changes in the included TypeScript files.
An interesting side-effect occurs. Naturally, each TypeScript file is going to need the declare const Polymer: any;
declaration as a workaround for the fact that the Polymer dependency can't be imported (see previous sections). But because we now use -p
, the project flag, the compiler expects all files share global scope. And the second file using declare const Polymer: any;
will get an error: Cannot redeclare block-scoped variable 'Polymer'
. How can we use the project flag, without letting the compiler share the global scope between all TypeScript files?
A workaround would be to create a TypeScript file that just imports/declares all the expected global variables once. This would make the code less transparent at best and it might even create other scoping issues.
As an alternative let's try to run compilation with an isolated scope for each TypeScript file. This issue explains that this would be possible by supplying a tsconfig.json for each scope. That would be doable for a limited set of scopes that is static over time (e.g. a back-end codebase and a front-end codebase in the same project). However, it makes no sense from a maintenance standpoint for the current project as it would need a tsconfig.json for each Polymer element.
To be complete, I did try this out. First, set up a base tsconfig that can be inherited:
// base.json
{
"compilerOptions": {
"sourceMap": true,
"target": "ESNext",
"moduleResolution": "node",
"experimentalDecorators": true
}
}
Now for each Polymer TypeScript file a tsconfig, e.g.:
// polygram-details.tsconfig.json
{
"extends": "./base.json",
"files": [
"./polygram-details.ts"
]
}
It is now possible to compile/watch polygram-details.ts with tsc -w -p polygram-details.tsconfig.json
, but it is still not possible to compile/watch multiple tsconfigs at the same time.
In this case it would be better to forget about the watch flag -w
altogether and just use npm watch
combined with tsc [changedfile]
. You can't use a tsconfig.json combined with an input file path for tsc, so all options must be supplied as flags: tsc --target ES6 --sourceMap [changedFile]
.
I tried combining this compilation one-liner with a watch script, but I could not get this to work with nodemon, npm-watch or watch, so I wrote a small script:
// ts-poly-watch.js, run with: node ts-poly-watch.js
const watch = require('watch');
const path = require('path');
const chalk = require('chalk');
const tsc = require('node-typescript-compiler');
watch.createMonitor(__dirname, { interval: 1 }, function (monitor) {
console.log(chalk.gray.bgGreen.bold('TS-POLY-WATCH started'));
monitor.on('changed', function (f, curr, prev) {
const ext = path.extname(f);
if(ext === '.ts') {
console.log(f + ' changed');
tsc.compile(
{
'target': 'ES6',
'sourceMap': true
},
f
);
}
});
});
Now it is possible to watch each TypeScript file and compile it with its scope isolated from the other TypeScript files.
With ts-poly-watch.js
it looks like we finally have an acceptable working environment. I have extracted the script to its own project typescript-batch-compiler and npm package because there is much room for improvement and it will be easier to use in other projects if it is an npm package.
So are we now done? In fact there is one more thing I want to explore. During the research I ran into twc. This is a compiler for TypeScript Web Components and can be used to compile TypeScript classes to Polymer 2 elements. Although this sounds like it is similar to my typescript-batch-compiler
, here are some preliminary findings:
twc
the entrypoint is a TypeScript file that imports an HTML template. A great advantage is that this is more like Polymer 3 and also similar to other component driven frameworks like React, Vue and Angular. The disadvantage is of course that the style will be foreign to other Polymer developers.With the aforementioned wiki, I take these steps:
twc
in a new subdir of the project appropriately named "twc"// polygram-twc.ts
import { CustomElement } from 'twc/polymer';
import 'bower:polymer/polymer-element.html';
/**
* `online-state`
* Lets you select an online state (online or offline) and reflect the change on a host attribute.
*
* @customElement
* @polymer
* @demo demo/index.html
*/
@CustomElement()
class OnlineState extends Polymer.Element {
prop1: string = "online-state";
template() {
return `
<style>
:host {
display: block;
}
</style>
<h2>Hello [[prop1]]!</h2>
`;
}
}
tsc --init
to create a new tsconfig.json in the twc dir. This turns out to be important. When I re-use my existing tsconfig.json the build fails with Error: Debug Failure.
. This seems to be caused by the line "moduleResolution": "node"
, which is not needed for this compilation.node_modules/twc/types/polymer.decorators.d.ts
to the include section of the tsconfig.json, to resolve certain types.../node_modules/.bin/twc polygram-twc.ts
.I also converted the original polygram-details.html (the one with embedded JavaScript) to this format. See the result in the repo for this experiment. When working on this conversion, some differences with normal web components become apparent:
customElements.define(PolygramDetails.is, PolygramDetails);
import './polygram-ui-details';
is converted to <link rel="import" href="./polygram-ui-details.html">
is
getter, i.e. this: static get is() { return 'polygram-details'; }
is auto generated from the class name.This syntax uses plain ES modules and is therefore also closer to Polymer 3. Still there are some differences. Compare the code for polygram-twc.ts but in Polymer 3 syntax:
// PolymerElement is its own module now, instead of a property of the Polymer namespace. Also, bower is no longer used.
import { Element as PolymerElement } from '@polymer/polymer/polymer-element';
// Aside from inline templates, this syntax can be used too:
//import * as view from './app.template.html';
export class OnlineState extends PolymerElement {
constructor() {
super();
// Property must be defined in the constructor, but this might be a difference with TypeScript and not Polymer 3.
this.prop1 = 'online-state';
}
static get template() {
// I don't know where the <style> element should go.
return `<h2>Hello [[prop1]]!</h2>`;
// Or when using an import:
//return view;
}
}
Although I can't find any sources, I heard that Polymer 3 would supply an auto converter from (normal) Polymer 2 syntax. You could use that converter on the output of twc, so this should not be a reason to avoid twc.
Unit testing and coverage support when using TypeScript has not been mentioned, but I hope it is clear that it is unchanged from a normal Polymer 2 application when using typescript-batch-compiler. You can just use WCT), because all components are compiled to a state that conforms to a non-TypeScript Polymer 2 situation.
For the Webpack approach it would be an improvement to see why polymer-webpack-loader
is not importing Polymer when using import Polymer from '../bower_components/polymer/polymer-element.html'
or import '../bower_components/polymer/polymer-element.html'
.
It could also be an improvement to add the prettier plugin to promote a consistent coding style. This could be added to TSLint via Webpack, but could also be integrated in the typescript-batch-compiler package.