"We will soon migrate to TypeScript, and then..." – how often do you hear this phrase? Perhaps, if you mainly work within a single project or mostly just start new projects from scratch, this is a relatively rare expression for you to hear. For me, as someone working in an outsourcing company, who, in addition to my main project, sees dozens of various other projects every month, it is a quite common phrase from the development team or a client who would like to upgrade their project stack for easier team collaboration. Spoiler alert: it is probably not going to be as soon of a transition as you think (most likely, never).
While it may sound drastic, in most cases, this will indeed be the case. Most people who have not undergone such a transition may not be aware of the dozens of nuances that can arise during a project migration to TypeScript. For instance, are you prepared for the possibility that your project build, which took tens of seconds in pure JavaScript, might suddenly start taking tens of minutes when using TypeScript? Of course, it depends on your project's size, your pipeline configuration, etc., but these scenarios are not fabricated. You, as a developer, might be prepared for this inevitability, but what will your client think when you tell them that the budget for the server instance needs to be increased because the project build is now failing due to a heap out-of-memory error after adding TypeScript to the project? Yes, TypeScript, like any other tool, is not free.
On the Internet, you can find a large number of articles about how leading companies successfully migrated their projects from pure JavaScript to TypeScript. While they usually describe a lot of the issues they had during the transition and how they overcame them, there are still many unspoken obstacles that people can encounter which can become critical to your migration.
Despite the awareness among most teams that adding typing to their projects through migration to TypeScript might not proceed as smoothly as depicted in various articles, they still consider TypeScript as the exclusive and definitive solution to address typing issues in their projects. This mindset can result in projects remaining in pure JavaScript for extended periods, and the eagerly anticipated typing remains confined to the realm of dreams. While alternative tools for introducing typing to JavaScript code do exist, TypeScript's overwhelming popularity often casts them into the shadows. This widespread acclaim, justified by the TypeScript team's active development, may, however, prove disadvantageous to developers. Developers tend to lean towards the perception that TypeScript is the only solution to typing challenges in a project, neglecting other options.
Next, we will consider JSDoc as a tool that, when used correctly and understood in conjunction with other tools (like TypeScript), can help address the typing issue in a project virtually for free. Many might think that the functionality of JSDoc pales in comparison to TypeScript, and comparing them is unfair. To some extent, that is true, but on the other hand, it depends on the perspective. Each technology has its pros and cons, counterbalancing the other.
Code examples will illustrate a kind of graceful degradation from TypeScript to JavaScript while maintaining typing functionality. While for some, this might appear as a form of progressive enhancement. Again, it all depends on how you look at it…
JSDoc and Its Extensions
JSDoc is a specification for the comment format in JavaScript. This specification allows developers to describe the structure of their code, data types, function parameters, and much more using special comments. These comments can then be transformed into documentation using appropriate tools.
/**
* Adds two numbers.
* @param {number} a - The first number.
* @param {number} b - The second number.
* @returns {number} The sum of the two numbers.
*/
const getSum = (a, b) => {
return a + b
}
Initially, JSDoc was created with the goal of generating documentation based on comments, and this functionality remains a significant part of the tool. However, it is not the only aspect. The second substantial aspect of the tool is the description of various types within the program: variable types, object types, function parameters, and many other structures. Since the fate of ECMAScript 4 was uncertain, and many developers lacked (and still lack to this day) proper typing, JSDoc started adding this much-needed typing to JavaScript. This contributed to its popularity, and as a result, many other tools began to rely on the JSDoc syntax.
An interesting fact is that while the JSDoc documentation provides a list of basic tags, the specification itself allows developers to expand the list based on their needs. Tools built on top of JSDoc leverage this flexibility to the maximum by adding their own custom tags. Therefore, encountering a pure JSDoc setup is a relatively rare occurrence.
The most well-known tools that rely on JSDoc are Closure Compiler (not to be confused with the Closure programming language) and TypeScript. Both of these tools can help make your JavaScript typed, but they approach it differently. Closure Compiler primarily focuses on enhancing your .js
files by adding typing through JSDoc annotations (after all, they are just comments), while TypeScript is designed for .ts
files, introducing its own well-known TypeScript constructs such as type
, interface
, enum
, namespace
, and so on.
Not from its inception, but starting from version 2.3, TypeScript began allowing something similar to Closure Compiler – checking type annotations in .js
files based on the use of JSDoc syntax. With this version, and with each subsequent version, TypeScript not only added support for JSDoc but also incorporated many of the core tags and constructs present in Closure Compiler. This made migration to TypeScript more straightforward. While Closure Compiler is still being updated, used by some teams, and remains the most effective tool for code compression in JavaScript (if its rules are followed), due to support for checking .js
files and various other updates brought by the TypeScript team, Closure Compiler eventually lost to TypeScript.
From the implementation perspective, incorporating an understanding of JSDoc notation into TypeScript is not a fundamental change. Whether it is TypeScript types or JSDoc types, ultimately, they both become part of the AST (Abstract Syntax Tree) of the executed program. This is convenient for us as developers because all our everyday tools, such as ESLint (including all its plugins), Prettier, and others, primarily rely on the AST. Therefore, regardless of the file extensions we use, our favorite plugins can continue to work in both .js
and .ts
files (with some exceptions, of course).
Developer Experience
When adding typing to JavaScript code using JSDoc, it is advisable to use additional tools that enhance the development experience.
eslint-plugin-jsdoc is a JSDoc plugin for ESLint. This plugin reports errors in case of invalid JSDoc syntax usage and helps standardize the written JSDoc. An important setting for this plugin is the mode option, which offers one of the following values: typescript
, closure
(referring to Closure Compiler), or jsdoc
. As mentioned earlier, JSDoc can vary, and this option allows you to specify which JSDoc tags and syntax to use. The default value is typescript
(though this has not always been the case), which, given TypeScript's dominance over other tools that work with JSDoc, seems like a sensible choice.
It is also important to choose a tool for analyzing the type annotations written in JSDoc, and in this case, it will be TypeScript. This might sound strange because, in this article, it seems like we are discussing its replacement. However, we are not using TypeScript for its primary purpose – our files still have the .js
extension. We will only use TypeScript as a type checking linter. In most projects where TypeScript is used fully, there is typically a build
script responsible for compiling .ts
files into .js
. In the case of using TypeScript as a linting tool, instead of a build
command handling compilation, you will have a command for linting your types.
// package.json
{
"scripts": {
"lint:type": "tsc --noEmit"
}
}
If, in the future, a tool emerges that surpasses TypeScript as a linting tool for project typing, we can always replace it in this script.
To make this script work correctly, you need to create a tsconfig.json file in your project or add additional parameters to this script. These parameters include allowJs and checkJs, which allow TypeScript to check code written in .js
files. In addition to these parameters, you can enable many others. For example, to make type checking stricter, you can use strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noPropertyAccessFromIndexSignature, and more. TypeScript will rigorously check your code even if you are using .js
files.
The TypeScript team consistently enhances the functionality of TypeScript when working with JSDoc. With almost every release, they introduce both fixes and new features. The same applies to code editors. Syntax highlighting and other DX features provided by TypeScript when working with .ts
files also work when dealing with .js
files using JSDoc. Although there are occasional instances where support for certain JSDoc features may come later, many GitHub issues labeled with JSDoc in the TypeScript backlog indicate that the TypeScript team continues to work on improving JSDoc support.
Many might mention the nuance that when using TypeScript solely for .js
files, you are deprived of the ability to use additional constructs provided by TypeScript. For example: Enums, Namespaces, Class Parameter Properties, Abstract Classes and Members, Experimental (!) Decorators, and others, as their syntax is only available in files with the .ts
extension.
Again, for some, this may seem like a drawback, but for others, it could be considered a benefit, as most of these constructs have their drawbacks. Primarily, during TypeScript compilation to JavaScript, anything related to types simply disappears. In the case of using the aforementioned constructs, all of them are translated into less-than-optimal JavaScript code. If this does not sound compelling enough for you to refrain from using them, you can explore the downsides of each of these constructs on your own, as there are plenty of articles on the Internet discussing these issues.
Overall, the use of these constructs is generally considered an anti-pattern. On most of my projects where I use TypeScript to its full extent (with all my code residing in .ts
files), I always use a custom ESLint rule:
// eslint.config.js
/** @type {import('eslint').Linter.FlatConfig} */
const config = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'TSEnumDeclaration,TSModuleDeclaration,TSParameterProperty,ClassDeclaration[abstract=true],Decorator',
message: 'TypeScript shit is forbidden.',
},
],
},
}
This rule prohibits the use of TypeScript constructs that raise concerns.
When considering what remains of TypeScript when applying this ESLint rule, essentially, only the typing aspect remains. In this context, when using this rule, leveraging JSDoc tags and syntax provided by TypeScript for adding typing to .js
files is almost indistinguishable from using TypeScript with .ts
files.
As mentioned earlier, most tools rely on AST for their operations, including TypeScript. TypeScript does not care whether you define types using TypeScript's keywords and syntax or JSDoc tags supported by TypeScript. This principle also applies to ESLint and its plugins, including the typescript-eslint plugin. This means that we can use this plugin and its powerful rules to check typing even if the entire code is written in .js
files (provided you enabled the appropriate parser).
Unfortunately, a significant drawback when using these tools with .js
files is that some parts of these tools, such as specific rules in typescript-eslint, rely on the use of specific TypeScript keywords. Examples of such rules include explicit-function-return-type, explicit-member-accessibility, no-unsafe-return, and others that are tied explicitly to TypeScript keywords. Fortunately, there are not many such rules. Despite the fact that these rules could be rewritten to use AST, the development teams behind these rules are currently reluctant to do so due to the increased complexity of support when transitioning from using keywords to AST.
Conclusion
JSDoc, when used alongside TypeScript as a linting tool, provides developers with a powerful means of typing .js
files. Its functionality does not lag significantly behind TypeScript when used to its full potential, keeping all the code in .ts
files. Utilizing JSDoc allows developers to introduce typing into a pure JavaScript project right now, without delaying it as a distant dream of a full migration to TypeScript (which most likely will never happen).
Many mistakenly spend too much time critiquing the JSDoc syntax, deeming it ugly, especially when compared to TypeScript. It is hard to argue otherwise, TypeScript's syntax does indeed look much more concise. However, what is truly a mistake is engaging in empty discussions about syntax instead of taking any action. In the end, you will probably want to achieve a similar result, as shown in the screenshot below. Performing such a migration is significantly easier and more feasible when transitioning from code that already has typing written in JSDoc.
By the way, many who label the JSDoc syntax as ugly, while using TypeScript as their sole primary tool, after such remarks, nonchalantly return to their .ts
files, fully embracing TS Enums, TS Parameter Properties, TS Experimental (!) Decorators, and other TS constructs that might raise questions. Do they truly believe they are on the right side?
Most of the screenshots were taken from the migration of .ts
files to .js
while preserving type functionality in my library form-payload (here is the PR). Why did I decide to make this migration? Because I wanted to. Although this is far from my only experience with such migrations. Interestingly, the sides of migrations often change (migrations from .js
to .ts
undoubtedly occur more frequently). Despite my affection for TypeScript and its concise syntax, after several dozen files written/rewritten using JSDoc, I stopped feeling any particular aversion to the JSDoc syntax, as it is just syntax.
Summing up: JSDoc provides developers with real opportunities for gradually improving the codebase without requiring a complete transition to TypeScript from the start of migration. It is essential to remember that the key is not to pray to the TypeScript-God but to start taking action. The ultimate transition to using TypeScript fully is possible, but you might also realize that JSDoc is more than sufficient for successful development, as it has its advantages. For example, here is what a "JSDoc-compiler" might look like:
// bundler.js
await esbuild.build({
entryPoints: [jsMainEntryPoint],
minify: true, // 👌
})
Give it a try! Do not stand still, continually develop your project, and I am sure you will find many other benefits!
Top comments (12)
Thx for that image which was clerify to me the jsDoc unite type declaration format which help me to write this one a much more easy format:
Currently I was working on a jsdoc react state handling npm module:
jsdoc-duck
-- convert to ->
You can export a type without making a variable:
This automatically exports the type.
With TS pre 5.5, you can import it like this in another file:
With TS 5.5 and above, you can import with JSDoc
@import
syntax:Basically just wrap an import statement with
/**@
and*/
to convert it to type-only import comment.If you want to avoid
@private
, you can use official JS private syntax:Also check out some proposals for simplified and more concise type comment syntax here:
github.com/microsoft/TypeScript/is...
For example see how concise this is compared to JSDoc:
Hey @trusktr ! Thank you for your answer and advice!
I knew about auto-import when the type is declared via JSDoc comments and also about the new
@import
feature (I followed and contributed to the discussion on this feature here). However, I prefer the type declaration usinglet
+ JSDoc comments, as shown in the screenshots above. The main issue with using comments is that there are no ESLint rules to lint them. It's easy to forget to delete something because rules like "no-unused-vars" or "sorted-imports" don't work for type declarations and imports in JSDoc comments.The issue about annotations as comments is really good! I wasn't aware of it. I've subscribed to it and will follow all the discussions. Thank you!
I've never been a fan of fully typed versions of untyped languages, specially of the interpreted variety (as this effectively makes them compiled languages, without really giving them any of the associated performance benefits).
First for Lua, then recently for JavaScript I've also landed on type annotations in comment form that a language server can use to check my code as I am writing it, but that doesn't need any additional build steps.
I've now gotten so used to this way of structuring my code that I am starting to miss it when writing Ruby code, but alas, I haven't yet found a LS that will do this sort of "optional" type checking properly (solargraph tries to but is kind of weird about it).
Hey @darkwiiplayer !
I understand and support your points. I would like to see more JavaScript tools that can check code for type safety. This could create stronger competition, thereby pushing the development of tools faster. But for now, we have to live with what we have 🥲
I hope that the ES types proposal, if it will be accepted, could enable the creation of more tools that will help with this
The issue with JSDoc is that you end up writing TypeScript in your docblocks anyway. Your code examples are full of TypeScript annotations, so it's not that you're moving away from TypeScript, it's just that you're putting it somewhere else.
In your example screenshot of commit changes, the fundamental difference between the new version and the old version is that you've made a single use type declaration into a reusable one. The aesthetics don't matter, it's the fact that you've made a reusable type declaration that's important.
Putting all your types in docblocks leads you to a situation like this:
It's fine if
obj
is of a type that's only used in this one place. But in reality it the type ofobj
will be used multiple times, so it's simpler to declare the type once:Even if your codebase is JS using docblocks, this is a much neater solution:
Even better if you move your types into the function declaration, then you can use the docblocks for the actual documentation:
This was a simple example... If you have functions with multiple shapes, union types, optional arguments, default values, or destructured arguments, then the docblock version becomes even more unwieldy.
I'm not so sure if this is accurate.
In my experience, the difference in build time between the two is negligible. The additional time you refer to is the time it takes to perform type checking. If you don't check types when you build, then a project that uses Vite / Webpack / Babel etc will take roughly the same time to build.
If TypeScript build times were really much larger as you say, we wouldn't see people using TypeScript in large projects. But we do, because when you save a TS / JS file and HMR kicks in, the build time is negligible in both cases.
Again, it's not the build it's the type checking that takes time, and you should consider type check as a separate step to build.
See
ts-loader
documentation here for example:If you follow this advice and disable type checking at the build stage, regardless of your tooling, you'll get faster builds. Personally, I only do type checking once I am ready to integrate my changes into an upstream branch.
This just means that you're using TypeScript. :)
Hey @teamradhq !
Thank you for your comment, and especially for your code snippets! Let's go step by step 🙂
I completely agree that it's better to extract such types into separate files. This applies to both JSDoc and TypeScript, as well as the programming language in general. Making the code more readable and decomposing it is independent of any particular language, and, of course, it's always better to do so whenever possible (though unfortunately not always feasible).
Also, I would like to add that I can declare these types using JSDoc syntax in
.js
files without the need for.ts
files. Here's an example of how it can look:I use TypeScript syntax in JSDoc simply because I chose it. However, it doesn't prevent me from using, for example, the Closure Compiler syntax as well.
Trust me 🙂
Here are two screenshots from one of my presentations where the build took a whopping 17 minutes. It's quite an old presentation that uses create-react-app (yes, with various ENV options like
CI=false
and others). Without.ts
files, the build took around 2 minutes.Btw, the presentation itself is about migrating to Vite. Eventually, after all the optimizations, the build started taking around 1 minute (even with
.ts
files). But that's because I have experience and know what to do. For most people, doing such optimizations for the first time will be a significant journey.This means that I am currently using TypeScript now, and I will gladly replace it if I can in the future. I hope tools like quick-lint-js.com/ will continue to evolve in this direction.
Thank you for your well thought out and informative reply. I appreciate you taking the time :)
I'm a big fan of docblocks, which is why I enjoyed your article (and brilliant reply) to my comment so much. If you look at my work, you'll find plenty of docblocks. I practise document driven development (DDD) so it's a no-brainer. The only way you and I differ here is that I use a type system to document my types and docblocks to document my implementation.
I trust that you know what you know. Please trust me in return when I say that you're making my point for me here:
Out of the box, CRA doesn't skip type checking. I'm also pretty sure that it uses
tsc
to transpile TS > JS. TypeScript isn't slower than JavaScript when you built with CRA because it takes longer to transpile. It's slower becausetranspileOnly
is set tofalse
, so types are being checked.With that in mind, your slide screenshot isn't making a fair comparison because it's comparing apples to apples and oranges (where oranges = static type checking). I guarantee you that if you configured CRA to skip type checking, there wouldn't be such a large discrepancy between the two.
I say this as seasoned Webpack user (CRA without the abstraction) who's both transitioned projects from JavaScript to TypeScript and from Webpack to Vite. My initial experience with TypeScript and Webpack aligns with your experience with CRA. Build times were off the hook. That's when I learned that type checking is a laborious process and all that was needed to speed up the build was to skip this step.
Even without your and my extensive knowledge of the dark arts (that's what how I refer to build process configuration) everybody will see drastic results switching from CRA/Webpack to Vite. It's much faster out of the box because it transpiles with
esbuild
instead oftsc
, and Vite skips type checking by default (emphasis mine):This is what I mean when I say it's comparing apples to apples and oranges yeah? This documentation is spot on to say that we should consider type checking as a separate step from build, because they are totally different things.
When a file is transpiled, it's only concerned with itself and its direct dependencies. When a file is type checked, it's concerned with every dependency in the graph. It's the difference between touching one file and
n
files which is why it's slower.From the same docs page:
If you look at Vite starter templates, their
package.json
provides this script for builds:Now a
production
build takes many times longer to complete due to the type checking stage, but it also won't build if there are type errors. This means you can reduce the likelihood of misused types inproduction
, which (as far as I know) you can't achieve without static type analysis.My point is that you want your
production
build to take as long as possible, performing any and all checks possible to minimise the risk of catastrophic failure. Going back to your screenshot, if deploying toproduction
results in downtime, then instead of avoiding long running tasks, switch to a different deployment method like blue green deployments, or containerisation or similar. That is, build your system outside of your operating environment and only deploy the built files upon success.I'm curious to know what the equivalent of overloading a function would be in docblock:
That is to say,
a
is always a number,b
is a string unlessc
is provided, in which case it's a number.So calling
someFunction(1, 'string', {})
should show an error:Similarly, within the function body itself I would see errors:
b
is undefined it would give me an error if I try to usec
as a recordc
is defined it would give me error if I try to useb
as a stringObviously, this is a contrived example, but it's not an uncommon pattern to encounter in a less abstract way in the wild. I don't think this is possible to achieve with JSDoc alone:
Everything that's returned from this function has the type
number | string | Record<string, string>
which makes the annotation kind of pointless. This is what the types should be:If there's a way to achieve this with JSDoc, I'd love to know more about it. Perhaps this can be achieved with Closure syntax, which I've never used.
I'd never tell someone what tools they should or shouldn't use, and I really hope that this discussion is coming across as informative and not confrontational.
I'm not trying to convince you to use or not use TypeScript or suggest that you're entirely wrong. I just want you to know that transpiling TypeScript is not slower than JavaScript. Type checking is the slow part, and it's really only necessary when integrating your change upstream :)
And again, thanks for your great reply :)
Hello @teamradhq !
Cool that you also like JSDoc!
Thanks for providing such a detailed guide on how type checking works during the build! I hope everyone reading this article will also go through the comments since there is a lot of useful information here.
Regarding react-create-app, even if it's deprecated and everyone prefers Vite now, it is still used in many old projects. Actually, I've been a react-create-app hater since its creation, always preferring to configure Webpack myself, or at least eject the react-create-app to have control over my app's build. Disabling type checking during build is usually the first thing I did. But the problem is not everyone is as curious as we are. Unfortunately, many still use the "standard" react-create-app, where type checking happens before the build. Although this has become less common lately because Vite is doing its job 👍
To be honest, I try not to use overloads in production applications. In applications, I find it better to use multiple functions. Overloads, in my opinion, are more suitable for libraries.
If anything, I'm not making excuses 😄 You can create overloads with JSDoc just as conveniently as in TypeScript using the
@overload
tag. You can learn more about it here - devblogs.microsoft.com/typescript/...Example:
I completely understand and agree with you! However, not everyone grasps this concept, and that's the gist of my article. Everything described in the first paragraph. I have to work with many teams, and each has its reasons for adopting or not adopting TypeScript. I'm just highlighting that going all-in with TypeScript (using .ts files) is not the sole solution to type checking in a project. Action is needed, not just praying to the TypeScript-God!
Thank you so much for your comments! I'm sure they added value to the article!
Thank you for the article! Interesting insight
You don't need a complete transition to TS from JS. Every JS code is valid TS code. So you can migrate your code partially if you want to.
Hey @disane !
Yes, you’re right! That's also one option. However, now you need to add a build step to your delivery-process. Additionally, depending on your development setup, you might encounter issues importing TS files into JS files. With the use of JSDoc, you don't need any of this since it's just comments.