Home > Back-end >  Why can I not use keyof from one type on another type when both types have the same keys defined but
Why can I not use keyof from one type on another type when both types have the same keys defined but

Time:11-28

For example. Given the following code. I get a typescript error on the last compat[k] with an error saying

Type 'keyof T' cannot be used to index type 'Partial<CompatType>'

export type KeysOfType<T, U, B = false> = {
  [P in keyof T]: B extends true
    ? T[P] extends U
      ? U extends T[P]
        ? P
        : never
      : never
    : T[P] extends U
    ? P
    : never;
}[keyof T];

export type BigIntKeys<T> = KeysOfType<T, bigint, true>

export type CompatType<T> = Omit<T, BigIntKeys<T>> &
  {
    [Property in BigIntKeys<T>]: string;
  };

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] = typeof v === "bigint" ? v.toString() : v;
  }
  return compat as CompatType<T>;
};

The types should fully overlap on their keys, but the types of the values on the objects differ. This should mean I can use the keys on one to index the other, but it's not appearing this way. Is there something I am misunderstanding or that I have wrong?

TS Playground

CodePudding user response:

This is a design limitation of TypeScript; see microsoft/TypeScript#28884. According to 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."

So a type like Omit<T, K> & Record<K, string> where K extends keyof T is not going to be seen as having the same keys as T, even though it pretty much must. The compiler compares Exclude<keyof T, K> | Extract<keyof T, K> and keyof T but does not see them as equal 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!
}

For any specific types T and K, the compiler can fully evaluate Extract<keyof T, K> | Exclude<keyof T, K> and see that it's the same as keyof T, but when T and/or K are not a specific type, the compiler defers this evaluation and thus it has no idea whether they're the same or not.


One thing you can do instead is to build CompatType as a homomorphic mapped type directly from T, and use a conditional type to decide whether the particular key K from keyof T would be part of BigIntKeys<T> and choose 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 produces nicer looking types,

type Check = CompatType<{ a: string, b: bigint, c: number, d: boolean }>;
/* type Check = {
    a: string;
    b: string;
    c: number;
    d: boolean;
} */

and the compiler knows that CompatType<T> definitely has the same keys as T, even for generic 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>;
};

Of course you still get an error when trying to assign typeof v === "bigint" ? v.toString() : v to compat[k], but that's because the compiler doesn't really know how to verify if something is assignable to a conditional type (see microsoft/TypeScript#33912), nor does it understand the correlation between the type of compat[k] when being written to and the type of typeof v === "bigint" ? v.toString() : v when being read from (see microsoft/TypeScript#30581 especially with the fact that writing to unions requires intersections as per microsoft/TypeScript#30769). These problems are out of scope for the question as asked, which was just "why is keyof not working for me".


Anyway, in situations where you are sure what you are doing is correct but the compiler is not, you can use type assertions or the any type to loosen type checking enough to make the compiler happy. For example:

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 tell the compiler not to worry about the property value types of compat, and we just return it as CompatType<T>. As long as you are definitely 100% sure that the typings are correct, this is fine to do. Although, you probably shouldn't be so sure:

const hmm = compatModel({ a: Math.random() < 10 ? 3n : 3 });
hmm.a // number | bigint
if (typeof hmm.a !== "number") {
  3n * hmm.a; // no error at compile time, but runtime            
  • Related