Typescript: Parameter of type 'K extends FunctionPropertyNames<T>' is always a union type and not restricted to the string literal passed to function

Created on 26 Jun 2018  路  4Comments  路  Source: microsoft/TypeScript


TypeScript Version: 2.8.3 and 2.9.1 (I think. The actual version in playground)

Code

The next code works as expected.

interface I {
    property: number;
    method1(): void;
    method2(a: string): number; 
}

declare const ob: I;

declare function foo<T, K extends keyof T>(ob: T, key: K): T[K];

const f1 = foo(ob, 'property'); // number
const f2 = foo(ob, 'method1'); // () => void
const f3 = foo(ob, 'method2'); // (a: string) => number

// f1(); OK, not a function
f2();
f3('a string');

But I want to restrict the key parameter in foo to just functions.

Since #21316, and as an example on that PR, we can use something like FunctionPropertyNames.

type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];

declare function bar<T, K extends FunctionPropertyNames<T>>(ob: T, key: K): T[K];

// const f4 = bar(ob, 'property'); OK, 'property' is not allowed  
const f5 = bar(ob, 'method1'); // () => void | (a: string) => number
const f6 = bar(ob, 'method2'); // () => void | (a: string) => number

// ERROR! Both are of type '() => void | (a: string) => number'
f5();
f6('a string');

// Type assertion
const f7 = bar(ob, 'method1' as 'method1'); // () => void
const f8 = bar(ob, 'method2' as 'method2'); // (a: string) => number

// OK again
f7();
f8('a string');

Expected behavior:
f5 and f6 inferred to () => void and (a: string) => number, respectively, without need a type assertion (pretty much as the first snippet).

Actual behavior:
f5 and f6 are both inferred to () => void | (a: string) => number, which is very odd since I'm passing a literal string.

Playground link
Playground link

Related Issues:
Maybe #24080 (but that seems related to the keyof T ~ string | number | symbol issue and my example fails with TS 2.8)

Question

Most helpful comment

Another workaround is to simply change FunctionPropertyNames<T> to

type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T] & string;

The intersection with string makes it clear to the compiler that the type is a subtype of string which in turn makes it clear that you want to preserve literal types during type inference.

All 4 comments

Two possible workarounds and you can pick your favorite

interface I {
    property1: string;
    method1(): void;
    method2(s: string): number;
}

type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];

function getProperty<T, K extends keyof T>(obj: T, propName: K & FunctionPropertyNames<T>): T[K] {
    return obj[propName];
}

declare const i: I;
const m1 = getProperty(i, "method1");
const m2 = getProperty(i, "method2");
const p1 = getProperty(i, "property1");

or

interface I {
    property1: string;
    method1(): void;
    method2(s: string): number;
}

type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];

function getProperty<T, K extends string & FunctionPropertyNames<T>>(obj: T, propName: K): T[K] {
    return obj[propName];
}

declare const i: I;
const m1 = getProperty(i, "method1");
const m2 = getProperty(i, "method2");
const p1 = getProperty(i, "property1");

Another workaround is to simply change FunctionPropertyNames<T> to

type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T] & string;

The intersection with string makes it clear to the compiler that the type is a subtype of string which in turn makes it clear that you want to preserve literal types during type inference.

Thank you very much for the answers.

@ahejlsberg Could you explain why the checker doesn't do that without the & string? I am not an expert on this matter but I can't figure out why.

I'm also curious why & string is necessary.

Was this page helpful?
0 / 5 - 0 ratings