Use JSDoc as an alternative to TypeScript to reduce build size and polyfills

Use JSDoc as an alternative to TypeScript to reduce build size and polyfills

TypeScript is one of the best things to come out of Microsoft. While JavaScript is great, it is a scripting language that was created to run with minimal setup and checks. This means that it's easy to introduce errors and bugs into the code. Here are fantastic features from TypeScript:

  • Static type Checking
  • Futuristic. Use syntax that isn't part of JavaScript yet but should be.
  • Sits on top of JavaScript, meaning that all TypeScript file compiles down into JavaScript.
  • Adds syntax sugaring to make it easier to catch bugs

While the above-listed features are great, TypeScript will not work without compilation. It's an additional step and you will need tooling to watch the project for changes.

This can lead to setting up configurations and dependencies to make a project work. The workaround and standard workflow is to use a boilerplate.

For modern Web App development, the impact of running npm build before deployment is not huge. The problem arises when we try to build our own libraries and publish them on npm.

Here are the common problems when it comes to building with TypeScript and how to fix it.

The issue with TypeScript is that it creates large transpiled files

It's ideal that bundle size remains as small as possible for speed and a good user experience. TypeScript isn't the greatest when it comes to transpiling for older browers. Take a look at the example below:

const arr = [1, 2, 3, 4, 5];

const newArr = [...arr, 6, 7];

The array looks simple and innocent enough. However, if your TSConfig target is specified to anything less than es2015, you're going to end up with a large block of JavaScript code that looks something like this:

'use strict';

var __spreadArrays =
  (this && this.__spreadArrays) ||
  function () {
    for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;
    for (var r = Array(s), k = 0, i = 0; i < il; i++)
      for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++) r[k] = a[j];
    return r;
  };

var arr = [1, 2, 3, 4, 5];
var newArr = __spreadArrays(arr, [6, 7]);

TypeScript injected a huge function for spreading purposes. However, if you're a library author and targeting modern workflows, all this polyfilling becomes code noise. If the project integrates the above into its workflow, you may end up with duplicated polyfills. A polyfill is a piece of code that's aimed at providing modern functionality to older browsers. It fills in the support gaps by achieving the intended effect using old methods.

This is a similar case with async/await. Here is an example:

async function main() {
  const req = await fetch('url');
  const data = await req.json();

  console.log(data);
}

The main logic is just 2 lines. However, when you compile it using TypeScript for targets before es2017, you get something like this:

'use strict';
var __awaiter =
  (this && this.__awaiter) ||
  function (thisArg, _arguments, P, generator) {
    function adopt(value) {
      return value instanceof P
        ? value
        : new P(function (resolve) {
            resolve(value);
          });
    }

    return new (P || (P = Promise))(function (resolve, reject) {
      function fulfilled(value) {
        try {
          step(generator.next(value));
        } catch (e) {
          reject(e);
        }
      }

      function rejected(value) {
        try {
          step(generator['throw'](value));
        } catch (e) {
          reject(e);
        }
      }
      function step(result) {
        result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
      }
      step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
  };

function main() {
  return __awaiter(this, void 0, void 0, function* () {
    const req = yield fetch('url');
    const data = yield req.json();
    console.log(data);
  });
}

It creates the above function to interop with generators. In addition to this, the function above is approximately 1KB in size.

So how do we fix bloat created by TypeScript?

The easiest fix is to not use TypeScript at all. Instead, use TypeScript inside your actual JavaScript files.

How to use TypeScript features through JSDoc

To use TypeScript natively inside your JavaScript, you need the following:

  • VSCode as the editor (note that this is different from VSCode Studio)
  • ESLint extension for VSCode

Why VSCode? It is because VSCode has JSDoc built into it. What is JSDoc?

JSDoc is a way to document your code right. It was initially created to generate large documentation sites. However, VSCode adopted it for use in intelliSense.

Here is an example of how to write JSDoc code:

/**
 * Square a number
 * @param {number} a Number to be squared
 */
function square(a) {
  return a ** 2;
}

JSDoc starts with a double star(/**), not /*. VSCode will only recognize the comments as JSDoc if there are 2 stars.

On line 2, we are describing what the function does. It's a simple description.

On line 3, @param {number} a Number to be squared is used to specify that the function parameter a is of type number. The text Number to be squared is just a description of this parameter.

VSCode inferred the parameter and function return type from JSDoc itself. This means that you will get this additional description whenever you use this function. For example:

function square(a: number): number;

JSDoc also lets you create typings against a variable. Here is an example:

/** @type {string} */
const name = 'Hello';

Walkthrough example: Converting a TypeScript code snippet into JavaScript

Lets look at the TypeScript code below:

function sum(a: number, b: number) {
  return a + b;
}

document.querySelector('#submit').addEventListener('click', () => {
  const val1 = +(document.querySelector('#input1') as HTMLInputElement).value;
  const val2 = +(document.querySelector('#input2') as HTMLInputElement).value;

  console.log(sum(val1, val2));
});

Here is the converted JavaScript equivalent with JSDoc.

/**
 * @param {number} a
 * @param {number} b
 */
function sum(a, b) {
  return a + b;
}

document.querySelector('#submit').addEventListener('click', () => {
  /** @type {HTMLInputElement} */
  const el1 = document.querySelector('#input1');

  /** @type {HTMLInputElement} */
  const el2 = document.querySelector('#input2');

  const val1 = el1.value;
  const val2 = el2.value;

  console.log(sum(val1, val2));
});

How to enable TypeScript level strict checking in JavaScript files

You can enable strict checking in JavaScript files by simply adding the following comment at the top of your file.

// @ts-check

This signals to VSCode to type check your code as if it was TypeScript itself. With this method, there is no extra tooling or compilation step required.

How to import d.ts via JSDoc into your JavaScript

TypeScript has a d.ts file that handles all of the Declarations. It is basically an index of types. Let's take a look at the function below:

export function sum(a, b) {
  return a + b;
}

You can Type this function's parameters' types and return types inside a d.ts file:

export function sum(a: number, b: number): number;

Now whenever you import and use sum function, you'll automatically get intelliSense as if the original function was written in TypeScript itself.

JSDoc lets you import your TypeScript types/interfaces from a d.ts file. Here is an example of how to do it.

Let's pretend we're building an app that uses Twitter API to get data. But the response is so big that you get lost in it. So you declare a return type that looks something like this:

export interface IncludesMedia {
  height: number;
  width: number;
  type: 'photo' | 'video' | 'animated_gif';
  url: string;
  preview_image_url: string;
  media_key: string;
}

export interface ConversationIncludes {
  media?: IncludesMedia[];
  users: User[];
}

export interface Mention {
  start: number;
  end: number;
  username: string;
}

export interface Hashtag {
  start: number;
  end: number;
  tag: string;
}

export interface EntityUrl {
  start: number;
  end: number;
  /** format: `https://t.co/[REST]` */
  url: string;
  expanded_url: string;
  /** The possibly truncated URL */
  display_url: string;
  status: number;
  title: string;
  description: string;
  unwound_url: string;
  images?: {
    url: string;
    height: number;
    width: number;
  }[];
}

export interface Attachments {
  poll_id?: string[];
  media_keys?: string[];
}

export interface User {
  username: string;
  description: string;
  profile_image_url: string;
  verified: boolean;
  location: string;
  created_at: string;
  name: string;
  protected: boolean;
  id: string;
  url?: string;
  public_metrics: {
    followers_count: number;
    following_count: number;
    tweet_count: number;
    listed_count: number;
  };
  entities?: {
    url?: {
      urls: EntityUrl[];
    };
    description?: {
      urls?: EntityUrl[];
      mentions?: Mention[];
      hashtags?: Hashtag[];
    };
  };
}

export interface ConversationResponseData {
  conversation_id: string;
  id: string;
  text: string;
  author_id: string;
  created_at: string;
  in_reply_to_user_id: string;
  public_metrics: {
    retweet_count: number;
    reply_count: number;
    like_count: number;
    quote_count: number;
  };
  entities?: {
    mentions?: Mention[];
    hashtags?: Hashtag[];
    urls?: EntityUrl[];
  };
  referenced_tweets?: {
    type: 'retweeted' | 'quoted' | 'replied_to';
    id: string;
  }[];
  attachments?: Attachments;
}

/**
 * Types from response after cleanup
 */
export interface ConversationResponse {
  data: ConversationResponseData[];
  includes: ConversationIncludes;
  meta: {
    newest_id: string;
    oldest_id: string;
    result_count: number;
  };
  errors?: any;
}

There are two main things about the above code:

  1. We're declaring interfaces
  2. We're exporting them all

To use the type from the above directly in JSDoc, import it into your file. Here is an example of the syntax.

// @ts-check

const req = await fetch('TWITTER_API_URL');

/** @type {import('./twitter.d').ConversationResponse} */
const data = await req.json();

import via JSDoc works in a similar fashion as the dynamic import we're used to seeing. The only major difference is that it is importing all the exported types from our declaration file.

This allows us to use the ConversationResponse interface from the imported file. Our data variable becomes typed and VSCode will suggest autocompletion and errors as you create your code.

You're not limited to just typing. JSDoc support type alias and classes, in addition to helpers and operators. Here is an example:

// Partial of imported type
/** @type {Partial<import('./twitter.d').ConversationResponse>} */

// Pick types
/** @type {Pick<import('./twitter.d').ConversationResponse>, 'data' | 'includes'>} */

// Union types
/** @type {number | string} */

// Tuple types
/** @type {[[number, number], [number, number]]} */

Clean commenting in JSDoc

You can keep your JSDoc @types clean by not having those import statements everywhere. To do this, you can create a JSDoc alias for these types at the top level of your apps and directly use them. Here is an example using JSDoc's @typedef syntax here.

Using types.js as the index for the alias, here is the syntax for it:

/**
 * @typedef {import(../../twitter.d).ConversationResponse} ConversationResponse
 */

@typedef is used to declare complex types under a single alias. Think of it as a toned down version of type or interface. The code for fetching from the Twitter API becomes simpler:

// @ts-check

const req = await fetch('TWITTER_API_URL');

/** @type {ConversationResponse} */
const data = await req.json();

We got rid of the import here and it becomes easier to manage from a single source of truth.

Using Generics in JSDoc

This topic might be the most searched for topic, because not many answers are there for using Generics in JSDoc.

Let's pretend we have a generic function. Here is an example:

function getDataFromServer<T>(url: string, responseType: T): Promise<T> {
  // some epic code here
}

You can use @template in JSDoc to create a generic. Here is an example:

/**
 * @template T
 * @param {string} url
 * @param {T} responseType
 * @returns {Promise<T>}
 */
function getDataFromServer(url, responseType) {
  // Do epic shit
}

The above works - but there are 2 caveats you should know:

  1. @template is non-standard. It's not specified on JSDoc's own documentation. It's used internally in Google's Closure Compiler's source code, which VSCode supports.

  2. No type narrowing. This is because no narrowing is possible in JSDoc. You can't specify a generic type as T extends Array.


TypeScript is great but it's can lead to unnecessary bloat through polyfills. JSDoc makes a good alternative to TypeScript, whilst creating good documentation whilst you code. As there is no transpiling involved, the JavaScript you create remains unprocessed and reduces the number of polyfills in your final build.

Share this post

More from Layercode

  • Cloud

    Firebase vs. Digital Ocean App Platform vs. AWS Amplify

    Is Supabase your new Firebase alternative?

    +4 more articles

    View Series

  • Frontend

    What are CSS Modules and how to use it in React

    How modal dialogs work and how to add them to your React app

    +4 more articles

    View Series