The Nuances of JavaScript Typing using JSDoc
As someone who has worked extensively on codebases using TypeScript, as well as codebases using JavaScript, I am here to inform you: I greatly prefer JavaScript.
It’s not that I don’t like specifying types for my variables and function signatures. I do! In fact, I like it so much I even do it in Ruby. 😲
But you see, what I don’t like is my types being part of “the code”. I believe code should be strictly about the behavior. What it’s called. What it does. The “metadata” surrounding the code—this is a string, that is an integer—makes perfect sense as part of documentation in the form of code comments. Because guess what? You should be documenting your code anyway. Whoever said don’t write lots of comments in code is grossly mistaken.
Yes indeed, it is my sincere belief that you should be documenting your functions, and your value objects, and your classes, and all sorts of other things as much as possible. (Without going overboard…usually a sentence or two is quite sufficient.) Which brings us to…
When it comes to documenting JavaScript, JSDoc is the way to go. Even if you don’t expressly use the tool to generate an API site (I actually have never done that personally!), your JSDoc comments are interpreted by a wide variety of tools and editors. Which brings us to…
Wait, if you prefer JavaScript, why are you talking about TypeScript??
Because TypeScript is how you type check JavaScript even if you’re writing JavaScript with JSDoc and not TypeScript. (Confused yet? 🥴)
Here’s an example. To declare a new string variable in TypeScript proper, you might write something like this:
let str: string
str = "Hello world"
str = 123 // this will cause a type error!
However, you can also get the exact same benefits in pure JavaScript by adding // @ts-check as the first line of a .js file and then specifying types via JSDoc:
// @ts-check
/** @type {string} */
let str
str = "Hello world"
str = 123 // this will cause a type error!
If you’re using an IDE like VSCode or Zed, you’ll likely see the type hints and errors show up automatically, but it also might be a good idea to npm install typescript -D because you may want to run tsc separately to generate TypeScript declaration files or to type check your files in a CI process. (In my package.json I have a script which looks like this: "build:types": "npx tsc".)
You can configure how the type checking works by adding a jsconfig.json file to your project’s root folder. Here’s one I use:
{
"compilerOptions": {
"strictNullChecks": false,
"target": "es2022"
}
}
At the same time, you’ll also probably need to add a tsconfig.json file at some point, like so:
{
// Change this to match your project
"include": ["src/**/*"],
"compilerOptions": {
// Tells TypeScript to read JS files, as
// normally they are ignored as source files
"allowJs": true,
// Generate d.ts files
"declaration": true,
// This compiler run should
// only output d.ts files
"emitDeclarationOnly": true,
// Types should go into this directory.
// Removing this would place the .d.ts files
// next to the .js files
"outDir": "types",
// go to js file when using IDE functions like
// "Go to Definition" in VSCode
"declarationMap": true
}
}
I know that might all sound like a lot, but I promise you once you get a project going with your editor and your CLI tooling, you can replicate that setup across countless more projects and it becomes second nature.
Rather than continue to just talk about using JSDoc for typing, let’s dive into some examples!
JSDoc in the wild
Here’s a class constructor which accepts a number of arguments.
class ReciprocalProperty {
/**
*
* @param {HTMLElement} element - element to connect
* @param {string} name - property name
* @param {(value: any) => any} signalFunction - function to call to create a signal
* @param {() => any} effectFunction - function to call to establish an effect
*/
constructor(element, name, signalFunction, effectFunction) {
this.element = element
this.name = name
this.type = this.determineType()
// etc.
}
}
As you can see, it’s fine to keep using any at times when you really don’t “care” about the exact nature of the variable in question. And again, the nice thing about putting your type information in as part of the code documentation is…now you have documentation! 🙌
Here’s an example of specifying a variable type that is a JavaScript object (a “record” in TypeScript parlance) with typing for the key/value pairs:
/** @type {Record<string, ReciprocalProperty>} */
const attrProps = this.element["_reciprocalProperties"]
You can also see here we’re calling this.element["_reciprocalProperties"] instead of this.element._reciprocalProperties because TypeScript gets fussy about accessing properties it doesn’t know about. Using [] syntax bypasses those complaints (just make sure you know what you’re doing!).
Here’s an example of specifying a type inline as part of a for…of loop:
for (const stop of /** @type {StreetcarStatementElement[]} */ ([...this.children])) {
stop.operate()
}
That particular syntax took me a while to figure out! 😅 When in doubt, put your inline /** @type */ declarations in front of a piece of code wrapped in parentheses. That usually does the trick.
Here’s an example of specifying a type inline within a method signature:
htmx.defineExtension("streetcar", {
handleSwap: (swapStyle, _target, /** @type {Element} */ fragment) => {
// etc.
},
})
And here’s an example of importing just a type (aka not a standard JavaScript import) using @typedef syntax:
/**
* @typedef { import("./HostEffects.js").default } HostEffects
*/
// later on…
/**
* @param {HostEffects}
*/
You can also use @typedef to define the equivalent of TypeScript’s interface which is documented here.
And finally: every once in a while, you may just need to do something funky that TypeScript simply isn’t going to like, and that’s OK! As the documentation states:
TypeScript may offer you errors which you disagree with, in those cases you can ignore errors on specific lines by adding
// @ts-ignoreor// @ts-expect-erroron the preceding line.
JavaScript + JSDoc + tsc should be the industry default
I have stated on multiple occasions that I believe it has been a huge mistake for “TypeScript” to become a sort of industry default. I firmly encourage everyone I talk to to start writing real web open standard .js files which don’t require any build steps or tooling to execute properly, all while utilizing the power combo of JSDoc + tsc to gain all of the benefits of type hints in IDEs and type checking in CI. It really is the best of both worlds, and the number of cases when you must give in and start authoring .ts files proper is vanishingly small.
It’s possible there are certain frameworks you may need to use which essentially “require” you to author your code using TypeScript. In those instances, so be it. But if you have any control over the shape of a project, I hope you’ll consider using good ol’ fashioned JavaScript. After all, it’s ECMAScript—not TypeScript—that is the lingua franca of the web.