Home > Enterprise >  How do I extend an interface or type while adding a property at the same time without using ts-ignor
How do I extend an interface or type while adding a property at the same time without using ts-ignor

Time:12-18

I'm trying to extend an HTMLElement by adding a property to it.

type HTMLElementWeighted = HTMLElement & {weight : number}

function convertElementToWeighted(element : HTMLElement, weight : number) : HTMLElementWeighted {
  element.weight = weight // <-- TS screams at me here because weight is not part of element
  return element
}

I know this can be overcome using @ts-ignore or as but I really want to do this the proper way. It feels hacky otherwise.

CodePudding user response:

In general, you can create an object with a new property without altering the existing object by spreading it into a new one.

// DON'T DO THIS
function convertElementToWeighted(element : HTMLElement, weight : number) : HTMLElementWeighted {
    return { ...element, weight };
}

This works well for plain objects, and happens to make TypeScript happy here - but for HTMLElements, it's almost certainly a bad idea because the returned object no longer references the actual element in the DOM, which other parts of the script may be expecting. The code above will only extract enumerable own properties from the element, and it may not even have any properties in the resulting JavaScript. (TypeScript does not take into consideration enumerable vs non-enumerable, or own vs inherited properties.)

So

  • Mutating objects to add properties that don't exist on them to begin with is usually a bad idea (it's doable in TypeScript, but it would change the type of all HTMLElements, so I wouldn't recommend it)
  • Attempting to clone the element to add the new property likely won't work. (While you could use a DOM method to clone the element, it won't refer to the original element.)

Best to leave the element as-is, and return a new data structure that includes the element and whatever other data you want. For example, you could have:

function convertElementToWeighted(element: HTMLElement, weight: number) {
  return {
    element,
    weight,
  };
}

For greater flexibility, use generics to preserve the specific type of the passed element if there is one. (For example, if you pass in an HTMLAnchorElement, it'd be nice for the returned type to have a type of HTMLAnchorElement instead of the less useful `HTMLElement)

function convertElementToWeighted<T extends HTMLElement>(element: T, weight: number) {

CodePudding user response:

If your function is going to mutate its argument by adding a property to it return the mutated object, then you can write this fairly simply with the Object.assign() method to assign the property, which sidesteps the issue of using a type assertion. It even returns the intersection you want:

function convertElementToWeighted(
  element: HTMLElement, weight: number
): HTMLElementWeighted {
  return Object.assign(element, { weight }); // okay
}

While we're at it, we might as well make it generic so that any subtype of HTMLElement stays assignable to that same subtype:

type HTMLElementWeighted<T extends HTMLElement> = T & { weight: number }

function convertElementToWeighted<T extends HTMLElement>(
  element: T, weight: number
): HTMLElementWeighted<T> {
  return Object.assign(element, { weight });
}

Now we can test it:

const img = document.createElement("img");
img.weight; // error, weight does not exist on HTMLImageElement
const imgW = convertElementToWeighted(img, Math.PI);
imgW; // const imgW: HTMLElementWeighted<HTMLImageElement>
console.log(imgW.weight.toFixed(2)) // "3.14"
console.log(imgW.width); // 0

The compiler sees imgW as HTMLElementWeighted<HTMLImageElement>, so you're allowed to access its weight as well as its HTMLImageElement-specific properties and methods.

The only downside I see here is that after the call to convertElementToWeighted() you are essentially required to throw away your original img reference and use the imgW return value if you want to see the weight property. They're both the same object at runtime, and as such the weight property exists on both of them, but the compiler does not see img as having changed its type at all. Maybe this works for you, but it is a caveat.


On the other hand, if you don't want to use the return value and would rather narrow the function argument's apparent type, you could make convertElementToWeighted() an assertion function. For example:

function convertElementToWeighted<T extends HTMLElement>(element: T, weight: number
): asserts element is HTMLElementWeighted<T> {
  Object.assign(element, { weight });
}

Instead of returning anything, the function is annotated to return the assertion predicate asserts element is HTMLElementWeighted<T>. Let's test it:

const img = document.createElement("img");
img.weight; // error, weight does not exist on HTMLImageElement
convertElementToWeighted(img, Math.PI);
img; // const img: HTMLElementWeighted<HTMLImageElement>
console.log(img.weight.toFixed(2)) // "3.14"
console.log(img.width); // 0

You can see that before the call to convertElementToWeighted, the compiler is unhappy about img.weight, but afterward it sees img as type HTMLElementWeighted<HTMLImageElement> and allows you to access its weight property while still treating it like an HTMLImageElement. The same reference, img, is used throughout.

Note that assertion functions are not allowed to return anything, so you have to choose between whether you want to use the function's argument or its return value after you call the function; you can't have both.

Playground link to code

  • Related