0

I am experiencing the following error in the very last line of this function: "Cannot assign to 'ATTRIBUTE_NODE' because it is a read-only property."

I have tried using the Object.getOwnPropertyDescriptor method to utilize a guard clause but TypeScript still can't determine if I am always accessing a readOnly property or not. I need to access the "innerText" property most of the time but sometimes I also need to access the "src" property to dynamically get an image, that's why I am using the index method. Is this a bad practice or is there a fix?

function fillData(selector: string, property: string, data: string, parentElem: HTMLElement){
  const targetElem = parentElem.querySelector(`[data-${selector}]`) as HTMLElement
  
  if (!property) return

  targetElem[property as keyof typeof targetElem] = data
}
Fexxix
  • 13
  • 5
  • remove as keyof typeof targetElem – Tachibana Shin Feb 24 '23 at 02:37
  • @TachibanaShin Then it will just no longer compile. – Robby Cornelissen Feb 24 '23 at 02:38
  • 1
    @TachibanaShin that just redirects to a new error "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'HTMLElement'. No index signature with a parameter of type 'string' was found on type 'HTMLElement'." – Fexxix Feb 24 '23 at 02:41
  • Does using constrained generic parameters like [this](https://tsplay.dev/wQ2LVN) meet your needs? If so, I can write it up as an answer. If not, what am I missing? – jsejcksn Feb 24 '23 at 02:52
  • @jsejcksn you are welcomed to try. – Fexxix Feb 24 '23 at 02:59
  • The property "src" exists for img but not for `Element`s in general – qrsngky Feb 24 '23 at 02:59
  • @qrsngky that is not the issue here, the problem is that typescript can't determine whether or not the property I'm assigning a value to is read only or not – Fexxix Feb 24 '23 at 03:06
  • Assuming that 'data' is always string, basically you want something like `targetElem[property as ElementStringValueKeys & WritableKeys]`. (Strictly speaking it misses the case for 'src', though. But you're telling the compiler to ignore it) – qrsngky Feb 24 '23 at 03:24
  • Try using `ElementStringValueKeys` from jsejcksn's demo and `WritableKeys` from the answer of https://stackoverflow.com/questions/49579094/typescript-conditional-types-filter-out-readonly-properties-pick-only-requir and see if that's what you want – qrsngky Feb 24 '23 at 03:27

1 Answers1

1

This is fundamentally not checkable by the compiler because the type of DOM element that you want to mutate doesn't exist at compile time and can't be inferred — so you'll have to annotate/assert the type of element that you expect to be selected by your selector.

With that (quite large) caveat out of the way, here's how you can do it:

Note: the WritableKeys utility is borrowed from the answers here and here.


For reference, here are the type utilities used in the code below:

type IfEquals<X, Y, A = X, B = never> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? A : B;

type WritableKeys<T> = {
  [P in keyof T]-?:
    IfEquals<
      { [Q in P]: T[P] },
      { -readonly [Q in P]: T[P] },
      P
    >
}[keyof T];

type KeysByValue<T, V> = keyof {
  [
    K in keyof T as
      T[K] extends V ? K : never
  ]: unknown;
};

By using two constrained generic type parameters with your function, you can achieve the desired result:

TS Playground

function fillData<
  T extends Element,
  K extends Extract<KeysByValue<T, string>, WritableKeys<T>>
>(selector: string, property: K, data: T[K], parentElem: HTMLElement): void {
  const targetElem = parentElem.querySelector<T>(`[data-${selector}]`);
  if (!targetElem) return;
  targetElem[property] = data;
}

declare const parentElement: HTMLElement;

fillData("key", "className", "my-class", parentElement); // Ok
// no generic used, so inferred as Element - "className" exists on Element

fillData("key", "src", "ok", parentElement); // Error
//              ~~~~~
// no generic used, so inferred as Element - "src" doesn't exist on Element

fillData<HTMLImageElement, "src">("key", "src", "img.jpg", parentElement); // Ok
//       ^^^^^^^^^^^^^^^^  ^^^^^
// but it's repetitive: an explicit generic type must be provided for the element AND the property

fillData<HTMLImageElement, "alt">("key", "src", "img.jpg", parentElement); // Error
//                                       ~~~~~
// property value doesn't match the explicitly provided generic

fillData<HTMLImageElement, "loading">("key", "loading", "ok", parentElement); // Error (expected )
//                                                      ~~~~
// Argument of type '"ok"' is not assignable to parameter of type '"eager" | "lazy"'.(2345)

fillData<HTMLDivElement, "src">("key", "src", "my-class", parentElement); // Error (expected )
//                       ~~~~~
// "src" doesn't exist on HTMLDivElement

However, as you can see in the commented usage examples, it's not very DRY because it requires repeating the property twice — once as a type parameter and once as the actual value. TypeScript theoretically has the capability of partial inference, but it is not yet an implemented feature. For more info, see this GitHub issue: microsoft/TypeScript#10571 - Allow skipping some generics when calling a function with multiple generics

So, while I think this answers your question... can it be improved? I think an alternate pattern could look something like this:

TS Playground

function selectByData<T extends Element>(
  selector: string,
  parent: ParentNode = document,
): {
  set: <K extends Extract<KeysByValue<T, string>, WritableKeys<T>>>(
    property: K,
    value: T[K],
  ) => void;
} {
  const element = parent.querySelector<T>(`[data-${selector}]`);
  return { set: element ? ((k, v) => element[k] = v) : (() => {}) };
}

declare const parentElement: HTMLElement;

selectByData("key", parentElement).set("className", "my-class"); // Ok
// no generic used, so inferred as Element - "className" exists on Element

selectByData("key", parentElement).set("src", "ok"); // Error
//                                     ~~~~~
// no generic used, so inferred as Element - "src" doesn't exist on Element

selectByData<HTMLImageElement>("key", parentElement).set("src", "img.jpg"); // Ok
//           ^^^^^^^^^^^^^^^^
// now, only the generic type for the element must be provided

selectByData<HTMLImageElement>("key", parentElement).set("loading", "ok"); // Error (expected )
//                                                                  ~~~~
// Argument of type '"ok"' is not assignable to parameter of type '"eager" | "lazy"'.(2345)

selectByData<HTMLDivElement>("key", parentElement).set("src", "my-class"); // Error (expected )
//                                                     ~~~~~
// "src" doesn't exist on HTMLDivElement

selectByData<HTMLDivElement>("key", parentElement).set("tagName", "my-class"); // Error (expected )
//                                                     ~~~~~~~~~
// "tagName" exists on HTMLDivElement, but isn't writable

In this alternate version, the work is done in two steps:

The initial function is invoked using two arguments:

  1. the partial data attribute name as the selector, and
  2. optionally, the parent node (if not provided, document is used as the default)

The return value of that function is an object with a set method which can be invoked using the

  1. writable property name, and
  2. corresponding value.

If the child element was not found in the first invocation, the setter function that's returned is simply a no-op — you can call it in the same way you would if the element existed, but it will have no effect.

jsejcksn
  • 27,667
  • 4
  • 38
  • 62
  • Thanks a lot, I'll try it in a bit. I'd like to ask that should I not have used the index method since I'd have to do this much to fix an error that doesn't even stop compilation. Doesn't this mean that it's just bad practice? – Fexxix Feb 25 '23 at 01:25
  • [^](https://stackoverflow.com/questions/75552136/how-to-check-if-a-property-is-readonly-in-typescript/75561455?noredirect=1#comment133314373_75561455) @Fexxix That's opinion territory. With that in mind, I think the most useful part of the function is abstracting the element query. IMO, it's not really worth the type complexity just to create a setter function in this case unless you're doing FP. Again, just opinion (which isn't really on topic for SO). – jsejcksn Feb 25 '23 at 03:37