Type checking without the muck

January 12th, 2026. Tagged: JavaScript


This is part 4 of a series about hacking on sightread.org with minimal tooling/building and maximum web platform-ing:

Now let's talk about types, the type of types that check if you made a typo and typed a string type where you were supposed to type a number type. This type o' thing.

The goal

I would like some type of type safety in my project. This has not always been my preference. You see, I don't like how every language I bother to learn sooner or later turns into Java. And I remember Douglas Crockford joking how in Java you repeat everything thrice. User user = new User();. Like some kind of incantation. Instead of getting down to the business of first name, age and favorite burrito ingredient. Anyway. So I'm still not a huge fan of types, especially for 1-person project. But I'm more malleable, I'm warming up. My friend Jeff Lembeck kinda swayed me, saying especially for personal projects the types pull their weight. Because now you know what's happening but in 3 weeks when you want to change just one little thing, you've forgotten all the context and the more guardrails you have in place, the better.

Anyway, before this turns into the type (pun!) of SEO rambling people put on web sites instead of just giving you the damn kale soup recipe... let's go.

The muck

What I especially dislike in TypeScript and the like is the verbosity, the type casting and everything that adds more words to the source making it cluttered, noisy and increasing the mental load of the reader. And the DOM type-casting, oh gimme a break! I mean:

const levelSelect = document.querySelector('#level') as HTMLSelectElement;
const tempoInput = document.querySelector('#tempo') as HTMLInputElement;
const checkboxes = Array.from(
  document.querySelectorAll('input[name="timeSignature"]:checked')
) as HTMLInputElement[];

I don't like all the "as"-ing about.

Or event handlers:

element.addEventListener('change', (e: Event) => {
  const target = e.target as HTMLInputElement;
  if (target.id === 'level' && target.value === 'custom') {
    previousLevel = target.dataset?.previousValue || '1';
    openCustomDialog();
  }
});

You can't just use e.target.id—TypeScript says EventTarget doesn't have id. So casting again.

Also this project has no transpilation, so TypeScript is out the door, a priori.

So can I have some type safety without the extra stuff? Turns out, yes, mostly.

Enter JSDoc

In the beginning there was JSDoc. Well technically JavaDoc came before it, but we the JS community embraced the idea of inline documentation early on.

Here's the canonical example:

/**
 * Sums two numbers
 * @param {number} a - First number
 * @param {number} b - Second number
 * @returns {number} Sum of a and b
 */
function sum(a, b) {
  return a + b;
}

This looks like types, right? Well, you may be delighted to know that before there was TypeScript, and even before Flow, there was JSDoc and it was even used in some projects for typechecks.

Both Flow and TS came with JSDoc support out of the box. This was adoption aid, why not use what's already in the source instead of making developers redefine types with a new syntax.

I was wondering if TS still supports JSDoc. Turns out, yes, it does!

The setup

We start with a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "baseUrl": ".",
    "paths": {
      "/src/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.js", "src/**/*.d.ts"],
  "exclude": ["dist", "test"]
}

Key bits:

  • allowJs + checkJs: Check JavaScript files
  • noEmit: Don't output anything, just check
  • paths: Resolve absolute imports like /src/app.js (used in dynamic imports)

Running:

npx -p typescript tsc --noEmit

The -p typescript flag tells npx to use the typescript package without installing it locally. First run downloads it, subsequent runs use the cache. No node_modules, no package.json, remember?

The types file

All shared type definitions live their happy lives in src/types.d.ts, not in my program source code:

/** A single note within a pattern */
export interface Note {
  duration: number;
  rest?: boolean;
  tie?: boolean;
}

/** Time signature as [beats per measure, note value] */
export type TimeSignature = [number, number] | number[];

/* ... */

Then in JavaScript files, I import them with JSDoc:

/** @typedef {import('./types').Options} Options - App settings */
/** @typedef {import('./types').Pattern} Pattern - Rhythm pattern */

And use them to annotate variables:

/** @type {Options} */
const myOptions = {
  tempo: 80,
  metronome: 'yes',
  measures: 4,
  //...
};

The DOM problem (and solution)

Here's where it gets interesting. TypeScript's DOM types are strict. When you do:

document.querySelector('#level').value

TypeScript complains: "Property 'value' does not exist on type 'Element'."

The traditional fix is inline casting. But I don't like it. Get off my lawn (and source code)!

My solution: extend the DOM types in types.d.ts:

declare global {
  interface Element {
    value?: string;
    checked?: boolean;
    options?: HTMLOptionsCollection;
    dataset?: DOMStringMap;
    name?: string;
  }

  interface EventTarget {
    id?: string;
    value?: string;
    checked?: boolean;
    dataset?: DOMStringMap;
    name?: string;
    matches?(selectors: string): boolean;
  }
}

Now element.value just works. Yes, this is less strict, TypeScript won't catch me accessing .value on a <div>. But in practice, I know which elements have which properties. That's the thing, I usually know my DOM and what's in it, no need to be overly paranoid, like TS likes.

The service worker problem

Service workers run in ServiceWorkerGlobalScope, not Window. TypeScript doesn't seem to know this. So self.skipWaiting() throws an error.

The fix: extend Window and Event with service worker methods in types.d.ts:

declare global {
  interface Window {
    skipWaiting?(): Promise<void>;
    clients?: Clients;
  }

  interface Event {
    waitUntil?(promise: Promise<any>): void;
    respondWith?(response: Response | Promise<Response>): void;
    request?: Request;
  }
}

The methods are optional (?) because they only exist in service worker context. But TypeScript won't complain no more and the code remains clean.

What I gave up

Strict null checks. With strictNullChecks: true, TypeScript wants you to handle every possible null:

const el = document.querySelector('#foo');
el.value = 'bar';  // Error: 'el' is possibly null

Fixing all of these would mean ?. and if checks everywhere. No, thanks. Leave the DOM to moi.

No implicit any. Same deal. Some function parameters don't have explicit types, and that's fine with me.

Test file checking. Tests use mock data that doesn't satisfy strict types. This is fine and makes my life simple.

What I kept

  • Strict function types: catch mismatched callback signatures and such
  • Strict bind/call/apply: catches incorrect this binding
  • Real type safety: for the "business" logic (rhythm generation, pattern matching, URL parsing). You know, the stuff that matters. Help me with these, leave my DOM alone, thank you very much.

The build integration

Type checking runs at the start of every build:

// build.js
console.log('Type checking...');
try {
  execSync('npx -p typescript tsc --noEmit', { stdio: 'inherit' });
} catch (error) {
  console.error('Type check failed! Build aborted.');
  process.exit(1);
}

Fail fast, fail spectacularly, that's how I like my development cycle.

Conclusion

You don't need to convert to TypeScript to get type safety. JSDoc + checkJs gives most of the benefits with minimum of the hassle. All the type overhead is tucked away in types.d.ts and JSDoc comments. And all JSDoc comments can be collapsed in your IDE of choice with a keyboard shortcut.

Extend DOM interfaces instead of casting. Accept some looseness where it doesn't matter (null checks on DOM elements) to maintain strictness where it does ("business" logic).

Is this approach for everyone? Yes! Personal projects? Sign me up! A team with 1000 developers? Heck, yeah! This is the gospel, read and follow while you batch-rename your .ts files back to sanity. OK, OK, you know I jest.

Comments? Find me on BlueSky, Mastodon, LinkedIn, Threads, Twitter