The querySelector
method lets you retrieve DOM elements from JavaScript using CSS selectors:
|
This API is ubiquitous in JavaScript, but it presents some challenges in a TypeScript context. For example:
|
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:
|
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:
|
You can also use the optional chaining operator to allow undefined
s to bubble up:
|
But null
isn't the only problem. You might also get a type that's not specific enough. For example:
|
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:
|
Or you can use an instanceof
check to narrow the type:
|
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:
|
You can swap this in for any use of querySelector
to exclude null
:
|
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:
|
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:
|
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:
|
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
:
|
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.
|
And this works exactly as you'd hope:
|
(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:
|
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:
- Item 3: "Understand That Code Generation Is Independent of Types" or Evan Martin's TypeScript's type independent output.
- Item 8: "Know How to Tell Whether a Symbol Is in the Type Space or Value Space" or Florian Reuschel's TypeScript's Secret Parallel Universe
- Item 55: "Understand the DOM hierarchy"