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.