DEV Community

Cover image for How to get perfect intellisense in JavaScript
Rui Figueiredo
Rui Figueiredo

Posted on • Originally published at blinkingcaret.com

How to get perfect intellisense in JavaScript

TypeScript is often described as the solution for making large scale JavaScript projects manageable. One of the arguments supporting this claim is that having type information helps catch a lot of mistakes that are easy to make and hard to spot.

Adopting TypeScript might not always be an option, either because you are dealing with an old codebase or even by choice.

Whatever the reason for sticking with plain JavaScript, it is possible to get a nearly identical development experience in terms of having intellisense and development time error highlighting. That is the topic of this blog post.

VS Code and JavaScript intellisense

If you create a new index.js in VS Code and type conso followed by Ctrl+space (or the Mac equivalent) you'll see something similar to this:

Intellisense for 'conso' in VS Code

The source of the intellisense data is from the type definition files that that are bundled with VS Code, namely console is defined in [VS Code installation folder]/code/resources/app/extensions/node_modules/typescript/lib/lib.dom.d.ts. All the files with the .d.ts extension in that folder will contribute for what you see in the intellisense dropdown.

TypeScript definition files are one of the sources of intellisense in VS Code.

They are not the only source though. Another source is what VS Code infers from your code.

Here's an example of declaring a variable and assigning it a value. The intellisense is coherent with the type of that value:

string intellisense example

(and yes, you can call .blink() or .bold() on a string, even in Node.js)

Here's another example where the type is inferred from the usage of a variable in a class definition:

class where a variable is used as a number and as a string, the inferred type is number or string

And additionally to type inference, VS Code will add all the unique words on the file you are editing to the intellisense dropdown:

intellisense dropdown with methods from a class plus all the unique words in the file

Even though the type inference available in VS Code is very clever, it's also very passive.

It won't warn you if you call myInstance.pethodName() instead of myInstance.methodName():

incorrect method name and no error highlighting

We usually only figure this out at runtime when we get a TypeError: myInstance.pethodA is not a function.

Turns out that VS Code has a flag that is turned off by default that when turned on will enable type checking to run through your code, and report errors:

checkjs highlights that pmethod doesn't exist in myInstance

The flag name is called checkJs and the easiest way to enable it is to open "Show all commands" (Ctrl+Shift+p) and type "Open workspace settings" and then activate checkJs:

animation showing the opening of all commands typing open workspace and then clicking checkjs

You might discover that after turning on checkJs your file turns into a Christmas Tree of red squiggles. Some of these might be legitimate errors, but sometimes they might not. It doesn't happen often but I've encountered instances where the type definition files for a JavaScript library don't match the latest version (how this happens will become clearer later in the blog post).

If this happens and you are sure that the code you have is correct you can always add at the very top of the file:

//@ts-nocheck
Enter fullscreen mode Exit fullscreen mode

This will turn off type checking for the whole file. If you just want to ignore a statement you add this immediately before the statement to be ignored:

//@ts-ignore
variableThatHoldsANumber = false; //this won't be reported as an error
Enter fullscreen mode Exit fullscreen mode

Manually providing type information in JavaScript

There are situation where it is impossible for type inference to figure out the type information about a variable.

For example, if you call a REST endpoint and get a list of orders:

const orders = await getOrdersForClient(clientId);
Enter fullscreen mode Exit fullscreen mode

There's not enough information available for any useful type inference there. The "shape" of what an order looks like depends on what the server that hosts the REST api sends to us.

We can, however, specify what an order looks like using JsDoc comments, and those will be picked up by VS Code and used to provide intellisense.

Here's how that could look like for the orders:

/** @type {Array<{id: string, quantity: number, unitPrice: number, description: string}>} */
const orders = await getOrdersForClient(clientId);
Enter fullscreen mode Exit fullscreen mode

Here's how that looks like in VS Code when you access an order:

Intellisense sourced from JsDoc annotations

Even though this can look a little bit cumbersome it's almost as flexible having TypeScript type information. Also, you can add it just where you need it. I found that if I'm not familiar with a legacy codebase that has no documentation, adding this type of JsDoc annotations can be really helpful in the process of becoming familiar with the codebase.

Here are some examples of what you can do with JsDoc type annotations:

Define a type and use it multiple times

/**
* @typedef {object} MyType
* @property {string} aString
* @property {number} aNumber
* @property {Date} aDate
*/

/** @type {MyType} */
let foo;
/** @type {MyType} */
let bar;
Enter fullscreen mode Exit fullscreen mode

If you use @typedef in a file that is a module (for VS Code to assume this there only needs to be an exports statement in the file) you can even import the type information from another file.

For example if @typedef is in a file named my-type.js and you type this from another-file.js in the same folder:

/** @type {import('./my_type').MyType} */
let baz;
Enter fullscreen mode Exit fullscreen mode

The intellisense for the baz variable will be based on MyType's type information.

Function parameters and return values

Another scenario where type inference can't do much is regarding the parameter types in function definitions. For example:

function send(type, args, onResponse) {
    //...
}
Enter fullscreen mode Exit fullscreen mode

There's not much that can be inferred here regarding the parameters type, args and onResponse. It's the same for the return value of the function.

Thankfully there's JsDoc constructs that we can use to describe all of those, here's how it would look like if type is a string, args can be anything and onResponse is an optional function function with two arguments, error and result and finally the return value is a Promise or nothing.

It's a pretty involved example, but it serves to illustrate that there's really no restrictions on the type information we can provide. Here's how that would look like:

/**
 * You can add a normal comment here and that will show up when calling the function
 * @param {string} type You can add extra info after the params
 * @param {any} args As you type each param you'll see the intellisense updated with this description
 * @param {(error: any, response: any) => void} [onResponse]
 * @returns {Promise<any> | void} You can add extra an description here after returns
 */
function send(type, args, onResponse) {
    //...
}
Enter fullscreen mode Exit fullscreen mode

And here it is in action:

Animated gif that shows how intellisense is displayed as you type the function name and then its parameters

Class and inheritance

One thing that happens often is that you have to create a class that inherits from other classes. Sometimes these classes can even be templeted.

This is very common for example with React where it's useful to have intellisense for the props and state of a class component. Here's how we could do that for a component named ClickCounter whose state is a property named count which is a number and that also has a component prop named message of type string:

/** @extends {React.Component<{message: string}, {count: number}>}  */
export class ClickCounter extends React.Component {
    //this @param jsdoc statement is required if you want intellisense
    //in the ctor, to avoid repetition you can always define a @typedef
    //and reuse the type
    /** @param { {message: string} } props */
    constructor(props) {
        super(props);
        this.state = {
            count: 0,
        }
    }

    render() {
        return (
            <div onClick={_ => this.setState({ count: this.state.count + 1 })}>{this.props.message} - {this.state.count} </div>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

This is how it looks like when you are using your component:

Intellisense in JSX

This also possible in function components, for example this function component would have the same intellisense on usage than the class component from the example above:

/**
* @param {object} props
* @param {string} props.message
*/
export function ClickCounter(props) {
    const [count, setCount] = useState(0);

    return (
        <div onClick={_ => setCount(count + 1)}>{props.message} - {count} </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Casting

Sometimes you might want to force a variable to be of a particular type, for example imagine you have a variable that can be either a number or a string and you have this:

if (typeof numberOrString === 'string') {
    //there will be intellisense for substring
    const firstTwoLetters = /** @type {string} */ (numberOrString).substring(0, 2);
}
Enter fullscreen mode Exit fullscreen mode

Use type information from other modules

Imagine you are writing code in Node.js and you have the following function:

function doSomethignWithAReadableStream(stream) {
    //...
}
Enter fullscreen mode Exit fullscreen mode

To enable intellisense for the stream parameter as a readable stream we need the type information that is in the stream module. We have to use the import syntax like this:

/** @param {import('stream').Readable} stream */
function doSomethindWithAReadableStream(stream) {
    //...
}
Enter fullscreen mode Exit fullscreen mode

There might be cases though where the module you want to import the type from isn't available out of the box (as stream is). In those cases you can install an npm package with just the type information from DefinitelyTyped. There's even a search tool for looking up the correct package with the typing information you need for a specific npm package.

For example, imagine you wanted typing information for mocha's options, you'd install the type definition package:

npm install @types/mocha --save-dev
Enter fullscreen mode Exit fullscreen mode

And then you could reference them in JsDoc and get intellisense for the options:

example of intellisense for mocha's options

Providing type information to consumers of your module/package

If you were to create a module that exposed functions and classes with the JsDoc type annotations that we've been looking at in this blog post, you'd get intellisense for them when that module is consumed from another module.

There's an alternative way of doing this though, with type definition files. Say you have this very simple module using CommonJS and this module is defined in a file named say-hello.js:

function sayHello(greeting) {
    console.log(greeting);
}

module.exports = {
    sayHello
}
Enter fullscreen mode Exit fullscreen mode

If you create a file named say-hello.d.ts (and place it in the same folder as say-hello.js) with this inside:

export function sayHello(message: string): void;
Enter fullscreen mode Exit fullscreen mode

And you import this function in another module, you'll get the the typing information defined in the .d.ts file.

In fact, this is the type of file that the TypeScript compiler generates (along with the .js files) when you compile with the --declaration flag.

As a small aside, say that you are creating an npm module written totally in JavaScript that you want to share. Also, you haven't included any JsDoc type annotations but you still want to provide intellisense.

You can create a type declaration file, usually named index.d.ts or main.d.ts and update your package.json with the types (or typings) property set to the path to that file:

{
    "name": "the-package-name",
    "author": "Rui",
    "version": "1.0.0",
    "main": "main.js",
    "types": "index.d.ts"
}
Enter fullscreen mode Exit fullscreen mode

The type declarations that you put in index.d.ts define the intellisense you'll get when you consume the npm package.

The contents of index.d.ts don't even have to match the code in the module (in fact that's what the type definition packages in DefinitelyTyped do).

I'm intentionally leaving the topic of how to write typescript definition files very light here because it's a very dense topic and it's usually easy to find how to provide type information in most cases in the official docs.

A quick note about TypeScript definition files: a .d.ts file does not affect the file it "describes", i.e. if you create a type declaration file for module my-module.js and in that type declaration file you specify that functionA receives a parameter of type number and you invoke that function from functionB also inside my-module you won't get intellisense for functionA. Only modules that require/import my-module will take advantage of the type information in the type declaration file.

That's it, now think about that large 30+ property configuration object for which you can never remember the exact name of the property you want to set (is it includeArrayIndex or enableArrayIndex and does it take a boolean or a string?). Now you don't have to worry about mistyping it and you don't have to look it up everytime.

Top comments (0)