HomeArticles

TypeScript without TypeScript -- JSDoc superpowers

TypeScript

One way to think about TypeScript is as a thin layer around JavaScript that adds type annotations. Type annotations that make sure you don’t make any mistakes. The TypeScript team worked hard on making sure that type checking also works with regular JavaScript files. TypeScript’s compiler (tsc) as well as language support in editors like VSCode give you a great developer experience without any compilation step. Let’s see how.

Table of contents #

TypeScript with JSDoc Annotations #

In the best case, TypeScript finds out types on its own by infering correctly from the way you use JavaScript. 

function addVAT(price, vat) {
return price * (1 + vat) // Oh! You add and mulitply with numbers, so it's a number
}

In the example above, we mulitply values. This operation is only valid for type number. With this information, TypeScript knows that the return value of addVAT will be of type number.

To make sure the input values are correct, we can add default values:

function addVAT(price, vat = 0.2) { // great, `vat`is also number!
return price * (1 + vat)
}

But type inference just can get so far. We can provide more information for TypeScript by adding JSDoc comments:

/**
* Adds VAT to a price
*
* @param {number} price The price without VAT
* @param {number} vat The VAT [0-1]
*
* @returns {number}
*/

function addVAT(price, vat = 0.2) {
return price * (1 + vat)
}

Paul Lewis has a great video on that. But there’s a lot, lot more to it than a couple of basic types in comments. Turns out working with JSDoc type gets you very far.

Activating reports #

To make sure you not only provide type information, but get actual error feedback in your editor (or via tsc), please activate the @ts-check flag in your source files:

// @ts-check

If there’s one particular line that errors, but you think you know better, add the @ts-ignore flag:

// @ts-ignore
addVAT('1200', 0.1); // would error otherwise

Inline types #

Defining parameters is one thing. Sometimes you want to make sure that a variable, which hasn’t been assigned yet, has the correct type. TypeScript supports inline comment annotations.

/** @type {number} */
let amount;
amount = '12'; // 💥 does not work

Don’t forget the correct comment syntax. Inline comments with // won’t work.

Defining objects #

Basic types is one thing, but in JavaScript you usually deal with complex types and objects. No problem for comment based type annotations:

/**
* @param {[{ price: number, vat: number, title: string, sold?: boolean }]} articles
*/

function totalAmount(articles) {
return articles.reduce((total, article) => {
return total + addVAT(article)
}, 0)
}

See that we defined a complex object type (just like we would do in TypeScript) inline as a parameter.

Annotating everything inline can become crowded very quickly. There’s a more elegant way of defining object types through @typedef:

/**
* @typedef {Object} Article
* @property {number} price
* @property {number} vat
* @property {string} string
* @property {boolean=} sold
*/


/**
* Now we can use Article as a proper type
* @param {[Article]} articles
*/

function totalAmount(articles) {
return articles.reduce((total, article) => {
return total + addVAT(article)
}, 0)
}

More work writing, but ultimately more readable. Also TypeScript now can identify Article with the name Article, providing better information in your IDE.

Please note the optional parameter sold. It’s defined with @property {boolean=} sold. An alternative syntax is @property {boolean} [sold]. Same goes for function @params.

Defining functions #

Functions can be defined inline, just like their object counterparts:

/**
* @param {string} url
* @param {(status: number, response?: string) => void} cb
*/

function loadData(url, cb) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url)
xhr.onload = () => {
cb(xhr.status, xhr.responseText)
}
}

Again, this can get very confusing quickly. There’s the @callback annotation that helps with that:

/**
* @callback LoadingCallback
* @param {number} status
* @param {string=} response
* @returns {void}
*/


/**
* @param {string} url
* @param {LoadingCallback} cb
*/

function loadData(url, cb) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url)
xhr.onload = () => {
cb(xhr.status, xhr.responseText)
}
}

@callback takes the same parameters as function annotation, but works like @typedef

Importing types #

@typedef allows you to import types from any other .js or .ts file. With that you can write TypeScript type definitions in TypeScript and import them in your source files.

See article.ts:

export type Article = {
title: string,
price: number,
vat: number,
sold?: boolean,
}

And our main.js:

// The following line imports the Article type from article.ts and makes it
// available under Article
/** @typedef { import('./article').Article } Article */

/** @type {Article} */
const article = {
title: 'The best book in the world',
price: 10,
vat: 0.2
}

You can also import a type directly in the type annotation:

/** @type {import('./article').Article} */
const article = {
title: 'The best book in the world',
price: 10,
vat: 0.2
}

Great when working a mix of TypeScript where you don’t have ambient type definitions.

Working with generics #

TypeScript’s generics syntax is available wherever there’s a type that can be generic:

/** @type PromiseLike<string> */
let promise;

// checks. `then` is available, and x is a string
promise.then(x => x.toUpperCase())

But you can define more elaborate generics (esp. functions with generics) with the @template annotation:

/**
* @template T
* @param {T} obj
* @param {(keyof T)[]} params
*/

function pluck(obj, ...params) {
return params.map(el => obj[el])
}

Convenient, but a bit hard to do for complex generics. Inline generics still work the TypeScript way:

/** @type { <T, K extends keyof T>(obj: T, params: K[]) => Array<T[K]>} */
function values(obj, ...params) {
return params.map(el => obj[el])
}

const numbers = values(article, 'price', 'vat')
const strings = values(article, 'title')
const mixed = values(article, 'title', 'vat')

Have even more complex generics? Consider putting them in a TypeScript file and import it via the import function.

Enums #

Turn a specially structured JavaScript object into an enum and make sure values are consistent:

/** @enum {number} */
const HTTPStatusCodes = {
ok: 200,
forbidden: 403,
notFound: 404,
}

Enums differ greatly from regular TypeScript enums. They make sure that every key in this object has the specified type.

/** @enum {number} */
const HTTPStatusCodes = {
ok: 200,
forbidden: 403,
notFound: 404,
errorsWhenChecked: 'me' // 💣
}

That’s all they do.

typeof #

One of my most favourite tools, typeof is also available. Saves you a ton of editing:

/**
* @param {number} status The status code as a number
* @param {string} data The data to work with
*/

function defaultCallback(status, data) {
if(status === 200) {
document.body.innerHTML = data
}
}

/**
* @param {string} url the URL to load data from
* @param {typeof defaultCallback} cb what to do afterwards
*/

function loadData(url, cb) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url)
xhr.onload = () => {
cb(xhr.status, xhr.responseText)
}
}

extending and augmenting from classes #

The extends annotation allow you to specify generic parameters when extending from a basic JavaScript class. See the example below:

/**
* @template T
* @extends {Set<T>}
*/

class SortableSet extends Set {
// ...
}

@augments on the other hand allows you to be a lot more specific with generic parameters:

/**
* @augments {Set<string>}
*/

class StringSet extends Set {
// ...
}

Handy!

Bottom line #

TypeScript annotations in plain JavaScript go really far. There’s a little more to TypeScript especially when entering generics, but for a lot of basic tasks you get a lot of editor superpowers without installing any compiler at all.

Know more? Shoot me a tweet. I’m more than happy to add them here.

Related Articles