Typescript: Constrained generic is not assignable to distributed `keyof` constraint

Created on 22 Oct 2019  ·  9Comments  ·  Source: microsoft/TypeScript


TypeScript Version: 3.6.4


Search Terms: generic constraint constrained function subtype keyof conditional

Code

type DistributedKeyOf<T> = T extends any ? keyof T : never;

type Union = { foo: string } | { bar: number };

const fn = <T extends Union>(t: T) => {
    const key: DistributedKeyOf<T> = 'foo'; // unexpected error ❌
};

If we try this without generics, it works as expected:

{
    type DistributedKeyOfUnion = DistributedKeyOf<Union>;

    const key1: DistributedKeyOfUnion = 'foo';
    const key2: DistributedKeyOfUnion = 'bar';
    const key3: DistributedKeyOfUnion = 'baz'; // expected error ✅
}
Working as Intended

Most helpful comment

@karol-majewski Then I think it might be like I said: the conditional type is deferred because it depends on an unresolved generic. From the handbook:

A conditional type T extends U ? X : Y is either resolved to X or Y, or deferred because the condition depends on one or more type variables. When T or U contains type variables, whether to resolve to X or Y, or to defer, is determined by whether or not the type system has enough information to conclude that T is always assignable to U.

What the handbook doesn’t mention is that while the type is deferred, it acts as a black box: nothing concrete is assignable to it. It’s only assignable to itself. This is also why the error message shows the type alias (including type parameter) instead of the type it actually resolves to.

If I’m wrong, hopefully someone will come along and correct me. :sweat_smile:

All 9 comments

Leaving aside the fact that conditional types are deferred when used on an uninstantiated type parameter (IIRC)...

T could possibly be instantiated to { bar: number } and then it would be incorrect to assign "foo" to it. The error looks correct to me even if the conditional type were not deferred.

@fatcerberus This behavior persists even when we don't use a union:

type DistributedKeyOf<T> = T extends any ? keyof T : never;

type Foo = { foo: string };

const fn = <T extends Foo>(t: T) => {
    const key: DistributedKeyOf<T> = 'foo'; // unexpected error ❌
};
  • If T is a subtype of Foo, then T _must_ have a property called foo.
  • Because T is not known at this point, it wouldn't be safe to assign any other property name to key, but foo is guaranteed to be there.

While working on a library I've stumbled upon this error too and I managed to bring it down to the following example:

type R<A> = A extends Bottom ? A : A
type Bottom = { test: number }

const f = <A extends Bottom>(a: A) => {
    useIt(a) // type error here
}

const useIt = <A extends Bottom>(a: R<A>) => console.log(a)

As you can also see in Playground, for some unclear reason a cannot be used as R<A>, even though this type is equivalent to A.

The type error is:

Argument of type 'A' is not assignable to parameter of type 'R<A>'.
  Type 'Bottom' is not assignable to type 'R<A>'.

Using a concrete type instead of a generic will work as expected, eg:

type X = {test: 1}
const x: R<X> = {test: 1} // all good
const noX: R<X> = {test: 2} // error

Having a better restriction type will also work as expected for concrete types:

type R<A> = A extends Bottom ? A : never
const x: R<X> = {test: 1} // all good
const error: R<{}> = {} // type error as expected given that {} doesn't extend Bottom

I've played a little bit with typescript versions available in the Playground and looks like it works as expected for version 3.0.1 and below.

@karol-majewski Then I think it might be like I said: the conditional type is deferred because it depends on an unresolved generic. From the handbook:

A conditional type T extends U ? X : Y is either resolved to X or Y, or deferred because the condition depends on one or more type variables. When T or U contains type variables, whether to resolve to X or Y, or to defer, is determined by whether or not the type system has enough information to conclude that T is always assignable to U.

What the handbook doesn’t mention is that while the type is deferred, it acts as a black box: nothing concrete is assignable to it. It’s only assignable to itself. This is also why the error message shows the type alias (including type parameter) instead of the type it actually resolves to.

If I’m wrong, hopefully someone will come along and correct me. :sweat_smile:

@fatcerberus thanks for the explanation, is there any way to explicitly force this type to be evaluated as the type it extends from? In my example it will be Bottom, possibly one that doesn't imply using a as Bottom, which would screw up the final inferred type for more complex scenarios

Perfect @fatcerberus, thank you for digging it up! It makes a lot of sense.

It would be very helpful if we could somehow make the evaluation eager in this case. We know the "expression" on the left-hand side is always true (T extends any, but because it's deferred we don't get the behavior we want. If only there was a way to implement DistributedKeyOf<T> without conditional types.

Regarding my particular problem, I solved it by applying the constraint on f too, so that will become:

const f = <A extends Bottom>(a: R<A>) => {
    useIt(a) // same type, no error
}

Lots going on in this thread but it looks like all outstanding questions have been resolved.

Re: OP, it seems like you just want

const key: DistributedKeyOf<Union> = 'foo'; // OK

Distributing a conditional type over a type parameter's constraint would be manifestly incorrect, so we don't do that.

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Roam-Cooper picture Roam-Cooper  ·  3Comments

wmaurer picture wmaurer  ·  3Comments

seanzer picture seanzer  ·  3Comments

blendsdk picture blendsdk  ·  3Comments

weswigham picture weswigham  ·  3Comments