Yeison Daza
8 min read

Types in JavaScript Without TypeScript/Flow

JavaScript isn’t a strictly typed language, and many projects have scaled quite well without using types. But the reality is that as a project grows, its complexity increases, and there are simply too many details we can no longer keep in our heads.

Types help us reduce this complexity in several ways. Some of these are:

  • Avoiding common errors, since knowing the inputs/outputs or interfaces of the modules we use helps us use them the way they should be used.
  • Documentation, Being able to clearly see the data types a module accepts or returns without having to dig into its definition is pretty useful, especially in large projects.
  • IDE support, The integration and suggestions from IDEs/editors are really helpful when you’re coding.
  • Refactoring, Being able to modify parts of your code without regressions or inferring where what you’re changing is being used turns out to be one of the best advantages that types provide.

These are some of the reasons why integrating types into your projects is a great idea. It’ll generally improve the developer experience and help prevent errors before the product reaches your users.

If you don’t use a way to define types, that’s fine. Many projects work without this, and if you don’t feel like you have the problem, don’t integrate them, since it’ll only add another layer of complexity for your developers.

JSDOC + TSC

The two most common alternatives for integrating types into JavaScript are Flow or TypeScript. Both have their pros and cons, but both will add a transpilation layer to your code, and integrating them into your development workflow might not be that straightforward.

It takes time and experience to be efficient with types if you have a background in typed languages.

An alternative is to use JSDoc, which is a standard way to add documentation to your JS code. By using it together with TSC or TS (just for type checking), we can get the same advantages of using types.

One of the reasons to use this approach is that you don’t need (or barely need) an extra transpilation step. The code you write is still JS, and you don’t need to migrate or change the tools you use in development.

Also, if you use VSCode, it supports JSDoc for IntelliSense, allowing for better autocomplete, parameter information, etc.

Configuration

To enable this in VSCode, you can do it in two ways:

The first is to enable it by default on all JS files. In settings, add:

"javascript.implicitProjectConfig.checkJs": true

The second is to add a jsconfig.json file at the root of your project with the following configuration:

{
  "compilerOptions": {
    "target": "es2017",
    "allowSyntheticDefaultImports": true,
    "jsx": "react",
    "noEmit": true,
    "strict": true,
    "noImplicitThis": true,
    }
  },
  "exclude": ["node_modules", "build"],
}

You can read more about all the configuration options in detail here.

You might also be interested in having a CI step that checks your types. For this, you just need to add a script in your package.json with:

  "type-lint": "tsc --pretty",

One of the large projects that uses this approach for type checking is webpack.

Third-Party Library Types

Once you start using types this way or with TypeScript, you’ll find that your dependencies need help to know their types since not all of them are published with types. For this, the TS community has a great tool called TypeSearch that helps you find the types for your dependencies.

What Types Can I Use?

The basic types are:

  • null
  • undefined
  • boolean
  • number
  • string
  • Array or []
  • Object or {}

To define a variable’s type, you can use @type:

/**
 * @type {number}
 */
const age = 1

/**
 * @type {string}
 */
const name = "yeison"

In this case it would be unnecessary since assigning a number to the variable infers the type.

For example, with arrays we can define the type of the elements they contain:

/**
 * @type {Array<number>}
 */
const randomNumbers = []

An alternative is to use the @type {number[]} syntax.

With objects, you can define what type each property will have:

/**
 * @type {{age: number, name: string}}
 */
const person = {age: 1, name: 'yeison'}

Another alternative is to define each property on a separate line:

/**
 * @property {number} age
 * @property {string} name
 */
const person = {age: 1, name: 'yeison'}

person.name = 1 // Te va a mostrar un error

If a property is optional, we can declare it using [] around the property name:

/**
 * @typedef {Object} Options The Options to use in the function createUser.
 * @property {string} firstName The user's first name.
 * @property {string} lastName The user's last name.
 * @property {number} [age] The user's age.
 */
/**
 * @type {Options} opts
 */
const opts = { firstName: 'Joe', lastName: 'Bodoni' } // no va a mostrar error

Defining Custom Types

We can create custom types. This is a way to create reusable type definitions.

To declare a custom type, we use @typedef:

/**
 * @typedef {{age: number, name: string}} Person
 */

/**
 * @type {Person}
 */
const person = {age: 1, name: 'yeison'}

/**
 *
 * @param {Person} person
 * @returns {string}
 */
const getUpperName = (person) => person.name.toUpperCase()

Methods and Functions

When we declare functions, we can define what values a function will receive and return. We have several syntax options we could use.

Standard JSDoc Syntax

/**
 *
 * @param {number} a
 * @param {number} b
 * @returns {boolean}
 */
const gte = (a, b) => a > b

Syntax More Similar to TS

/**
 * @type {function(number, number): boolean}
 */
const gte = (a, b) => a > b

Closure-like Syntax


/**
 * @type {(a: number, b: number) => boolean}
 */
const gte = (a, b) => a > b

Generics

To use generic values, we can use @template:

/**
 * @template T
 * @param {T} i
 * @return {T}
 */
function identity(i) {
  return i
}

In this case, identity will accept any type, but whatever type it receives is the one it must return.

Importing Types

We can also import types between files. Standard JSDoc doesn’t support this, but VSCode allows using import to import type definitions.

/**
 * @typedef {import('moment').Moment} initialDate
 */

You can also import them from files (you don’t need to declare any export clause):

/**
 * @typedef {import('../utils').File} File
 */

Intersections and Unions

Something we might want to do is extend a definition we already have. For this, we can use intersections (&), which let us merge several types into one:

/**
 * @typedef {{name: string, cc: number, tel: number}} Person
 * @type {Person & {addres: string}}
 */
const person = { name: 'yeison', cc: 1, tel: 12, addres: 'asd' }

Or we might need a type to be one or another. For this, we can use unions (|):

/**
 * @type {{isValidCitizen: true, cc: number} | {isValidCitizen: false, ce: number}}
 */
const person = { isValidCitizen: true, cc: 1 }

React with JSDoc

Once we know how to use the basics of JSDoc, we can apply them in projects where we use React — in this case, to define the types of the props and state of each component.

If a component we’re going to use has its types defined, when we use it the editor will give us information about the props it expects and their types.

  • Function component
import React from 'react'

/**
 * @param {{name: string}} Props
 */
const Hello = ({ name }) => (
  <h1>Hello {name}</h1>
)

<Hello name={1}> {/* muestra error */}
  • Class components

For components declared with classes, we need to define their props and state. Since the class is extending Component, to write this in JSDoc we need to use @extends:

import React, { Component } from 'react'

/**
 * @typedef {{ user: string, password: string }} State
 * @typedef {{ login: (data: State) => Promise }} Props
 * @extends {Component<Props, State>}
 */
class Login extends Component {
  state = {
    user: '',
    password: ''
  }

  /**
   * @param {React.ChangeEvent<HTMLInputElement>} ev
   */
  handleChange = (ev) => {
    const { value, id } = ev.target

    this.setState({[id]: value})
  }

  /**
   * @param {React.FormEvent} ev
   */
  handleSubmit = (ev) => {
    ev.preventDefault()

    this.props.login(this.state)
  }

  render () {
    return (
      <form>
        <fieldset>
          <label htmlFor='user'>
            Password
            <input type='text' id='user' />
          </label>
          <label htmlFor='password'>
            Password
            <input type='password' id='password' />
          </label>
        </fieldset>
        <input type='submit' value='Enviar'/>
      </form>
    )
  }
}

Bonus

In many projects, aliases are used to import modules without having to write long paths. The problem is that your editor won’t recognize these paths. To fix this, we can add the alias configuration in jsconfig.json:

{
  "compilerOptions": {
    "module": "es2015",
    "esModuleInterop": true,
    "moduleResolution": "node",
    "baseUrl": "./src",
    "paths": {
      "utils": ["utils/"],
      "api": ["api/"],
      "actions/*": ["actions/*"],
      "stores/*": ["stores/*"],
    }
  }
}

Final Words

I personally believe that using JSDoc in a project is a pretty good and low-friction way to improve the developer experience and get the advantages that types offer without having to migrate to TS/Flow right away.