Writing a safe querySelector: the one-way street from values to types

The querySelector method lets you retrieve DOM elements from JavaScript using CSS selectors:

const codeBlockEl = document.querySelector('p.intro code');

This API is ubiquitous in JavaScript, but it presents some challenges in a TypeScript context. For example:

alert(codeBlockEl.textContent);
// ~~~~~~~~~~~ const codeBlockEl: Element | null
// Object is possibly 'null'.

There's no guarantee that the selector will match any element, in which case it returns null. If you know from context that such an element does exist, you can use a non-null assertion (!) to silence the error:

alert(codeBlockEl!.textContent);  // ok

codeBlockEl! is shorthand for (codeBlockEl as Element) and is just as unsafe as any type assertion. If you don't know that the selector will match, you can use a conditional to narrow its type:

if (codeBlockEl) {
alert(codeBlockEl.textContent); // also ok
}

You can also use the optional chaining operator to allow undefineds to bubble up:

alert(codeBlockEl?.textContent);  // ok, might alert null or undefined

But null isn't the only problem. You might also get a type that's not specific enough. For example:

const nameEl = document.querySelector('input.first-name');
alert(nameEl!.value);
// ~~~~~ Property 'value' does not exist on type 'Element'.

document.querySelector returns an Element, but the value property is only defined on HTMLInputElement, which is a subtype. The solutions to this problem are similar. You can either use an unsafe type assertion:

const nameEl =
document.querySelector('input.first-name') as HTMLInputElement;
alert(nameEl.value); // ok

Or you can use an instanceof check to narrow the type:

const nameEl = document.querySelector('input.first-name');
if (!(nameEl instanceof HTMLInputElement)) {
throw new Error('Something has gone very, very wrong.');
}
alert(nameEl.value); // ok

If you plug this last example into the TypeScript playground and mouse over nameEl on the last line, you'll see that its TypeScript type is HTMLInputElement. Every other type would have made the code throw.

Writing these sorts of checks out every time you use querySelector quickly becomes tedious. Let's try to write a generic version instead!

The null checking is easy to abstract:

function checkedQuerySelector(
parent: Element | Document, selector: string
): Element {
const el = parent.querySelector(selector);
if (!el) {
throw new Error(`Selector ${selector} didn't match any elements.`);
}
return el;
}

You can swap this in for any use of querySelector to exclude null:

alert(checkedQuerySelector(document, 'p.intro code').textContent);  // ok

But what about the case where you need a more specific type? It's tempting to plug in a generic type parameter and try to implement something like this:

function safeQuerySelector<T extends Element>(
parent: Document|Element, selector: string
): T {
const el = checkedQuerySelector(parent, selector);
if (!(el instanceof T)) {
// ~ 'T' only refers to a type, but is
// being used as a value here.
throw new Error(`Selector ${selector} returned the wrong type.`);
}
return el;
// ~~~~~~~~~ Type 'Element' is not assignable to type 'T'.
}

The error on the instanceof check is a fundamental one that cannot be worked around. instanceof is an operator that is evaluated at runtime. As Item 3 of Effective TypeScript ("Understand That Code Generation Is Independent of Types") and many other sites explain, TypeScript types do not exist at runtime. So referencing them in a runtime expression is nonsensical. You can't get a value at runtime from a TypeScript type. It just won't work.

You can go the other way, however! Given a value, you can get a TypeScript type using typeof. And fortunately for us, there are runtime values corresponding to all the DOM types, just as there are for all classes. HTMLButtonElement in TypeScript can refer to either a type or a value depending on the context.

So instead of passing in the type we want, let's pass in a value. Here's a sketch:

function safeQuerySelector<T extends ???>(
parent: Document | Element,
type: T,
selector: string,
): ??? {
// ...
}

const buttonEl = safeQuerySelector(
document, HTMLButtonElement, 'button.save'
); // type should be HTMLButtonElement

Don't let the name fool you: type here is a value. We've got a few blanks to fill in, but this declaration at least has the great advantage of not going against TypeScript's design principles!

Every value has a type. So what's the type of HTMLButtonElement in the previous function call? It's typeof HTMLButtonElement, of course! You can see what this really is by hovering over T in the following sample:

type T = typeof HTMLButtonElement;
// type is {
// new (): HTMLButtonElement;
// prototype: HTMLButtonElement;
// }

This definition comes from lib.dom.d.ts. It says that you can new an instance of typeof HTMLButtonElement to get an instance of the HTMLButtonElement type. The other DOM element classes (such as HTMLElement) have similar types.

We can use this to place a constraint on the generic parameter, T:

function queryElement<T extends typeof Element>(
container: Element,
type: T,
selector: string,
): ??? {
// ...
}

The Element in T extends typeof Element refers to a value, but typeof Element is a type. If HTMLButtonElement is a subtype of Element, then typeof HTMLButtonElement is a subtype of typeof Element. Passing in HTMLButtonElement will work, but passing in RegExp or console won't. And by using a generic parameter, we'll be able to infer a precise type based on the value of the type parameter at the call site.

function queryElement<T extends typeof Element>(
container: Document | Element,
type: T,
selector: string,
): InstanceType<T> {
const el = checkedQuerySelector(container, selector);
if (!(el instanceof type)) {
throw new Error(
`Selector ${selector} matched ${el} which is not an ${type}`
);
}
return el as InstanceType<T>;
}

And this works exactly as you'd hope:

const el = queryElement(
document, HTMLTextAreaElement, 'textarea.deep-thoughts'
);
alert(el.value); // ok, el's type is HTMLTextAreaElement

(If you know how to get rid of the type assertion on the return line, please let me know!)

If you preferred the syntax of the explicit generic argument, where the Element subtype is more clearly separated from the usual querySelector parameters, you can get close by currying:

const curryQueryElement = <T extends typeof Element>(
type: T,
) => (
container: Document | Element,
selector: string,
) => queryElement(container, type, selector);

const el = curryQueryElement(HTMLTextAreaElement)(
document, 'textarea.deep-thoughts'
);

// Or:
const textareaQuery = curryQueryElement(HTMLTextAreaElement);
const el2 = textareaQuery(document, 'textarea.deep-thoughts');

If you ever find yourself wanting to use a TypeScript type at runtime, 🛑STOP 🛑! Remember that TypeScript types get erased when your code is converted to JavaScript, leaving only the values. But if your goal is to check the runtime type and narrow the TypeScript type, there may still be hope. Types and values are a one-way street: you can go from a value to a type (with typeof), but not from a type to a value. So the solution is often to rework your API to pass around values and derive the types from those. The safe querySelector we've built in this post is one example of doing that. In future posts we'll look at a few more examples of how this strategy can play out in practice.

Related material:

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »