microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.79k stars 12.46k forks source link

Operator to produce keys of a type where the properties of that type in those keys match some type #48992

Open RyanCavanaugh opened 2 years ago

RyanCavanaugh commented 2 years ago

Suggestion

🔍 Search Terms

keyof property keysOfType

✅ Viability Checklist

My suggestion meets these guidelines:

⭐ Suggestion

We frequently get people writing definitions to produce the keys K of an object type O where O[K] extends T. Let's call this operation KeysOfType<O, T>. Varying userland definitions include

type ObjectKey<O, T> = {[K in keyof O]: O[K] extends T ? K : never}[keyof O & string]
// TODO: Link more definitions

These userland definitions work in concrete cases, but can't be used to write generic functions:

type ObjectKey<O, T> = {[K in keyof O]: O[K] extends T ? K : never}[keyof O & string];
function getString<O extends object, K extends ObjectKey<O, string>>(obj: O, key: K): string {
    // Can't assign ObjectKey<O, K> to 'string', but this is sound by construction
    return obj[key];
}

// Demo
interface MyInterface {
    someString: string;
    someNumber: number;
}
declare const mi: MyInterface;
getString(mi, "someString"); // OK
getString(mi, "someNumber"); // Error

We could add a new type operator or intrinsic alias KeysOfType<T, P> that returns all keyof T K such that T[K] extends P. When this type indexes a generic object of type T, we can then know that T[KeysOfType<T, P>] is assignable to P. In non-generic positions, this can be immediately resolved the same way as the userland definition.

type KeysOfType<O, P> = intrinsic;

// Hypothetical
function getString<O extends object>(obj: O, key: KeysOfType<O, string>): string {
     // OK, O[KeysOfType<O, string>] is known to be assignable to
     // string because O is a subtype of O and string is assignable to string
    return obj[key];
}

// getString works the same from the outside as the userland version

📃 Motivating Example

See getString above

💻 Use Cases

This is a frequent complaint; need link more inbound issues here

48989

jcalz commented 2 years ago

related: #30728

RyanCavanaugh commented 2 years ago

Collecting more

42795

40654

39972

32550

somebody1234 commented 2 years ago

Solution (to codeblock 2) - Playground

jcalz commented 1 year ago

Note to self for future ease of searching: I usually call KeysOfType<O, T> by the name KeysMatching<T, V>

RobertAKARobin commented 10 months ago

Sure would be nice to see this implemented! The KeysMatching approach doesn't work when used on the base of a derived class, e.g.:

type KeysMatching<Target, Type> = {
    [Key in keyof Target]: Target[Key] extends Type ? Key : never
}[keyof Target];

class Animal {
    breathe() {
        console.log(`Ahh`);
    }

    exec(methodName: KeysMatching<this, Function>) {
        (this[methodName] as Function)();
    }
}

class Cat extends Animal {
    constructor() {
        super();
        this.exec(`meow`); // Argument of type 'string' is not assignable to parameter of type 'KeysMatching<this, Function>'.
    }

    meow() {
        console.log(`Meow`);
    }
}
somebody1234 commented 10 months ago

@RobertAKARobin actually that doesn't even work on the base class. This does though: Playground Link

class Animal {
    breathe() {
        console.log(`Ahh`);
    }

    exec<T extends Record<MethodName, Function>, MethodName extends keyof T>(this: T, methodName: MethodName) {
        (this[methodName] as Function)();
    }
}

class Cat extends Animal {
    a = 1;

    constructor() {
        super();
        this.exec(`breathe`);
    }

    meow() {
        console.log(`Meow`);
    }
}
Howard-Lam-UnitedVanning commented 6 months ago

Another issue that maybe related to this In the following, in usetest2, Test2['e'] has the type never even though it should always be number. Or maybe the engine found out a case I never could think of. EDIT: I have 2 implementations of BooleanKey here they both lead to the same results...

interface Test {
    a: boolean;
    b?: boolean;
    c: undefined;
    d?: string;
    e: number;
}
interface TestGeneric<T> extends Test {
    f: T;
    g: T[];
}
type BooleanKey<T, K extends keyof T = keyof T> = K extends any ? T[K] extends (boolean|undefined) ? (T[K] extends undefined ? never : K) : never : never;
type BooleanKeys<T> = {
    [K in keyof T]-?: T[K] extends (boolean | undefined) ? (T[K] extends undefined ? never : K) : never;
}[keyof T];
type t1 = BooleanKey<Test>;
type t2 = BooleanKeys<Test>;
type NonNullableBoolOnlyFields<T> = {
    [K in BooleanKeys<T>]-?: boolean;
};
type NonNullableBools<T> = T & NonNullableBoolOnlyFields<T>;
type Test1 = NonNullableBools<Test>
type Test2<T> = NonNullableBools<TestGeneric<T>>
type Field = 'e';

function test1(p1: Test1[Field]): number {
    const result: number = p1;
    return result;
}
function usetest1() {
    return test1(10);
}
function test2<T>(p1: Test2<T>[Field]): number {
    const result: number = p1;
    return result;
}
function usetest2<T>() {
    return test2<T>(10);
}