At open-wc, we are big fans of buildless development setups. We have a post or two about it π. We believe that the future is all about coming back to the web platform. That means relying on native browser features in preference to userland or JavaScript solutions or development tools. That's why we have made it our mission to provide you the developer with the tools and techniques to use the platform today, even before legacy browsers are finally dropped.
This approach grants us tremendous advantages in DX, performance, and accessibility, but there are drawbacks. JavaScript, famously, is dynamically typed. Developers who want to enjoy type checking at development time will typically reach for Microsoft's TypeScript, Facebook's Flow, or Google's Clojure compiler. All of these require a build step.
Can we enjoy a safely typed developer experience while "staying true" to the web platform? Let's first dive in and see what Types can give us.
Examples in TypeScript
Let's say we want a function which takes a number or string and returns the square.
// helpers.test.ts
import { square } from '../helpers';
expect(square(2)).to.equal(4);
expect(square('two')).to.equal(4);
Our function's TypeScript implementation might look like this:
// helpers.ts
export function square(number: number) {
return number * number;
}
I know what you're thinking: a string as an argument? While implementing, we discovered that that was a bad idea, too.
Thanks to the type safety of TypeScript, and the mature ecosystem of developer tools surrounding it like IDE support, we can tell before we even run our tests that square('two')
will not work.
If we run the TypeScript compiler tsc
on our files, we'll see the same error:
$ npm i -D typescript
$ npx tsc
helpers.tests.ts:8:19 - error TS2345: Argument of type '"two"' is not assignable to parameter of type 'number'.
8 expect(square('two')).to.equal(4);
~~~~~
Found 1 error.
Type safety helped us catch this error before we pushed it to production. How can we accomplish this kind of type safety without using TypeScript as a build step?
Achieving Type Safety in Vanilla JavaScript
Our first step will be to rename our files from .ts
to .js
. Then we will use browser-friendly import statements in our JavaScript files by using relative urls with .js
file extensions:
// helpers.test.js
import { square } from '../helpers.js';
expect(square(2)).to.equal(4);
expect(square('two')).to.equal(4);
Then, we will refactor our TypeScript function to JavaScript by stripping out the explicit type checks:
// helpers.js
export function square(number) {
return number * number;
}
Now, if we go back to our test file, we no longer see the error at square('two')
, when we pass the wrong type (string) to the function π!
If you're thinking "Oh well, JavaScript is dynamically typed, there's nothing to be done about it", then check this out: we actually can achieve type safety in vanilla JavaScript, using JSDoc comments.
Adding Types to JavaScript Using JSDoc
JSDoc is a long-standing inline documentation format for JavaScript. Typically, you might use it to automatically generate documentation for your server's API or your web component's attributes. Today, we're going to use it to achieve type safety in our editor.
First, add a JSDoc comment to your function. The docblockr plugin for VSCode and atom can help you do this quickly.
/**
* The square of a number
* @param {number} number
* @return {number}
*/
export function square(number) {
return number * number;
}
Next, we'll configure the TypeScript compiler to check JavaScript files as well as TypeScript files, by adding a tsconfig.json
to our project's root directory.
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"lib": ["es2017", "dom"],
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": false,
"noImplicitThis": true,
"alwaysStrict": true,
"types": ["mocha"],
"esModuleInterop": true
},
"include": ["test", "src"]
}
Hey! I thought you said we weren't going to be using TypeScript here?!
You're right, although we will be authoring and publishing browser-standard JavaScript, our editor tools will be using the TypeScript Language Server under the hood to provide us with type-checking.
Doing this allows us to get exactly the same behaviour in VSCode and Atom as with TypeScript.
We even get the same behaviour when running tsc
.
$ npx tsc
test/helpers.tests.js:8:19 - error TS2345: Argument of type '"two"' is not assignable to parameter of type 'number'.
8 expect(square('two')).to.equal(4);
~~~~~
Found 1 error.
Refactoring
Great, we've written our square
feature, including type checks, and pushed it to production. But some time later, the product team came to us saying that an important customer wants to be able to increment the numbers we square for them before we apply the power. This time, the product team already spoke with QA, who worked through the night to provide the following tests for our refactored feature:
expect(square(2, 10)).to.equal(14);
expect(square(2, 'ten')).to.equal(14);
However, it appears that they probably should have spent those hours sleeping, as our original typecasting bug is still there.
How can we deliver this critical (π) feature to our customers quickly while still maintaining type safety?
If we had implemented the feature in TypeScript, you might be surprised to learn that we don't need to add explicit type annotations to the second parameter, since we will supply it with a default value.
export function square(number: number, offset = 0) {
return number * number + offset;
}
The provided default value let's TypeScript statically analyse the code to infer values type.
We can get the same effect using our vanilla-js-and-jsdoc production implementation:
/**
* The square of a number
* @param {number} number
* @return {number}
*/
export function square(number, offset = 0) {
return number * number + offset;
}
In both cases, tsc
will give the error:
test/helpers.tests.js:13:22 - error TS2345: Argument of type '"ten"' is not assignable to parameter of type 'number'.
13 expect(square(2, 'ten')).to.equal(14);
~~~~~
Also in both cases, the only thing we needed to add was offset = 0
as it contains the type information already. If we wanted to add an explicit type definition, we could have added a second @param {number} offset
annotation, but for our purposes, this was unnecessary.
Publishing a Library
If you want people to be able to use your code, you're going to need to publish it at some point. For JavaScript and TypeScript, that typically means npm
.
You will also want to provide your users with the same editor-level type safety that you've been enjoying.
To accomplish that, you can publish Type Declaration files (*.d.ts
)in the root directory of the package you are publishing. TypeScript and the TypeScript Language Sever will respect those declaration files by default whenever they are found in a project's node_modules
folder.
For TypeScript files, this is straightforward, we just add these options to tsconfig.json
...
"noEmit": false,
"declaration": true,
...and TypeScript will generate *.js
and *.d.ts
files for us.
// helpers.d.ts
export declare function square(number: number, offset?: number): number;
// helpers.js
export function square(number, offset = 0) {
return number * number + offset;
}
(Note that the output of the js
file is exactly the same we wrote in our js version.)
Publishing JavaScript Libraries
Sadly, as of now tsc
does not support generating *.d.ts
files from JSDoc annotated files.
We hope it will in the future, and in fact, the original issue for the feature is still active, and it seems to be on board for 3.7
. Don't take our word for it, the Pull Request is in flight.
In fact, this works so well that we are using it in production for open-wc.
!WARNING!
This is an unsupported version => if something does not work no one is going to fix it.
Therefore if your use-case is not supported you will need to wait for the official release of TypeScript to support it.
We took the liberty of publishing a forked version typescript-temporary-fork-for-jsdoc which is just a copy of the above pull request.
Generate TypeScript Definition Files for JSDoc Annotated JavaScript
So now that we have all the information. Let's make it work πͺ!
- Write your code in JS and apply JSDoc where needed
- Use the forked TypeScript
npm i -D typescript-temporary-fork-for-jsdoc
-
Have a
tsconfig.json
with at least the following:
"allowJs": true, "checkJs": true,
Do "type linting" via
tsc
, ideally in apre-commit
hook via husky-
Have
tsconfig.build.json
with at least
"noEmit": false, "declaration": true, "allowJs": true, "checkJs": true, "emitDeclarationOnly": true,
Generate Types via
tsc -p tsconfig.build.types.json
, ideally in CIPublish both your
.js
and.d.ts
files
We have exactly this setup at open-wc and it served us well so far.
Congratulations you now have type safety without a build step π
Feel free to also check out the repository for this post and execute npm run build:types
or npm run lint:types
to see the magic live.
Conclusions
To sum it all up - why are we fans of TypeScript even though it requires a build step?
It comes down to 2 things:
- Typings can be immensely useful (type safety, auto-complete, documentation, etc.) for you and/or your users
- TypeScript is very flexible and supports types for "just" JavaScript as well
Further Resources
If you'd like to know more about using JSDoc for type safety, we recommend the following blog posts:
Acknowledgements
Follow us on Twitter, or follow me on my personal Twitter.
Make sure to check out our other tools and recommendations at open-wc.org.
Thanks to Benny, Lars and Pascal for feedback and helping turn my scribbles to a followable story.
Top comments (8)
TSC seems to be blocking typings creation on vanilla JS now
The error:
My config:
Here's the issue on GH:
github.com/microsoft/TypeScript/is...
yes totally correct - it's written here dev.to/open-wc/generating-typescri...
Basically that means that currently there is an official TypeScript branch that allows it but might still take some time (as far as I can tell 3.7 still looks like the goal - but you know how plans sometimes change π )
So if you want to use it now you will need to either use a forked version or somehow make this branches code available to your project.
Thank you so much. I dug a bit on my own but didn't see any reference about a fix being worked on.
Also, saw your post on Twitter saying this has been officially released now. Awesome!
Hey Thomas, great article. Being able to develop a d.ts file from JavaScript with JSDoc type comments is an interesting feature for TypeScript. I have lots of node modules using JSDoc comments. So far I haven't really had any show stoppers from just leaving them with JSDoc comments and not providing a d.ts file. As they are with checkjs enabled in VSCode, full intellisense kicks in for them just as if they did have d.ts files. The only issue I've run into is that sometimes, where I have a JSDoc defined in one file and I import it into another file for use. This works fine in the source code. But sometimes, when the module is imported into another project, TypeScript fails to properly understand the imported JSDoc type and converts it to "any". That's not a show stopper, but it is annoying. Sometimes this works, which means it's a bug.
I currently have and issue open on Github for this: github.com/microsoft/TypeScript/is.... It's currently flagged as in the backlog. If you like, feel free to drop a line or a thumbs up to signal that more people are interested in seeing this bug resolved. Heck, ask some of you buddies at Open WC to chime in as well :-).
Personally, I'd rather not have to provide a d.ts file when the JavaScript already has all the types defined via JSDoc comments. The d.ts is then overwriting what's already there in the JavaScript. That seems like a waste. I'd rather just see TypeScript fix the import bug and properly understand a module's types from the information provided by JSDoc comments.
hey Robert, thx for the kind words π€
We had the same experience e.g. while working on the open-wc repository everything was super nice (e.g. full type errors and intellisense). However, our users always had problems using those types. We tried various things - e.g. teaching howto to include only our packages into typescript but ignore the rest of node_modules, or setting
maxNodeModuleJsDepth
[1]. You can read this PR to see the progress github.com/open-wc/open-wc/pull/277. It was a valid solution but it did put the burden of making it work on the user.Therefore right now if you distribute a library we recommend to ship definition files. You can now (or soon as it is part of the roadmap) auto-generate those typings in a pre-publish step which means no build step or "disturbance" while developing. Another added bonus is not every typing is possible with JSDoc so if you now need to add some complicated handwritten typings it should be possible.
I think that is the best of both worlds π
If at some point TypeScript will support a performant way of importing typings directly from JSDoc in node_modules then this will probably be easier and good enough for most cases πͺ
[1]: in 2016 it usually worked just fine as
maxNodeModuleJsDepth
had a default of 2 - meaning it analyzed node_modules 2 levels deep - but for imho valid performance reasons the default depth was changed to 0 via github.com/microsoft/TypeScript/pu....Just wondering, if instead of jsDoc, we can use only *.d.ts files for the type-checking purposes same as the jsDoc comment
This will allow my code to be lightly coupled with type-system at the same time, allowing me the benefits of type-safety
To summarize: Idea is to have .ts / .js files without any typescript specific language construct, with only .d.ts files accompanying them
Anyone used it like this or any thoughts on this ?
this is actually typescripts main use case it gets compiled to .js + d.ts files which you then publish.
e.g. even full typescript project have "only" js in their
node_modules
folder.often these .js files are accompanied by d.ts files or there is a dedicated package
@types/packageName
.So in short yes manually writing js and declaration files for it certainly works as well :)
Actually, I cannot somehow force TypeScript to emit types from JSDoc. Have to
declare module 'xxx'
.Yes, I already have this settings.