Add Typescript to your JS project in 1 line

maxmonteil

Maximilien Monteil

Posted on March 6, 2021

Add Typescript to your JS project in 1 line

If you've been spending any time in the JS world, you might have noticed Typescript becoming all the rage. But if you didn't start off with it, adding Typescript can be a pain.

Well it doesn't have to be, in 5 lines you can have your cake and eat it too!

Why Typescript in the first place?

The argument is that your Javascript code is bound to start getting errors that Typescript's typing could have avoided, especially as your project gets bigger.

According to Kent C. Dodds, adding a type system is also the first step to getting into testing if you don't already have it.

How to add testing to an existing project

Everyone wants to catch bugs that are obvious like passing a string where a number is expected, getting that sweet IDE auto-completion, and all around being more confident when you make changes.

Maybe you're convinced, but you're already knee deep in your pure JS project and adding Typescript seems like a huge hassle. Well there exists a beautiful solution that requires literally 1 line.

/** @param {number} value */
function onlyGiveMeNumbers (value) {
    return Math.round(value)
}
Enter fullscreen mode Exit fullscreen mode

Boom! Full on typing thanks to special JSDoc comments. You just need to make sure to use 2 * to start the multi-line comment.

If it doesn't work right away you have three options:

  1. add // @ts-check to the top of each file
  2. If you use VS Code there's a checkJs option
  3. create tsconfig.json or jsconfig.json in your project root
// jsconfig.json
// if you make a tsconfig.json make sure to add "checkJs": true

{
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"],
  "compilerOptions": {
    "module": "es2020",
    "moduleResolution": "node",
    "target": "es2020",
    "lib": ["dom", "es2019"],
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "baseUrl": ".",
  }
}
Enter fullscreen mode Exit fullscreen mode

This works because a lot of IDEs use the TS language server to check your code even if it is in Javascript.

Setting checkJs to true takes advantage of what's already there, but instead of getting types as any everywhere, JSDoc lets you give your editor the information it needs.

This has some advantages over TS:

  • No compilation step
  • Hyper simple setup
  • No impact on final bundle size
  • Self documenting
  • Almost as complete as full on TS

That last point is where you might have some second thoughts about going in on JSDoc. Standard JSDoc is not at feature parity with Typescript.

If you follow the official JSDoc documentation (JSDoc Docs if you will), there are some things that you either can't do or are a huge hassle to set up (compared to TS), but this might not even affect you.

You see, I think there are 2 kinds of typing worlds:

  1. Application logic
  2. Library land

In application logic, typing is generally pretty straight forward (compared to 2), you mainly need to make sure you are consistent and thorough.

For example, say you have an application that deals managing quests, you would first define the type for your core domains and then make sure every function and method expecting these domains is typed as such.

Define all your domains.

/**
 * @typedef Quest
 * @prop {string} target What you should hunt
 * @prop {boolean} completed Whether the quest is completed or not
 */

/**
 * @typedef QuestBoard
 * @prop {string} name The board's name
 * @prop {Quest[]} quests All the quests on this board
 */
Enter fullscreen mode Exit fullscreen mode

Type all the places that would expect these items.

/**
 * @param {string} task
 * @return {Quest}
 */
function createQuest (target) {
    return { target, completed: false }
}

/**
 * Given a name and list of quests, create a new Quest Board.
 *
 * @param {string} name Name for the quest board.
 * @param {Quest[]=} quests optional list of quests.
 * @return {QuestBoard}
 */
function createQuestBoard (name, quests=[]) {
    return { name, quests }
}
Enter fullscreen mode Exit fullscreen mode

If most of your typing is going to be in the Application realm then JSDoc will serve you admirably. But when you enter Library Land things can get a little murkier, mainly because of Generics.

As a quick explanation, Generics, are a placeholder type. You tell your code that it will receive a "something", but whatever it is, here is how you should handle it. Without Generics you might have to duplicate functions for each kind of type you expect it to receive.

When making libraries that will be used by others, you can't predict what people will send so you need to be ready for anything, I'm not a typing expert but I've seen some frightening Library Land typing that JSDoc might not be able to handle (or maybe it might?).

Unless you have such requirements, JSDoc still handles itself pretty well.

/**
 * @template SomeGenericType
 *
 * @typedef WrappedData
 * @prop {SomeGenericType} data
 * @prop {Object} meta
 * @prop {number} meta.createdAt
 */

/** @template DataType */
class DataWrapper {
    /** @param {DataType} data */
    constructor (data) {
        this.wrapped = this.wrap(data)
    }

    get data () {
        return this.unwrap()
    }

    /**
     * @private
     * @param {DataType} data
     * @return {WrappedData<DataType>}
     */
    wrap (data) {
        return {
            data,
            meta: {
                createdAt: +(new Date()),
            },
        }
    }

    /** @private */
    unwrap () {
        return this.wrapped.data
    }
}

// A generic wrapper that will work with any random thing
const messageWrapper = new DataWrapper('Hello, World!')

/** @extends {DataWrapper<Quest>} */
class QuestWrapper extends DataWrapper {}

const quest = createQuest('Capture a Shogun Ceanator!')
// This wrapper will only accept Quest type things
const questWrapper = new QuestWrapper(quest)
Enter fullscreen mode Exit fullscreen mode

As with most examples dealing with Generics, this is a little contrived and not all that useful, but even then, JSDoc manages to get through.

But what can you do about the things JSDoc just can't doc?

Well there are 2 tricks that can get you almost all the way to complete feature parity with Typescript:

  1. Your editor's sneaky little secret
  2. Good old *.d.ts files

Your editor's sneaky little secret

I said earlier that your editor (probably VS Code) uses a Typescript language server to parse and understand your code. Even in Vim I'm using the same language server to check my code (Neovim ftw).

That's the secret!

What do I mean? It is a Typescript Language Server, not a JSDoc Language Server (if that makes sense).

When your editor goes through your code trying to understand it, it does so with a Typescript manual, this means it understands all the JSDoc stuff but also all the Typescript stuff. Here's an example:

import { Quest } from 'quest'

class QuestMap {
    /** @param {ReturnType<QuestMap.toPersistence>} raw */
    static toDomain = (raw) => Quest.create(raw)

    /** @param {Quest} quest */
    static toPersistence = (quest) => ({ target: quest.target, completed: quest.completed })
}
Enter fullscreen mode Exit fullscreen mode

If you look at this line:

/** @param {ReturnType<QuestMap.toPersistence>} raw */

You'll see a Typescript only feature ReturnType that still works because your editor is checking things through a Typescript lens.

I haven't done extensive tests with this but it should generally work for any Typescript feature that you're able to write out in JSDoc syntax.

It does have it's limits, for example, I wasn't able to get this to work:

// some function that returns an arbitrary number of Promise<boolean>
const getBools = () => [Promise.resolve(false), Promise.resolve(true)]
const getString = async => 'Hello'

async function tryToTypeThis () {
    await Promise.all([
        ...getBools(),
        getString(),
    ])
}

async function jsDocPlease () {
    const promises = [...getBools(), getString()]

    // ???
    await /** @type {PromiseConstructor['all']<boolean | string>} */ (Promise.all(promises))
}
Enter fullscreen mode Exit fullscreen mode
const getBools: () => Promise<boolean>[] = () => [Promise.resolve(false), Promise.resolve(true)]
const getString: () => Promise<string> = async => 'Hello'

async function canTypeThis () {
    await Promise.all<boolean | string>([
        ...getBools(),
        getString(),
    ])
}
Enter fullscreen mode Exit fullscreen mode

This is another set of contrived examples and I don't think you should write code like this, but it serves the purpose of showing where JSDoc reaches it's limits.

But there's a solution even to that.

Good old *.d.ts files

In our config earlier we had to set checkJs to true, that's because your editor type checks .ts files by default which your definition files fall under.

You might think what's the point of writing definition files, might as well go full Typescript.

To that I say, even in Typescript you'd end up writing some definition files, and using them still gives you all the advantages of only using JSDoc.

With definition files, you get the full feature set of Typescript, but once again you won't need a compile step and during build they get ignored since your project is a JS one (not 100% sure on this, please correct me if I'm wrong).

/** @typedef {import('./types.d.ts').ComplexType} ComplexType */

/** @type {ComplexType} */
const complexVariable = {}
Enter fullscreen mode Exit fullscreen mode

So should you use JSDoc?

If you are in a situation where your project is almost all JS and you're tempted to switch TS but the cost is just too high, this could be an option to consider.

It even has the advantage that if you do make the switch to TS, things will already be typed and documented.

Now of course JSDoc isn't perfect, it's quite a lot more verbose than the equivalent TS and it can sometimes be difficult to find answers to some problems.

In the end it's up to you to evaluate your options and make the choice that's best for you.

Some Helpful resources

When writing JSDoc there are actually 2 syntaxes that you can use, the one described on the official JSDoc site and the Closure Compiler Syntax.

CCS has a few extra features that might only be understood by the Closure Compiler but I have used some of them in JSDoc so your mileage may vary.

Since we're relying on the TS language server to check our JSDoc comments, it's helpful to look at Typescript's own JSDoc reference for what's supported.

Other Links


If you liked this article you can follow me @MaxMonteil for more :D

💖 💪 🙅 🚩
maxmonteil
Maximilien Monteil

Posted on March 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related