Zack Hobson

Type checking JavaScript with TypeScript Tuesday September 5, 2023

Unlike the web component stuff, the thing I am curious about today is not especially new. Since I am accustomed to using TypeScript, I felt myself missing the type checker when working on straightforward browser-targeted JavaScript.

TypeScript for JavaScript

Just using TypeScript would not be a big leap here, I am already using a basic build system for the posting parts of this blog. However, I rather like that I can code directly to the browser! Also there are still ways to statically analyze JS code with the TypeScript compiler, so we're going to do that instead, just to see how useful it is.

Say you already have a web app with a couple JavaScript modules. The first step is probably to install TypeScript if it isn't installed yet, and to create a tsconfig.json:

$ yarn add typescript
$ cat > tsconfig.json
{
  "compilerOptions": {
    "target": "ES6",
    "allowJs": true,
    "checkJs": true,
    "noEmit": true
  },
  "include": ["static/js/*", "static/*.js"]
}

To get the tsc compiler to behave the way we need, we specify a few things. The target is ES6, which is the dialect of JS that we're using. allowJs and checkJs make sure that JS code is loaded and checked, otherwise it will only analyze TypeScript code. Finally the noEmit ensures that we aren't emitting any code, since we're just using the type checker. I am also putting my own paths in the include list.

Type-checking my code

Once I have tsc installed and configured I can run it with npx or as a command in my package.json:

$ npx tsc
static/notes-search.js:79:16 - error TS2339: Property 'select' does not exist
on type 'EventTarget'.

Oh hey, I got a type mismatch in my plain old JavaScript! It happens in an event listener, where I am selecting the search input text on focus:

const input = document.createElement('input')
// ...
input.addEventListener('focus', e => e.target.select())

The call to select() happens to be safe, because the event listener is being applied to an input element, which has that method. However, the type checker isn't buying it. There are a couple of ways to fix this!

  • We can cast to a known type using supported JSDoc annotations
  • We can ignore the callback args and capture input in the callback instead

First, let's try casting with JSDoc. This works because the TypeScript compiler utilizes JSDoc annotations in type analysis. Here, we're just casting the target object to the correct type before we use it:

input.addEventListener('focus', e => {
  const target = /** @type {HTMLInputElement} */ (e.target)
  target.select()
})

This works and is happily accepted by the TypeScript checker. It's a little noisy though. What about the second option?

input.addEventListener('focus', () => input.select())

This is accepted because the inferred type of input is already correct, so no cast is required. The type checker loves it! I like it too, it's even slightly clearer than the original code.

What about implicit any?

I'm already out of stuff to fix! But there is another knob we can mess with. By default, the TypeScript checker will implicitly apply the any type to values where no type is specified or inferred. I usually have the "implicit any" option disabled in my TypeScript code bases, let's see what happens when we do that here:

$ npx tsc
<...list of errors elided>

Found 12 errors in 2 files.

Look at that! There appear to be 12 places where the type of an expression is not able to be inferred. Of course, it's all member declarations and method parameters. Everything else is initialized with a value so it gets a type.

Most of these were actually fairly easy to add, until I got to some code that filters and maps over fetched data. I almost stopped here, but I became pretty curious about how I'd even do it. This is the code:

const resultRows = this.index.notes
  .filter(note => note.title.toLowerCase().indexOf(matchValue) >= 0)
  .map(note => {
    const row = document.createElement('li')
    const item = row.appendChild(document.createElement('a'))
    item.textContent = note.title
    item.href = note.url
    return row
  })

The problem here is that the note parameter for both inline callbacks cannot be inferred by the type checker. Since they both accept the same object type, I decide to declare it separately instead of repeating it:

/**
 * @typedef {object} Note
 * @property {string} title
 * @property {string} url
 */

const resultRows = this.index.notes
  .filter(
    /** @param {Note} note */ note =>
      note.title.toLowerCase().indexOf(matchValue) >= 0,
  )
  .map(
    /** @param {Note} note */ note => {
      const row = document.createElement('li')
      const item = row.appendChild(document.createElement('a'))
      item.textContent = note.title
      item.href = note.url
      return row
    },
  )

Not entirely sure this is worth it! This kind of thing would make it easier to use if it was a reusable module, but for a small self-contained component it feels like overkill to me. I do enjoy the warm embrace of a type checker, so I'll leave it in place.

It's nice to be able to use modern validation tools on my browser-targeted JavaScript code without committing to a bundler or build system. It shouldn't really be a surprise, since TypeScript and JavaScript are still semantically identical, but it's fun to see it in action.