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:
- the partial data attribute name as the
selector
, and
- 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
- writable
property
name, and
- 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.