Generating TypeScript Definition Files from JavaScript
Thomas Allmer
Posted on September 4, 2019
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.
Posted on September 4, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.