TypeScript Contribution Diary: Improved errors for empty DOM interfaces
June 22, 2021
File copied from Wikipedia under the Creative Commons Attribution 2.0 Generic license. [source]
Problem Statement
TypeScriptâs
tsconfig.json
compiler
options allow us to specify what global type definitions to
compile with. They default to the built-in
"dom"
typings as well as
whatever your
"target"
compiler option
specifies:
{
"compilerOptions": {
// implicit:
// "lib": ["dom", "es2018"],
"target": "es2018"
}
}
You might, for example, ask to have
"dom"
and
"es2018"
global types
included regardless of your target:
{
"compilerOptions": {
"lib": ["dom", "es2018"]
}
}
These get a little tricky.
"dom"
is included in the
default settings when you donât specify your own
"lib"
, but not if you
specify a custom one without it. If you donât include
"dom"
in the list, type
definitions for global DOM elements such as
HTMLElement
wonât be
included.
{
"compilerOptions": {
// DOM types won't be included by default now!
"lib": ["es2018"]
}
}
Most projects for code that run in the browser will generally
want to include "dom"
.
Not including
"dom"
makes sense for
projects that execute in a context without the DOM, such as
Deno or Node. You wouldnât want someone thinking they have
access to global values such as
document
if they might not
exist.
Some library typings -including the ever-popular
@types/react
- include stub
interfaces so that they can be included in non-DOM projects
and still refer to those element types.
interface Element {}
interface DocumentFragment {}
interface HTMLElement extends Element {}
interface HTMLAnchorElement extends HTMLElement {}
Per TypeScript issue #33907 Give better error messages when properties are accessed on global empty DOM interfaces and its referenced Tweet it would sure be nice to have a better error message in TypeScript for the case of a developer trying to access a member of an object of one of those blank types!
function logElementContent(element: HTMLElement) {
console.log(element.textContent);
// ~~~~~~~~~~~
// Property 'textContent' does not exist on type 'HTMLElement'.
// Did you forget to include "dom" in your "lib" setting?
}
Prior Work
Turns out a Kyle Lin (@kylejlin) had already started a solution to #33907: PR #34407 Improve error messages for empty DOM interface property access.
It worked in the following manner:
-
Whenever the
Property '{0}' does not exist on type '{1}'.
diagnostic message would be logged: -
If the containing type seemed to be an empty DOM interface
per an
isEmptyDomInterface
function, -
Use the new diagnostic message that adds
"Try changing the 'lib' compiler option to include 'dom'."
.
function isEmptyDomInterface(type: Type): boolean {
return (
isEmptyObjectType(type) &&
/\bHTML\w*Element\b(?![:"])/.test(typeToString(type))
);
}
Confused by that regular expression? You and me both! Skip to the bottom of this post for an explanation.
I tried out that branch locally and ran it on some violating code.
interface HTMLInputElement {}
let element: HTMLInputElement;
element.textContent;
// ~~~~~~~~~~~
// Property 'textContent' does not exist on type 'HTMLInputElement'. Try changing the `lib` compiler option to include 'dom'.
Looks like it already worked great! Thanks Kyle!
Prior Feedback
Kyleâs PR had been given feedback but never addressed it
before aging away. The main changes requested were around that
isEmptyDomInterface
function:
- Rewriting to not use a regular expression
-
Using
type.symbol.escapedName
instead of the more expensivetypeToString
I really wanted this error message improvement. Time for me to dig in! đȘ
A New PR
Compiler Options
Looking over the original PR, I noticed that the
isEmptyDomInterface
function didnât check what the compiler options for
lib
were specified as. I
figured itâd be a good idea to add in logic to restrict the
new error message on the off chance that someone with
"dom"
in their
lib
was making their own
custom interfaces that happened to match the name matching.
Question: how to check compiler options in
checker.ts
?
I ran a text search for
compiler.*options
in the file and found a bunch of references to a
compilerOptions
object.
Good good.
function isEmptyDomInterface(type: Type): boolean {
return (
compilerOptions.lib &&
!compilerOptions.lib.includes("dom") &&
// ...
);
}
Union and Intersection Types
The original PR used a
typeToString
method to
convert the original container typeâs name to a usable string,
but I was worried that that strategy was inefficient and/or
didnât work at all for union and intersection types. For
example, this type should still trigger the complaint, even
though its name string is complex:
interface HTMLAreaElement {}
interface HTMLAudioElement {}
interface HTMLBaseElement {}
let element: HTMLAreaElement | (HTMLAudioElement & HTMLBaseElement);
element.textContent;
I ran some text searches for names like
every.*type
and
didnât see any existing utility function to check that every
contained type in a union or intersection matches a function.
So I wrote my own:
function everyContainedType(type: Type, f: (t: Type) => boolean): boolean {
return type.flags & TypeFlags.UnionOrIntersection
? every((type as UnionOrIntersectionType).types, f)
: f(type);
}
Now that I was testing against individual type names, I simplified the regular expression a bit:
function containerSeemstoBeEmptyDomElement(containingType: Type): boolean {
return (
compilerOptions.lib &&
!compilerOptions.lib.includes("dom") &&
everyContainedType(
containingType,
(type) =>
type.symbol &&
/^(EventTarget|Node|((HTML[a-zA-Z]*)?Element))$/.test(
type.symbol.escapedName.toString()
)
) &&
checker.getPropertiesOfType(containingType).length === 0
);
}
Confused by that regular expression? Skip to the bottom of this post for an explanation!
Asking Questions
Comparing my notes to how the original PR implemented empty
element detection, I wasnât sure what the best way to
determine whether an element is empty. The original PR used
isEmptyObjectType
but Iâd
previously seen a
getPropertiesOfType
function and used it in my own function.
I also didnât understand the request to not use a regular
expression. It seemed reasonable to me that weâd use some kind
of string-based logic to detect names matching
HTML*Element
.
I sent a new PR and asked about the regular expressions and empty object detection.
Peer Reviews
Naming
The first review feedback I got was pretty easy to resolve. Do you see the difference?
- containerSeemstoBeEmptyDomElement(containingType)
+ containerSeemsToBeEmptyDomElement(containingType)
đ€Š.
Getting Answers
The responses to my questions were pretty straightforward:
- Using a regular expression is fine; the original PRâs one was just a bit more complicated than it needed to be
-
isEmptyObjectType
is the right way in this situation to check for an empty object type
It was
also mentioned
that I didnât need to call
toString()
on
type.symbol.escapedName
,
since thatâs already a string.
TypeScript uses a
__String
type internally to refer to strings whose leading underscore have been escaped by adding extra leading underscores. See the Basarat article on Nominal Typing for more details.
I used
unescapeLeadingUnderscores
for Ultimate Correctness to sanitize that string.
Applying all the feedback:
function containerSeemsToBeEmptyDomElement(containingType: Type): boolean {
return (
compilerOptions.lib &&
!compilerOptions.lib.includes("dom") &&
everyContainedType(
containingType,
(type) =>
type.symbol &&
/^(EventTarget|Node|((HTML[a-zA-Z]*)?Element))$/.test(
unescapeLeadingUnderscores(type.symbol.escapedName)
)
) &&
isEmptyObjectType(containingType)
);
}
Confused by that regular expression? Skip to the bottom of this post for an explanation!
Diagnostic Messages Quotes
The original proposed diagnostic message addition looked like this:
"... Try changing the `lib` compiler option to include 'dom'."
One piece of feedback requested using a consistent quoting style. It was noted that other diagnostic messages donât have a consistent style; for now, we went with single quotes consistently.
"... Try changing the 'lib' compiler option to include 'dom'."
#43503 Error messages inconsistently use backticks and single quotes was filed to later resolve the general inconsistencies.
Wrapping Up
I pushed the requested adjustments in the PR and re-requested review. It was merged in within a day. Yay! đ
Thanks to @kylejlin for sending the first version of the PR and to @DanielRosenwasser and @sandersn for the PR reviews (as well as introducing me to the excellent phrase âUltimate Correctnessâ)!
Regular Expressions, Explained
Regular expressions can be tough to read if youâre not familiar with the syntax! A regular expression, or âregexpâ for short, is a description of what characters to check for, in order, from a string.
I like the MDN Regular Expressions article; the Codecademy Learn the Basics of Regular Expressions course is a solid intro if you want to be more hands-on and guided.
My Regular Expression
/^(EventTarget|Node|((HTML[a-zA-Z]*)?Element))$/;
First off, ^
indicates the
beginning of a string and
$
indicates the
end of a string. Using them at the beginning and end
of the regular expression means this will only match an
entire string: not a partial subset. For this regexp
or a simpler one like
^Node$
,
BlahNodeBlah
doesnât match
but Node
does.
Next, this regular expression shows off
groups and ranges. The |
pipes indicate
that the content must be any of:
EventTarget
Node
-
(HTML[a-zA-Z]*)?Element
That last one is what matches
Element
,
HTMLElement
, and all other
names such as
HTMLAudioElement
that have
characters between HTML
and
Element
:
-
[a-zA-Z]
indicates we can have any character in the alphabet betweena-z
orA-Z
-
*
indicates we want the previous character (here, the alphabet ones) to be allowed to repeat >=0 times -
The
?
in that last option means anything before it is optional
Putting them all together, this regular expression is another
way of testing for the name
EventTarget
,
Node
,
Element
,
HTMLElement
, or
HTML
+ (some alphabet
characters) + Element
.
The Original Regular Expression
/\bHTML\w*Element\b(?![:"])/;
-
Per
MDNâs Assertions article,
\b
matches a word boundary -
Per
MDNâs Character Classes article,
\w
matches any alphanumeric character from the basic Latin alphabet, including the underscore. -
?!
is another assertion: it matches only if the following characters are not what comes next in the string.
Putting them all together, this was another way of testing that the type has, in order:
HTML
- Any number of alphabetical characters
Element
-
Not
:
or"
at the end