parse5 icon indicating copy to clipboard operation
parse5 copied to clipboard

Are TypeScript types wrong or are we all doing something wrong?

Open ericmorand opened this issue 1 month ago • 3 comments

I've been using parse5 for months with TypeScript and I always had to fight with the typings, assuming I was doing something wrong but unable to understand what. Today I took the time to test the code samples that are shipped with the typings, for example:

https://github.com/inikulin/parse5/blob/0abd4406ae5b518a06b5df3d61bc332fc51887c1/packages/parse5/lib/serializer/index.ts#L56

And the code sample itself doesn't satisfy the typechecker:

import * as parse5 from 'parse5';

const document = parse5.parse('<!DOCTYPE html><html><head></head><body>Hi there!</body></html>');

// Serializes a document.
const html = parse5.serialize(document);

// Serializes the <html> element content.
const str = parse5.serialize(document.childNodes[1]); // TS2345: Argument of type ChildNode is not assignable to parameter of type ParentNode

console.log(str); //> '<head></head><body>Hi there!</body>'

I feel reassured - I may not be doing anything wrong - and concerned at the same time: parse5 is written in TypeScript. I'm not sure I understand how typings could be wrong if the code can be tested and built.

Note that the above code works perfectly fine once compiled into JavaScript.

ericmorand avatar Nov 25 '25 09:11 ericmorand

the childNodes will be of type ChildNode, but not all child nodes are parent nodes (e.g. a text node can be a child but not a parent).

the type system can't know what is in your HTML string so its upto you to narrow that union.

usually you'd do this with the type guards of the tree adapter, for example:

const node = document.childNodes[1];
if (parse5.defaultTreeAdapter.isElementNode(node)) {
  // node is now a ParentNode
}

or because your HTML is static, just cast it to parse5.DefaultTreeAdapterTypeMap['element']

43081j avatar Nov 25 '25 09:11 43081j

My point is why is the seralize method only able to serialize a ParentNode - or, more acurately, why is it declared as such - even though it can serialize an Element without any issue?

Actually, it can serialize something as soon as it implements a very narrow subset of what an Element is. For example, the following code works:

serialize({
  tagName: 'foo'
});

And of course, this works too:

serialize({
  tagName: 'foo',
  childNodes: [
    {
      tagName: 'bar',
      attrs: [{
        name: 'data-foo',
        value: '5'
      }]
    }
  ]
}); // <bar data-foo="5"></bar>

Shouldn't the types expose that fact? What is the point of exposing types that are more restrictive than the actual implementation? If tagName is the only required property, and everything else is optional, then why is the type not reflecting this?

ericmorand avatar Nov 25 '25 10:11 ericmorand

an Element is a ParentNode

its a union, like Element | Document | ...

just the same, Node is a union like Element | TextNode | Document | ...

you can't currently serialize a text node, the serializer expects the root node to be a parent node (element, document, etc)

the types reflect this by defining that you can only serialize nodes which can have children (ParentNode)

43081j avatar Nov 25 '25 11:11 43081j