The main issue arises from the fact that the compiler is unable to narrow down the type parameter K
based on the value of k
within the implementation of handleProperty()
. This behavior is discussed in detail in microsoft/TypeScript#24085. The compiler does not attempt to do so, and technically speaking, it is correct because K extends "name" | "age"
doesn't guarantee that K
will be either one of these values individually. It could potentially encompass the entire union "name" | "age"
, leading to scenarios where checking k
may not imply anything about K
and consequently T[K]
:
handleProperty(x, Math.random() < 0.5 ? "name" : "age", "bar"); // accepted!
In this scenario, the k
parameter is of type "name" | "age"
, inferring that K
must also be of this type. Therefore, the v
parameter is allowed to be a type of string | number
. Hence, the error encountered within the implication is justified: k
might be "age"
while v
is still a string. Despite deviating from the intended use case, this possibility concerns the compiler.
Your intention is actually to specify that either K extends "name"
or K extends "age"
, or perhaps even something like K extends_one_of ("name", "age")
(refer to microsoft/TypeScript#27808). Unfortunately, there isn't currently a way to express this concept. Generics don't offer the desired level of control you are seeking.
One approach to enforce restrictions on the callers for the specified use cases is by employing a union of rest tuples rather than generics:
type KV = { [K in keyof Entity]: [k: K, v: Entity[K]] }[keyof Entity]
// type KV = [k: "name", v: string] | [k: "age", v: number];
function handleProperty(e: Entity, ...[k, v]: KV): void {
// implementation
}
handleProperty(x, 'name', 10); // Error
handleProperty(x, 'name', 'bar'); // Valid
handleProperty(x, 'age', 'bar'); // Error
handleProperty(x, 'age', 20); // Valid
handleProperty(x, Math.random() < 0.5 ? "name" : "age", "bar"); // Error
This setup defines the type KV
as a union of tuples, with handleProperty()
accepting these tuples as its last two arguments.
While this method appears promising, it's important to note that it doesn't fully address the issue within the implementation:
function handleProperty(e: Entity, ...[k, v]: KV): void {
if (k === 'age') {
console.log(v + 2); // remains an error!
}
console.log(v);
}
This limitation results from the lack of support for what can be termed as correlated union types (refer to microsoft/TypeScript#30581). The compiler treats the deconstructed k
as "name" | "age"
and v
as string | number
, which is accurate but fails to grasp their interconnected nature after deconstruction.
To circumvent this hurdle, a potential workaround involves altering how the rest argument is handled, specifically delaying the destructuring until the first element is checked:
function handleProperty(e: Entity, ...kv: KV): void {
if (kv[0] === 'age') {
console.log(kv[1] + 2) // no longer an error!
// separate k and v if needed
const [k, v] = kv;
console.log(v + 2) // also no error
}
console.log(kv[1]);
}
This adjustment retains the rest tuple as a singular array value kv
, prompting the compiler to recognize it as a discriminated union. Subsequently, upon examining kv[0]
(formerly k
), the compiler narrows down the type of kv
accordingly, ensuring that kv[1]
aligns with this narrowed scope. Although navigating through kv[0]
and kv[1]
may seem cumbersome, partially mitigating this by deconstructing post the kv[0]
check aids in enhancing readability.
In conclusion, the outlined approach provides a more robustly type-safe implementation of handleProperty()
, although the trade-off in complexity may outweigh the benefits. In practice, resorting to idiomatic JavaScript with occasional type assertions tends to provide a pragmatic solution to address compiler warnings effectively, echoing your initial strategy.
Explore the code further in the TypeScript Playground