I’ve been building and maintaining a large vanilla JS project for quite a while now, so naturally over the years I’ve considered whether I should convert it to Typescript. The advantages are obvious - type safety, better type hints and autocomplete, and more bugs found at dev time instead of runtime. The drawbacks are: having to do a lot of manual typing (in both senses) for the conversion, and needing an additional build/compilation step.
But over the past 4-5 years, gradually and accidentally, I blundered into a model where I get most of the advantages of Typescript with none of the drawbacks. It boils down to:
- Use VSCode or a similarly clever IDE
- Install
tsc
and tell the IDE about it- Write clear vanilla JS such that most types can be inferred
- Annote type information with JSDoc comments
Added bonus: Export type declarations and API docs
That’s the tl;dr. Below are the gory details.
Step 1: Use a good modern IDE
Personally I use VSCode, but everything in this article should be achievable in other modern editors.
Step 2: Add Typescript and a jsconfig.json
file
VSCode can do a certain amount of type inference and hinting out of the box, with no additional setup. But for some checks it needs to have tsc
(the Typescript compiler) available, so install it from npm
.
npm install -g typescript # installs globally |
Most Typescript tutorials will then then tell you how to make tsc
compile your project into JS, but we won’t need any of that - as long as it’s available, VSCode should find it.
The next step is to add a jsconfig.json
file to your project. It can also be called tsconfig.json
, but since we’re using vanilla JS let’s stay true to the name. The details of jsconfig are here, but if you only care about type hints you can start with something minimal like:
// jsconfig.json |
Step 3: Write clear, type-inferrable JS
If you haven’t used vanilla JS recently, you might be surprised at how clever modern IDEs are. Given code like this:
export class Foo { |
Even when importing that class to a different file, VSCode will give correct type hints out of the box with no setup!
Note that it even knows that foo.getData().name
is a string
property - it infers this from the default parameter in the class constructor.
However, out of the box the editor only really gives hints - it won’t generate warnings for type-related errors. But once you have tsc
installed, you’ll additionally get linter-style warnings like below. (Of course you should also use a regular linter - personally I like eslint.)
Step 4: Add more type info with JSDoc comments
You can give the IDE more specific type information with JSDoc-style comments beginning with /**
. For example:
/** |
Annotated that way, the IDE can now infer all the types you inform it about - so when you pass a callback to the above function, you’ll get type hints and type warnings about the callback’s parameter types, return type, and so on.
You can also use import
syntax within comments to invoke types from other files, or the @typdef
tag to manually specify types that aren’t declared anywhere locally:
/** @type {import('./foo').Foo} */ |
In examples like the above, the JSDoc comments allow the editor to infer types for any reference, regardless of how it’s initialized. And note that all of these features work for object and class types, as well as primitives.
End result: you get type hints on all your important code, warnings when you misuse a type - largely the same as you would with Typescript, but without needing a compilation step for your build. And all for the low, low price of writing a few type hints and default argument values!
What a great deal!
…BUT WAIT THERE’S MORE!
Bonus step: Export type declarations and API docs!
There are two other indirect benefits to using Typescript: exporting .d.ts
type declaration files (which provide type hints to others using your project as a dependency), and exporting API documentation in formats like HTML or PDF. And vanilla JS with inferrable types can get both of these benefits, for relatively little pain.
But putting these into practice is a bit esoteric, so rather than a step-by-step guide I invite you to check out how I have it working in my project:
- This part my tsconfig file is mostly for exporting type declarations - note the
declaration
andemitDeclarationOnly
properties - This part of the tsconfig is for customizing the typedoc library, which builds API docs.
Using that setup, I can then run tsc
to export my engine’s type declarations and typedoc
to export the engine API reference.