Home > Back-end >  TypeScript Wrong typechecking when remapping keys with `as` combined with generics
TypeScript Wrong typechecking when remapping keys with `as` combined with generics

Time:06-24

With the new index as syntax, I can filter out keys of a type as follows.

// Filter out all keys of `T` that do not exist in `U`
type IntersectKeys<T, U> = {
    [K in keyof T as K extends keyof U ? K : never]: T[K];
};

// e.g., IntersectKeys<{ foo: string; bar: number}, { foo: never }> === { foo: string }

However, when I use this type with generics, TypeScript seems to completely ignore typechecking.

Is this a TypeScript bug? Is there a way around this?

interface FooBar {
    foo: string;
    bar: number;
}

function intersectFooBarKeys<U>(fb: FooBar, intersect: U): IntersectKeys<FooBar, U> {
    return false; // ---> !!Wrong implementation, but no type error!! <---
}

// Example usage
const fooBar: FooBar = {
    foo: 'foo',
    bar: 42,
};
const onlyFoo: { foo: string } = intersectKeys(fooBar, { foo: null });
// Based on its type, I would expect `onlyFoo` to be of the form `{ foo: string }`, but it is `false` instead!

CodePudding user response:

This seems to be a bug or design limitation in TypeScript. The compiler is unable to reason much about higher order type manipulations like key remapping over generic types. There are several GitHub issues around generic key remapping, like ms/TS#45212 and ms/TS#48855 and ms/TS#47447, but I don't see anything specific to this particular problem. One might file a new issue with code like this as a reproduction:

type Foo<U> = {
  [K in "foo" | "bar" as Extract<K, keyof U>]: 1;
};

type X = Foo<{ foo: 0, baz: 0 }> // {foo: 1}
type Y = Foo<{ baz: 0, qux: 0 }> // {}
type Z = Foo<{ [k: string]: 0 }> // {foo: 1, bar: 1}

function generic<U extends { [k: string]: 0 }>() {
  type G = Foo<U> // {} <-- shouldn't this either be *deferred* or,
  // if anything, eagerly resolved to {foo: 1, bar: 1}? 
}

But I don't know what would come of that.


In general, when the compiler sees any sort of complicated type function involving generic type parameters, it can either decide to defer evaluation of it, thereby seeing it as an opaque type nothing is assignable to... or it can decide to prematurely evaluate it by substituting the generic type parameter with something related to it... thereby possibly resolving it to something incorrect. It looks like the latter is what's happening here; no matter what U is, K extends keyof U ? K : never is being prematurely reduced to never inside the remapping clause, and thus you get IntersectKeys<FooBar, U> as {} independently of U. And that's particularly unfortunate, since {} is special-cased and does not trigger excess property checks (see other answer), and so false and just about anything except for null and undefined is assignable to it. Oops.

Thus I'd say the issue is similar to that in microsoft/TypeScript#24560. We're trying to use conditional types to filter a generic type related to keyof a generic type, and the compiler cannot figure out what that might be. The operable comment in that issue says:

Given an arbitrary constraint T extends C, there are very few things that can be readily determined about any possible instantiation of T-- you can't just use C as-is and get correct answers out of the other side. We have some logic in place when relating T as a subtype, but once you take a contravariant operation like keyof T, all bets are off.


Anyway, for your example code, my approach would be to stick to non-conditional non-remapped types where possible, since the compiler has slightly better reasoning abilities there (or at least defers them):

type IntersectKeys<T, U> = Pick<T, keyof T & keyof U>

This behaves as desired, or at least defers evaluation so that something like false isn't seen as assignable to it:

function intersectFooBarKeys<U>(fb: FooBar, intersect: U): IntersectKeys<FooBar, U> {
  return false; // error! Type 'boolean' is not assignable to type 'IntersectKeys<FooBar, U>'
}

Playground link to code

  • Related