One of the limitations of TypeScript lies in its design; refer to microsoft/TypeScript#28884. As mentioned in this comment, "complementary subsets of a higher order type, constructed using Pick<T, K>
or by other means, is not assignable back to that higher order type."
For instance, a type like
Omit<T, K> & Record<K, string>
where
K extends keyof T
will not be recognized as having the same keys as
T
, even though logically it should. This discrepancy occurs because the compiler fails to equate
Exclude<keyof T, K> | Extract<keyof T, K>
with
keyof T
when
T
and/or
K
are unspecified
generic types:
function foo<T, K extends keyof T>(a: keyof T) {
const b: Extract<keyof T, K> | Exclude<keyof T, K> = a; // error!
}
In situations where the exact types for T
and K
are unknown, the compiler defers the evaluation of
Extract<keyof T, K> | Exclude<keyof T, K>
, hence failing to recognize their equivalence to
keyof T
.
To address this issue, you can construct CompatType
as a homomorphic mapped type directly from T
, employing a conditional type to determine whether the specific key K
from keyof T
should belong to BigIntKeys<T>
and selecting the value type accordingly:
type CompatType<T> = { [K in keyof T]:
T[K] extends bigint ? bigint extends T[K] ? string : T[K] : T[K]
}
This approach results in more visually appealing types,
type Check = CompatType<{ a: string, b: bigint, c: number, d: boolean }>;
/* type Check = {
a: string;
b: string;
c: number;
d: boolean;
} */
Furthermore, the compiler acknowledges that CompatType<T>
indeed shares the same keys as T
, even in scenarios involving generic types for T
:
export function compatModel<T>(model: T): CompatType<T> {
const compat: Partial<CompatType<T>> = {};
for (const k of Object.keys(model) as Array<keyof T>) {
const v = model[k];
compat[k]; // no error here
compat[k] = typeof v === "bigint" ? v.toString() : v; // still error here, unrelated
}
return compat as CompatType<T>;
};
Nevertheless, an error may surface while attempting to assign
typeof v === "bigint" ? v.toString() : v
to
compat[k]
due to the compiler's inability to validate if something is assignable to a conditional type.
In situations where you are confident in the correctness of your code despite the compiler's doubts, you have the option of utilizing type assertions or resorting to the any
type to relax type checking enough to appease the compiler:
export function compatModel<T>(model: T): CompatType<T> {
const compat: Partial<Record<keyof T, any>> = {};
for (const k of Object.keys(model) as Array<keyof T>) {
const v = model[k];
compat[k] = typeof v === "bigint" ? v.toString() : v;
}
return compat as CompatType<T>;
};
Here, we inform the compiler to ignore concerns regarding the property value types of compat
and ultimately return it as CompatType<T>
. As long as you are absolutely certain about the accuracy of the typings, this practice is acceptable. However, exercising caution is advised:
const hmm = compatModel({ a: Math.random() < 10 ? 3n : 3 });
hmm.a // number | bigint
if (typeof hmm.a !== "number") {
3n * hmm.a; // no compile time error, but runtime issue: "cannot convert BigInt to number"
}
The a
property is inferred as number | bigint
, resulting in the compiler assuming that hmm.a
might hold a bigint
, which is impossible. While there exist remedies to such dilemmas, these fall beyond the scope of the current discussion length.
Please be mindful when resorting to type assertions or any
to mitigate compiler errors, as they increase the likelihood of potentially critical errors slipping through unnoticed.
Link to Playground with Code Example