Expected TypeScript to Enforce True "Node" Instance Assignment, but Getting Property Missing Error Instead

45 views Asked by At

Original Question:

I'm trying to type a variable in TypeScript such that it should only hold an actual instance of a Node (or a derived type, e.g., TextNode, HTMLElement, etc.) and not just any object that matches the shape of a Node.

Here's a simple example of the issue I'm facing:

// Create a node instance by initializing it with a new text node.
let nodeInstance: Node = document.createTextNode('Hello World!');

// Attempting to reassign nodeInstance with a plain object 
// that mimics having an `addEventListener` method.
// (Expected to fail TypeScript type-checking since it isn't a genuine DOM Node)
nodeInstance = {
    addEventListener(type, callback, options) {        
        // This method is a mock and doesn't actually perform any event listening.
    }
};

When assigning an object with a matching shape (in this case, addEventListener method) to the nodeInstance variable, I expect TypeScript to throw an error since the object is not a real instance of Node. Instead, TypeScript gives the following error:

Type '{ addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options: boolean | AddEventListenerOptions | undefined): void; }' is missing the following properties from type 'Node': baseURI, childNodes, firstChild, isConnected, and 45 more.

This error suggests TypeScript is checking the shape of the object, rather than whether it's a true instance of Node or one of its derived types.

Is there a way to type the nodeInstance variable so that TypeScript will only accept real instances of Node (or its subclasses) and not just objects with a similar shape?

Thank you in advance for any insights!


Edit/Update:

Thanks to @MatthieuRiegler for pointing out the nuances with structural typing. To provide more context, here's a more detailed example:

My challenge is how TypeScript treats the Node type within the context of the elementFactory function I've created. The function I've written is designed to generate DOM elements based on the provided tag name, while also handling configurations or child nodes as its arguments. However, due to the current type setup, TypeScript allows passing in properties of both Node and HTMLElementConfigMap[TagName], which isn't ideal.

Here's a refined version of my function and its usage:

function elementFactory<TagName extends keyof HTMLElementTagNameMap>(tagName: TagName) {
    // Type to handle CSS selectors or configurations
    type ElementConfig = (`.${any}` | `#${any}` | `[${any}`) | HTMLElementConfigMap[TagName];

    // Factory function
    return function(configOrNode?: ElementConfig | Node, ...additionalNodes: Node[]): HTMLElementTagNameMap[TagName] {
        const cssSelector = typeof configOrNode === 'string' ? configOrNode : '';
        const config = FormatValidator.isPlainObject(configOrNode) ? configOrNode as HTMLElementConfigMap[TagName] : undefined;
        const element = createElement(`${tagName}${cssSelector}`, config);
        //createElement
        
        // Collect child nodes
        const childNodes = [...((!configOrNode || cssSelector || config) ? [] : [configOrNode as Node]), ...additionalNodes];
        element.append(...childNodes);

        return element as HTMLElementTagNameMap[TagName];
    };
}

export const Div = elementFactory('div');
export const Video = elementFactory('video');
export const Button = elementFactory('button');
export const Input = elementFactory('input');
// ... more exports as needed

// Example usage
const videoContainer = Div(
    {
        className: 'video-container'
    },
    Video(
        { src: this.src, type: 'video/mp4' },
        Div(
            { className: 'controls bottom' },
            Button({
                type: 'button',
                text: 'Play',
                style: { fontSize: '25px' }
            }),
            Input({ type: 'range', min: '0', max: '1', step: '0.1' })
        )
    )
);

Given this setup, I'm facing a dilemma where properties of Node and HTMLElementConfigMap[TagName] can be interchangeably used, which can lead to confusion. How might I resolve this to strictly enforce the distinctions between a node and a configuration object?"

For a deeper understanding, here's my createElement function which I have used in my elementFactory function above:

/** 
 * Create a new element with specified configurations.
 * 
 * @param descriptor - The tag descriptor string (e.g., 'div#id.class[attr=value]').
 * @param config - The configuration object to define element properties.
 */
function createElement<Descriptor extends string>(descriptor: Descriptor, config?: HTMLElementConfigMap[ResolvedTagName<Descriptor>]): HTMLTypeFromSelector<Descriptor>;
function createElement<TagName extends keyof HTMLElementTagNameMap>(descriptor: TagName, config?: HTMLElementConfigMap[TagName]): HTMLElementTagNameMap[TagName];
function createElement(descriptor: string, config?: any): HTMLElement {
    // Parse the descriptor to extract tag, classes, ID, and attributes.
    const { tag, class: classes, id, attrs } = parseTagDescriptor(descriptor);

    // Create a new element based on the parsed tag.
    const element = document.createElement(tag);

    // Define custom configuration handlers.
    const customConfigHandlers: { [key: string]: (value: any) => void } = {
        attributes(attributes) {
            for (const [key, value] of Object.entries(attributes)) {
                element.setAttribute(key, value);
            }
        },
        '[]': function (attributes) {
            this.attributes(attributes);
        },
        '.': className => element.className = className,
        '#': id => element.id = id,
        html: htmlContent => element.innerHTML = htmlContent,
        text: textContent => element.innerText = textContent,
        style(styles) {
            for (const [propertyName, propertyValue] of Object.entries(styles)) {
                (element.style as any)[propertyName] = propertyValue;
            }
        },
        fallbackSrc(fallbackImageSource) {
            if (fallbackImageSource) {
                element.addEventListener('error', handleImageError);
            }

            function handleImageError() {
                element.removeEventListener('error', handleImageError);
                if (fallbackImageSource) {
                    (element as HTMLImageElement).src = fallbackImageSource;
                }
            }
        }
    };

    // Add classes, if any.
    if (classes.length > 0) {
        element.classList.add(...classes);
    }

    // Set ID, if specified.
    if (id.length > 0) {
        element.id = id.join(' ');
    }

    // Set attributes, if any.
    for (const [attr, value] of Object.entries(attrs)) {
        element.setAttribute(attr, value);
    }

    // Apply configurations.
    if (config) {
        for (const [key, value] of Object.entries(config)) {
            if (key in customConfigHandlers) {
                customConfigHandlers[key](value);
            } else if (findPropertyDescriptor(element, key)?.set) {
                (element as any)[key] = value;
            }
        }
    }

    return element;
}

With the above createElement function, I'm trying to enforce a clear distinction between properties of Node and HTMLElementConfigMap[TagName] for elements. Given the context and the setup, how can I best ensure TypeScript strictly enforces the distinctions between a node and a configuration object within the elementFactory function and its returned functions?

Thank you for any insights or solutions to this challenge.

0

There are 0 answers