Querying elements in a typesafe manner

Querying elements in a typesafe manner

If you are like me, you may not like typescript but you do like proper types. With jsdoc, it's easy enough to add proper typing to any bit of vanilla javascript code.

However, things are not so great when you use the querySelector and querySelectorAll api, because it returns a generic Element. How to fix this ?

The issue

You want typesafe js, so you enable this in your project in the jsconfig.jsonfile

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"],
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "noImplicitOverride": false,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "esModuleInterop": true
  },
  "include": ["src/*.js"],
  "exclude": ["node_modules", "./test/"]
}

Let's say you have a form with an input. Maybe you do something like this:

const myInput = document.querySelector('form input');
myInput.checked = true; // you get "Property checked does not exist on Element"

The obvious fix: add some inline jsdoc

/**
* @type {HTMLInputElement}
*/
const myInput = document.querySelector('form input');
myInput.checked = true; // yay!

This works, but it's not so great when you need to deal with collections. Sure, it works, but it's not exactly developer friendly.

document.querySelectorAll('form input.checkbox').forEach((input) => {
    input.checked = true; // Huho... again ?
});

document.querySelectorAll('form input.checkbox').forEach(
/**
* @param {HTMLInputElement} input
*/
(input) => {
    input.checked = true; // yay!
});

A better solution?

Introducing q the only function you need to query your dom! If you were missing jQuery ease of use, this might just be your new best friend.

/**
 * Query elements in a typesafe manner
 *
 * You can declare your owns tags in the HTMLElementTagNameMap namespace
 *
 * ```js
 * // my-tag.d.ts
 * declare global {
 *   interface HTMLElementTagNameMap {
 *      "my-tag": MyTag;
 *   }
 * }
 *

*

  • @template {keyof HTMLElementTagNameMap} K
  • @param {K|String} tagName Name of the element, or global selector string (returns any element).
  • @param {String} selector Selector appended to the type. If it contains a space, type is ignored.
  • @param {Document|HTMLElement} ctx Context (document by default). If a context is specified, :scope is applied
  • @returns {Array} */ export default function q(tagName, selector = '', ctx = document) { // Don't prepend the type of we are asking for children, eg: #my-element type selector = selector.includes(' ') ? selector : ${tagName}${selector}; // Needed for direct children queries // @link developer.mozilla.org/en-US/docs/Web/CSS/:s.. // Needed to avoid inconsistent behaviour // @link lists.w3.org/Archives/Public/public-webapi/.. if (!(ctx instanceof Document)) { selector = :scope ${selector}; } return Array.from(ctx.querySelectorAll(selector)); } ```

Let's revisit our previous examples

const myInput = q('input', 'form input')[0];
myInput.checked = true; // yay!

q('input', 'form input.checkbox').forEach((input) => {
    input.checked = true; // yay!
});

Not sure yet if it's worth it to make another function that returns only one element using querySelector, but that would be easy enough to create on your own :-)

Some extra goodies

You might have noticed that the code does a little bit more than that. Let's see the extra goodies included.

You can easily query an element and add more specificity.

const inputs = q('input', '.checkbox'); // will query input.checkbox

When querying children nodes (as soon as you have a space in the selector), the tag name is ignored.

const inputs = q('input', 'form input'); // will query form input

Fragments are properly scoped by adding :scope automatically. This is almost always the desired behavior because it allows using direct selectors without extra syntax, and it will make sure you don't get elements outside of the context.

This scope issue was so bad in even wondered if it makes sense to allow this third parameter.

const form = q('form')[0]; // will query form
const formInputs = q('input', '.container .checkbox', form); // will query :scope .container input.checkbox
const directInputs = q('input', '> .checkbox', form); // will query :scope > input.checkbox

You can still query multiple elements if you want.

const mixed = q('label,input'); // still working fine since you can pass a regular string, but obviously, you don't get a proper type back

You get an array as a result... meaning, you can map, filter... with properly typed element inside your callback function.

const allNames = formInputs.map((el) => {
  return el.getAttribute('name');
});

You can extend the list of tags if you use custom elements.

declare global {
   interface HTMLElementTagNameMap {
      "my-tag": MyTag;
   }
}

And let the magic happen.

const myTag = q('my-tag'); // who gets a properly typed element ?

You can play with it here (check the console).