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:
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:
(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:
And additionally to type inference, VS Code will add all the unique words on the file you are editing to the intellisense dropdown:
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()
:
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:
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:
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
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
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);
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);
Here’s how that looks like in VS Code when you access an order:
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;
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;
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) {
//...
}
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) {
//...
}
And here it is in action:
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>
);
}
}
This is how it looks like when you are using your component:
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>
);
}
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);
}
Use type information from other modules
Imagine you are writing code in Node.js and you have the following function:
function doSomethignWithAReadableStream(stream) {
//...
}
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) {
//...
}
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
And then you could reference them in JsDoc
and get intellisense for the 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
}
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;
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"
}
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.
That’s it, if you are thinking, what exactly were the two ways to take advantage of types in JavaScript? They are JsDoc’s type annotations and TypeScript definition files.
It’s not really a good title though, I just wanted to try out something like “3 ways you can improve… blah blah” since they seem to captivate people’s attention better (couldn’t think of a third way, so there’ only 2, but they can be life changing right? Imagine those config objects with 30 properties that you have to lookup every time you need them and that you always mistype at least once! Not anymore).
In the end the title is not great because these two ways are not mutually exclusive. Also, 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.