Search code examples
javascripttypescriptdomdeno

Why does getAttribute not exist on type "Node"?


I'm converting some Javascript I wrote into a Deno TypeScript module. The script uses deno-dom to get all the "a[href]" links off of a page, and returns a list of links. The script is working correctly, however, when I test the code using deno test, it complains

"TS2339 [ERROR]: Property 'getAttribute' does not exist on type 'Node'"

My understanding is that the Element type is a "type" of the Node type - but my attempts at intelligently declaring this has failed. What am I misunderstanding here?

Here's the full function in question:

import { DOMParser } from "https://deno.land/x/deno_dom/deno-dom-wasm.ts";

export async function getAllLinks(
  url: string,
  slice = 5,
): Promise<{ source: string; links: string[] }> {
  try {
    const response = await fetch(url);
    const content: string = await response.text();

    // Parse the HTML content using JSDOM
    const document = new DOMParser().parseFromString(content, "text/html");

    if (document === null) throw new Error("Failed to parse HTML");
    const baseUrl = response.url;

    // Find all links on the page
    const linkSelector = document.querySelectorAll(
      'a[href^="http:"], a[href^="https:"]',
    );

    if (linkSelector === undefined || linkSelector === null){
      throw new Error("No links found");
    }
    const links = Array.from(linkSelector, (link: Element) => link.getAttribute("href"))
      .map((link: string) => {
        // Convert relative links to absolute URLs
        const absoluteURL = new URL(link, baseUrl);
        return absoluteURL.href;
      })
      .filter((link: string) => {
        // Filter out links that are on the same domain as the URL
        const parsedUrl = new URL(url);
        const parsedLink = new URL(link);
        return parsedUrl.hostname !== parsedLink.hostname;
      });

    return {
      source: baseUrl,
      links: links.slice(0, slice),
    };
  } catch (error) {
    console.error(error);
    throw error;
  }
}

Thanks.

Edit: Declaring the (link) as (link: Element) causes the prior linkSelector before it in the Array.from to complain of this error:

No overload matches this call.
  Overload 1 of 4, '(iterable: Iterable<Element> | ArrayLike<Element>, mapfn: (v: Element, k: number) => string | null, thisArg?: any): (string | null)[]', gave the following error.
    Argument of type 'NodeList' is not assignable to parameter of type 'Iterable<Element> | ArrayLike<Element>'.
      Type 'NodeList' is not assignable to type 'ArrayLike<Element>'.
        'number' index signatures are incompatible.
          Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 127 more.  Overload 2 of 4, '(arrayLike: ArrayLike<Element>, mapfn: (v: Element, k: number) => string | null, thisArg?: any): (string | null)[]', gave the following error.
    Argument of type 'NodeList' is not assignable to parameter of type 'ArrayLike<Element>'.deno-ts(2769)

And also breaks the next lines (link: string) declaration with this complaint:

argument of type '(link: string) => string' is not assignable to parameter of type '(value: string | null, index: number, array: (string | null)[]) => string'.
  Types of parameters 'link' and 'value' are incompatible.
    Type 'string | null' is not assignable to type 'string'.
      Type 'null' is not assignable to type 'string'.deno-ts(2345)

What makes this all the more confusing is that the code still executes completely fine no matter my chosen type declarations.


Solution

  • This is an outstanding issue in the deno_dom library, and it continues to persist (at least as I write this answer — it's at version 0.1.38). See the following GitHub issue for more information:

    b-fuze/deno-dom#4 — bug: querySelectorAll returns NodeListOf<Node> instead of NodeListOf<ELement>

    That thread contains all of the information and linked resources, but here's the summary:

    deno_dom doesn't fully or correctly implement the DOM spec, and its types also don't align with the DOM types from TypeScript.

    In the TS DOM lib, the return type of document.querySelectorAll is a NodeListOf<E>, where E is a generic parameter constrained to Element, which is the interface that implements the getAttribute method.

    In deno_dom, the return type of document.querySelectorAll is NodeList, which is an iterable of Node (which doesn't include the getAttribute method — all Elements are Nodes, but not the other way around).

    The actual elements of the NodeList are indeed Elements, but just aren't typed that way, so — until the library is fixed — you'll have to use a type assertion to inform the compiler of the situation. Here's a self-contained example:

    example.ts:

    import { assert } from "https://deno.land/std@0.193.0/testing/asserts.ts";
    
    import {
      DOMParser,
      type Element,
    } from "https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts";
    
    const html = `
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8" />
      <title>Ice cream flavors</title>
    </head>
    <body>
      <h1>Ice cream</h1>
      <h2>Basic flavors</h2>
      <ul class="flavors basic">
        <li data-flavor="vanilla">Vanilla</li>
        <li data-flavor="chocolate">Chocolate</li>
        <li data-flavor="strawberry">Strawberry</li>
      </ul>
    </body>
    </html>
    `;
    
    const document = new DOMParser().parseFromString(html, "text/html");
    //    ^? const document: HTMLDocument | null
    assert(document, "The document could not be parsed");
    
    const flavorElements = document.querySelectorAll(
      "ul.flavors.basic > li",
    ) as Iterable<Element>; /*
      ^^^^^^^^^^^^^^^^^^^^
    Use a type assertion here */
    
    const flavors = [...flavorElements].map((element) => {
      const flavor = element.getAttribute("data-flavor");
      //    ^? const flavor: string | null
      assert(flavor, "Flavor attribute not found");
      return flavor;
    });
    
    console.log(flavors); // ["vanilla", "chocolate", "strawberry"]
    
    

    In the terminal:

    % deno --version
    deno 1.35.0 (release, aarch64-apple-darwin)
    v8 11.6.189.7
    typescript 5.1.6
    
    % deno run --check example.ts
    Check file:///Users/deno/example.ts
    [ "vanilla", "chocolate", "strawberry" ]
    

    For comparison, here's the equivalent code in the TypeScript Playground.