vladeck
vladeck

Reputation: 336

Deduce property type of callback argument when using generics and keyof

I'm struggling to write a code that will deduce the type of args.value inside if scope:

class Foo {
    public id: number;
    public name: string;
    public birth: Date;
}

interface ISetEventArgs<T> {
    field: keyof T;
    value: T[keyof T];
}

function bind<T>(obj: T, event: "set", handler: (args: ISetEventArgs<T>) => void): void {
    // Void
}

let f: Foo = new Foo();

bind<Foo>(f, "set", (args: IArgs<Foo>): void => {
    if (args.field === "id") {
        let id: number = args.value; // Error: Type 'string | number | Date' is not assignable to type 'number'.
    }
    else if (args.field === "name") {
        // ...
    }
    else if (args.field === "birth") {
        // ...
    }
});

I tried to solve this situation by writing something like this, but It does not feel right:

function getValue<T, K extends keyof T>(value: T[keyof T], key: K): T[K] {
    return value;
}

// Usage:
if (args.field === "id") {
    let id: number = getValue<Foo, "id">(args.value, args.field); // Correct type.
    // Can also be used as: getValue<Foo, "id">(args.value, "id");
}

Any ideas? Even if the solution requires using helper function I would really like to be able to use it in a more clean way, such as (if possible) getValue<Foo, "id">(args.value) or getValue(args.value, args.field)

Upvotes: 2

Views: 335

Answers (2)

CRice
CRice

Reputation: 32186

This question has been bugging me all day, so I played around with it, and while I don't have a solution (sadly), I did discover some interesting behavior which may or may not be helpful to you, depending on your exact use case.

TL;DR: You can get what you want in the specific case of Foo, but not in general. It seems to be a limitation on typescript's part.

So first, lets bind the field and value of ISetEventArgs together:

interface ISetEventArgs<T, K extends keyof T> {
    field: K;
    value: T[K];
}

Now, the problem is the type:

ISetEventArgs<Foo, keyof Foo>

resolves to:

ISetEventArgs<Foo, "id"|"name|"birth">

but we want it to be:

ISetEventArgs<Foo, "id"> | ISetEventArgs<Foo, "name"> | ISetEventArgs<Foo, "birth">

Since in the second case we can take advantage of typescript's discriminated unions capability. These seem to me to be semantically the same, but typescript will only narrow the second case. So we need to do some type shenanigans to get it into that form.

So, if we define a type:

type FooArgs = {[K in keyof Foo]: ISetEventArgs<Foo, K>}[keyof Foo]

and that resolves to what we want... But sadly, if we try to extend this pattern so it works with any type:

type GenericArgs<T> = {[K in keyof T]: ISetEventArgs<T, K>}[keyof T];
type GenricFooArgs = GenericArgs<Foo>;

Suddenly GenericFooArgs resolves to the first type above, instead of the second?! I don't know why declaring FooArgs manually comes out differently than using GenericArgs<Foo>.

So, if you use FooArgs in place of ISetEventArgs<T>, you'll get what you want when you implement your handler. But... You've lost the generic capability of bind, so it may not be a worthy trade after all.

Upvotes: 0

artem
artem

Reputation: 51659

I don't think it can be done without a helper function - typescript type inference is not taking into account that types for field and value are interdependent.

So you have to use so-called user-defined type guard function to express type relationship explicitly:

class Foo {
    public id: number;
    public name: string;
    public birth: Date;
}

interface ISetEventArgs<T> {
    field: keyof T;
    value: T[keyof T];
}

function bind<T>(obj: T, event: "set", handler: (args: ISetEventArgs<T>) => void): void {
    // Void
}

let f: Foo = new Foo();

// type guard
function argsForField<T, F extends keyof T>(args: ISetEventArgs<T>, field: F):
         args is { field: F; value: T[F]} {
    return args.field === field;
}

bind<Foo>(f, "set", (args: ISetEventArgs<Foo>): void => {
    if (argsForField(args, "id")) {
        let id: number = args.value; //no error
    }
    else if (argsForField(args, "name")) {
        let name: string = args.value
    }
    else if (argsForField(args, "birth")) {
        let birth: Date = args.value;
    }
});

Upvotes: 1

Related Questions