DEV Community

TypeScript Might Not Be Your God: Case Study of Migration from TS to JSDoc

Vladyslav Zubko on January 16, 2024

"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 mostl...
Collapse
 
pengeszikra profile image
Peter Vivo

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

/**
 * @typedef {{ type: "SUMMON", payload: Player[] }} SUMMON
 * @typedef {{ type: "DECK", payload: [string, string][] }} DECK
 * @typedef {{ type: "NEW_GAME", payload: Player }} NEW_GAME - dealer
 * @typedef {{ type: "BLINDS", payload: number | string }} BLINDS - blinds name or index
 * @typedef {{ type: "DEALING", payload:  null }} DEALING
 * @typedef {{ type: "PRE_FLOP", payload: string }} PRE_FLOP
 * @typedef {{ type: "FLOP", payload: string }} FLOP
 * @typedef {{ type: "TURN", payload: number }} TURN
 * @typedef {{ type: "RIVER", payload: string }} RIVER
 * @typedef {{ type: "SHOWDOWN", payload: string }} SHOWDOWN
 * @typedef {{ type: "CALL", payload: string }} CALL
 * @typedef {{ type: "RAISE", payload: string }} RAISE
 * @typedef {{ type: "FOLD", payload: string }} FOLD
 * @typedef {{ type: "CHECK", payload: string }} CHECK
 * @typedef {{ type: "NEXT_HAND", payload: string }} NEXT_HAND
 * @typedef {{ type: "ESCAPE", payload: string }} ESCAPE
 * @typedef {{ type: "CHAMPION_ARE", payload: string }} CHAMPION_ARE
 * @typedef {{ type: "CALC_RANK", payload: null }} CALC_RANK
 *
 * @typedef { DECK | SUMMON | NEW_GAME | BLINDS | DEALING | PRE_FLOP | FLOP | TURN | RIVER | SHOWDOWN | CALL | RAISE | FOLD | CHECK | NEXT_HAND | ESCAPE | CHAMPION_ARE | CALC_RANK } ActionsMap
 */
Enter fullscreen mode Exit fullscreen mode

-- convert to ->

/**
 * @typedef {{ type: "SUMMON", payload: Player[] }
 * | { type: "DECK", payload: [string, string][] }
 * | { type: "NEW_GAME", payload: Player }
 * | { type: "BLINDS", payload: number | string }
 * | { type: "DEALING", payload:  null }
 * | { type: "PRE_FLOP", payload: string }
 * | { type: "FLOP", payload: string }
 * | { type: "TURN", payload: number }
 * | { type: "RIVER", payload: string }
 * | { type: "SHOWDOWN", payload: string }
 * | { type: "CALL", payload: string }
 * | { type: "RAISE", payload: string }
 * | { type: "FOLD", payload: string }
 * | { type: "CHECK", payload: string }
 * | { type: "NEXT_HAND", payload: string }
 * | { type: "ESCAPE", payload: string }
 * | { type: "CHAMPION_ARE", payload: string }
 * | { type: "CALC_RANK", payload: null }
 * } ActionsMap
 */
Enter fullscreen mode Exit fullscreen mode
Collapse
 
trusktr profile image
Joe Pea

You can export a type without making a variable:

/**
 * This type is automatically a type-only export.
 * @typedef {Foo | Bar} Something
 */
Enter fullscreen mode Exit fullscreen mode

This automatically exports the type.

With TS pre 5.5, you can import it like this in another file:

/** @typedef {import('./Something').Something} Something */
Enter fullscreen mode Exit fullscreen mode

With TS 5.5 and above, you can import with JSDoc @import syntax:

/** @import {Something} from './Something' */
Enter fullscreen mode Exit fullscreen mode

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:

class Foo {
  /** @type {number[]} */
  #foo = []
}
Enter fullscreen mode Exit fullscreen mode

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:

//: abstract
export class Foo { //:<T extends object>
  foo = "456"

  //: abstract
  method() {} //: T
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
what1s1ove profile image
Vladyslav Zubko • Edited

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 using let + 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!

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

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).

Collapse
 
what1s1ove profile image
Vladyslav Zubko

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

Collapse
 
teamradhq profile image
teamradhq

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:

/**
 * @param {number} num
 * @param {string} str
 * @param {Record<string, string>} rec
 * @param {{ id: number, title: string, items: ({ id: number, name: string})}} obj
 * @returns {number}
 */
Enter fullscreen mode Exit fullscreen mode

It's fine if obj is of a type that's only used in this one place. But in reality it the type of obj will be used multiple times, so it's simpler to declare the type once:

type Item =  { id: number; name: string };

type Rec = Record<string, string>;

type Obj = {
  id: number;
  title: string;
  items: Item[];
};
Enter fullscreen mode Exit fullscreen mode

Even if your codebase is JS using docblocks, this is a much neater solution:

/**
 * @param {number} num
 * @param {string} str
 * @param {Rec} rec
 * @param {Obj} obj
 * @returns {number}
 */
Enter fullscreen mode Exit fullscreen mode

Even better if you move your types into the function declaration, then you can use the docblocks for the actual documentation:

/**
 * @param num A number to do something with.
 * @param str A string to label the result with.
 * @param rec A record of properties interacted with.
 * @param obj An object containing the data to work with.
 */
function someFunction(
  num: number,
  str: string,
  rec: Rec,
  obj: Obj,
): number
Enter fullscreen mode Exit fullscreen mode

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.

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.

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:

You probably don't want to give up type checking; that's rather the point of TypeScript. So what you can do is use the fork-ts-checker-webpack-plugin. It runs the type checker on a separate process, so your build remains fast thanks to transpileOnly: true but you still have the type checking.

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.

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).

This just means that you're using TypeScript. :)

Collapse
 
what1s1ove profile image
Vladyslav Zubko • Edited

Hey @teamradhq !

Thank you for your comment, and especially for your code snippets! Let's go step by step 🙂

It's fine if obj is of a type that's only used in this one place. But in reality it the type of obj will be used multiple times, so it's simpler to declare the type once:

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:

// obj.js

/**
 * @typedef {{
 *  id: number
 *  title: string
 *  items: Item[]
 * }}
 */
let Obj

export { Obj }
Enter fullscreen mode Exit fullscreen mode
// some-function.js

import { Obj } from './obj.js'

/**
 * @typedef {{
 *  num: number
 *  str: string
 *  obj: Obj
 * }}
 */
let SomeFunction

export { SomeFunction }
Enter fullscreen mode Exit fullscreen mode

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.

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.

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.

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.

Image description

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 just means that you're using TypeScript. :)

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.

Collapse
 
teamradhq profile image
teamradhq • Edited

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:

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.

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 because transpileOnly is set to false, 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 of tsc, and Vite skips type checking by default (emphasis mine):

The reason Vite does not perform type checking as part of the transform process is because the two jobs work fundamentally differently. Transpilation can work on a per-file basis and aligns perfectly with Vite's on-demand compile model. In comparison, type checking requires knowledge of the entire module graph. Shoe-horning type checking into Vite's transform pipeline will inevitably compromise Vite's speed benefits.
Vite Features

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:

For production builds, you can run tsc --noEmit in addition to Vite's build command.

If you look at Vite starter templates, their package.json provides this script for builds:

{ 
  "scripts": {
    "build": "tsc --noEmit && vite build"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 in production, 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 to production 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.

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'm curious to know what the equivalent of overloading a function would be in docblock:

function someFunction(a: number): number;
function someFunction(a: number, b: string): string;
function someFunction(a: number, b: number, c: Record<string,string>): Record<string, string>;
function someFunction(
  a: number, 
  b?: string|number, 
  c?: Record<string,string>
): number | string | Record<string, string> {
 // ...
}

someFunction(1, 'string', {}); 
Enter fullscreen mode Exit fullscreen mode

That is to say, a is always a number, b is a string unless c is provided, in which case it's a number.

So calling someFunction(1, 'string', {}) should show an error:

Argument of type 'string' is not assignable to parameter of type 'number'.(2345)
The call would have succeeded against this implementation, but implementation signatures 
of overloads are not externally visible.
Enter fullscreen mode Exit fullscreen mode

Similarly, within the function body itself I would see errors:

  • if b is undefined it would give me an error if I try to use c as a record
  • if c is defined it would give me error if I try to use b as a string

Obviously, 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:

/**
 *
 * @param {number} a
 * @param {string | number | undefined} b
 * @param {Record<string, string> | undefined} c
 * @returns {number | string | Record<string, string>}
 */
function someFunction(a, b, c) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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:

someFunction(a);        // number
someFunction(a, b);     // string
someFunction(a, b, c);  // Record<string, string>
Enter fullscreen mode Exit fullscreen mode

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.

This means that I am currently using TypeScript now, and I will gladly replace it if I can in the future.

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 :)

Thread Thread
 
what1s1ove profile image
Vladyslav Zubko

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 👍

I'm curious to know what the equivalent of overloading a function would be in docblock:

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:

/**
 * @overload
 * @param {string} value
 * @return {void}
 */

/**
 * @overload
 * @param {number} value
 * @param {number} [maximumFractionDigits]
 * @return {void}
 */

/**
 * @param {string | number} value
 * @param {number} [maximumFractionDigits]
 */
function printValue(value, maximumFractionDigits) {
    if (typeof value === "number") {
        const formatter = Intl.NumberFormat("en-US", {
            maximumFractionDigits,
        });
        value = formatter.format(value);
    }

    console.log(value);
}
Enter fullscreen mode Exit fullscreen mode

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 :)

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!

Collapse
 
lizaveis profile image
Yelyzaveta Veis

Thank you for the article! Interesting insight

Collapse
 
disane profile image
Marco

JSDoc provides developers with real opportunities for gradually improving the codebase without requiring a complete transition to TypeScript from the start of migration.

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.

Collapse
 
what1s1ove profile image
Vladyslav Zubko • Edited

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.