Issue when using distributive conditional types combined with generic method

312 views Asked by At

I've been trying to make a generic function which receives and object T and receives a string property name of that object T.

I used https://www.typescriptlang.org/docs/handbook/advanced-types.html as an example (section: Distributive conditional types)

I've come up with a solution that works without generics, but when I change the explicit types to a generic type typescript won't compile.

This is the non-generic version:

export type TypedPropertyNames<T, P> = { [K in keyof T]: T[K] extends P ? K : never }[keyof T];
export type StringPropertyNames<T> = TypedPropertyNames<T, string>;

interface Test {
  test: string;
}
 
function non_generic(form: Test, field: StringPropertyNames<Test>): string {
  return form[field];
}

This works.

Now when I change the Test interface into a generic argument it won't compile anymore.

export type TypedPropertyNames<T, P> = { [K in keyof T]: T[K] extends P ? K : never }[keyof T];
export type StringPropertyNames<T> = TypedPropertyNames<T, string>;

function generic<T>(form: T, field: StringPropertyNames<T>): string {
  return form[field]; // This won't compile
}

Is this expected behaviour? Or is this a typescript bug? Can anyone point me in the direction of making the generic version work (without any hacks)

Update 1:

Compilation error:

Type 'T[{ [K in keyof T]: T[K] extends string ? K : never; }[keyof T]]' is not assignable to type 'string'.

Playground link

2

There are 2 answers

2
jcalz On BEST ANSWER

The compiler generally cannot determine assignability of unresolved conditional types (that is, conditional types which cannot be eagerly evaluated because at least one of the T or U in T extends U ? V : W is not yet fully specified).

This is more of a design limitation than a bug (see Microsoft/TypeScript#30728); the compiler is not going to be as smart as a human being (note to self: come back here when the machine uprising happens and edit this) so we shouldn't expect it to just "notice" that T[TypedPropertyName<T,P>] extends P should always be true. We could write a particular heuristic algorithm to detect the situation and perform the desired reduction, but it would have to be able to run very quickly so that it doesn't degrade compile times for the 99% of the time when it wouldn't be useful.

Can anyone point me in the direction of making the generic version work (without any hacks)

That really depends on what you consider a hack. The absolute simplest thing to do is to use a type assertion, which is explicitly intended for times when you know something is type safe but the compiler isn't able to figure it out:

function generic<T>(form: T, field: StringPropertyNames<T>): string {
  return form[field] as any as string;  // I'm smarter than the compiler 
}

Or you could try to lead the compiler through the steps necessary to understand that what you're doing is safe. In particular, the compiler does understand that Record<K, V>[K] is assignable to V (where Record<K, V> is defined in the standard library as a mapped type whose keys are in K and whose values are in V). You can therefore constrain the type T like this:

function generic<T extends Record<StringPropertyNames<T>, string>>(
  form: T,
  field: StringPropertyNames<T>
): string {
  return form[field]; // okay
}

Now the compiler is happy. And the constraint T extends Record<StringPropertyNames<T>, string> isn't really a constraint at all, since any object type will conform to it (e.g., {a: string, b: number} extends Record<'a', string>). So you should be able to use it anywhere you use the original definition (for concrete types T anyway):

interface Foo {
  a: string;
  b: number;
  c: boolean;
  d: "d";
}
declare const foo: Foo;
generic(foo, "a"); // okay
generic(foo, "d"); // okay

Are those hacks? ‍♂️ Okay, hope that helps. Good luck!

1
Jclangst On

Honestly, I do not know what the issue is. You might try filing a issue on their GH. However, I do know that the following does work without explicitly specifying the return type:

function generic<T>(form: T, field: StringPropertyNames<T>) {
  return form[field];
}

and it even correctly types the return value as a string:

const test = {
  a: "b",
  c: 1,
  "other": "blah"
}
generic(test, "a").charAt(0) //passes - "b"
generic(test, "a") * 5 // fails - function result is not a number
generic(test, "c") //fails - "c" is not assignable to "a" | "other"

I would additionally recommend this addition to make sure the first argument must be an object:

function generic<T extends object>(form: T, field: StringPropertyNames<T>) {
  return form[field];
}