Laurens
Laurens

Reputation: 91

Generic keyof and property getter function

I'm trying to observe properties of objects in arrays. To make it type safe I'm using a getter function to get to the child object of the array objects (if necessary) that contain the actual property to be observed.

The propertyName / key must a string as the observe library I'm using needs it.

The getter should be able to accept a function that returns the same type as was passed in, like o => o.

You can find a condensed example below:

function foo<A, B>(
    array: A[], 
    getter: (ao: A) => B, 
    key: keyof B, 
    callback: (value: B[keyof B]) => void
) {
    callback(getter(array[0])[key]);
}

foo([{b: {c: 1}}], a => a.b, "c", v => {});

Alas this throws Argument of type '"c"' is not assignable to parameter of type 'never'.

But the following does work:

function bar<A>(
    array: A[], 
    key: keyof A, 
    callback: (value: A[keyof A]) => void
) {
    callback(array[0][key]);
}

bar([{b: 1}], "b", v => {});

Why is the compiler not able to infer the type of B and is there a workaround I could use?

Upvotes: 1

Views: 908

Answers (2)

jcalz
jcalz

Reputation: 327624

I don't have a definitive answer as to why the compiler doesn't infer B. My intuition is that it's much easier for the compiler to infer the type of a parameter you actually pass to the function, than it is for it to infer a type only related to the parameter you pass in. Based on that intuition, I'd rewrite the types to replace B with Record<K,V>, where K is the key you are actually passing in, and V is the type of value of the property associated with that key.

In case you're not aware, Record<K,V> is part of the TypeScript standard library and is defined like this:

type Record<K extends string, T> = {
    [P in K]: T; 
}

Then the function becomes:

function foo<A, K extends string, V>(
    array: A[], 
    getter: (ao: A) => Record<K,V>, 
    key: K, // easier to infer K because you directly pass it in
    callback: (value: V) => void
) {
    callback(getter(array[0])[key]);
}

And this seems to work:

foo([{ b: { c: 1 } }], a => a.b, "c", v => { });

The parameters are inferred as <{ b: { c: number; }; }, "c", number> as desired.

Hope that helps; good luck!


Update

@TitianCernicovaDragomir points out that the following call:

foo([{ b: { c: 1, d: "" } }], a => a.b, "c", v => { });

gets inferred as

foo<{ b: { c: number; d: string; }; }, "c" | "d", string | number>(...)

The problem here is that you want K to be just "c", and not "c" | "d", but apparently the array parameter was inspected before the key parameter. (I don't think the fact that V is string | number is the actual problem, since callback in fact takes any value whatsoever. If you passed a callback function that only took number and it failed, then that would be a problem.)

If you find that some type parameters are inferred in the wrong order (meaning the compiler uses a parameter to infer something, but you would rather it used a different parameter) you can lower the priority of an inference by intersecting the type with {}.

In this case, we can do this:

function foo<A, K extends string, V>(
  array: A[],
  getter: (ao: A) => Record<K & {}, V>, // delay inference of K
  key: K, 
  callback: (value: V) => void
) {
  callback(getter(array[0])[key]);
}

Now when we call

foo([{ b: { c: 1, d: "" } }], a => a.b, "c", v => { });

it is inferred as

foo<{ b: { c: number; d: string; }; }, "c", {}>(...)

which works fine, since the callback doesn't do anything. If we call

foo([{ b: { c: 1, d: "" } }], a => a.b, "c", (v: number): void => { });

then it is inferred as

foo<{ b: { c: number; d: string; }; }, "c", number>(...)

which is fine, right?

Any other use cases we need to support?

Upvotes: 2

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249466

Similar to @jcalz I don't necessarily have a reason, just a workaround. If you do it in a two call approach, you can fix A and B in a first call, and send the key and the callback in the second call and then the types will be inferred correctly.

function foo2<A, B>(
    array: A[],
    getter: (ao: A) => B,
) {
    return function <K extends keyof B>(key: K, callback: (value: B[K]) => void) {
        callback(getter(array[0])[key]);
    }
}

foo2([{ b: { c: 1, d: "" } }], a => a.b)("d", v => { }); // v is string
foo2([{ b: { c: 1, d: "" } }], a => a.b)("c", v => { }); // v is number

It's a bit uglier as far as syntax goes but you get full type safety.

Upvotes: 1

Related Questions