Motti
Motti

Reputation: 114695

Function that accepts only keys of array values (and deduces return type)

I'm trying to grok the new conditional types in typescript 2.8.

For example, I have have some objects with array properties which in my flow must have exactly one element and I want to get this value. Here is the code I thought should work, it correctly allows only relevant properties to be passed in but I can't figure out how to specify the return type. I get the following compilation error:

Type number cannot be used to index type Pick<T, { [K in keyof T]: T[K] extends any[] ? K : never; }[keyof T]>[K].

And the types of n and s are deduced to be number|string rather than number for n and string for s.

Code follows:

type ArrayProperties<T> = Pick<T, {
    [K in keyof T]: T[K] extends Array<any>? K : never
}[keyof T]>;

const obj = {
    a: 4,
    n: [2],
    s: ["plonk"]
};

// Compilation error on next line
function single<T, K extends keyof ArrayProperties<T>>(t: T, k: K): ArrayProperties<T>[K][number] {
    const val = t[k];
    if (!Array.isArray(val))
        throw new Error(`Expected ${k} to be an array`);

    if (val.length !== 1) 
        throw new Error(`Expected exactly one ${k}`);

    return val[0];
}

const n = single(obj, "n"); // 'n' should be of type 'number'
const s = single(obj, "s"); // 's' should be of type 'string'
const a = single(obj, "a"); // Should fail to compile (OK)

Upvotes: 5

Views: 2403

Answers (1)

jcalz
jcalz

Reputation: 327754

There are a few workarounds you can use to get this working.

To fix the reported error:

To force the TypeScript compiler to look up a property when it can't verify that the property exists, you can intersect the key type with the known keys. Like this:

type ForceLookup<T, K> = T[K & keyof T]; // no error

So you can change

ArrayProperties<T>[K][number]

to

ForceLookup<ArrayProperties<T>[K],number>

Let's make sure that works:

type N = ForceLookup<ArrayProperties<typeof obj>["n"],number>; // number ✔️
type S = ForceLookup<ArrayProperties<typeof obj>["s"],number>; // string ✔️

to infer narrower types for n and s:

The problem is that K is not being inferred as a string literal. To hint that the compiler should infer a string literal when possible for a type parameter, you can add the constraint extends string. It is not well-documented, but there are particular situations in which TypeScript infers literal types instead of widening to the more general type (so when 1 is inferred as 1 and not number, or when 'a' is inferred as 'a' and not string). The contraint keyof ArrayProperties<T> apparently does not trigger this non-widening, so K is widened to keyof ArrayProperties<T> in all cases. Here's the workaround for K:

K extends string & keyof ArrayProperties<T>

Let's see it all in action:

declare function single<T, K extends string & keyof ArrayProperties<T>>(
  t: T, k: K): ForceLookup<ArrayProperties<T>[K],number>;
const n = single(obj, "n"); // number ✔️
const s = single(obj, "s"); // string ✔️
const a = single(obj, "a"); // still error ✔️

All done!


Well, there's a bit of simplification I would do here. ArrayProperties<T>[K] can be reduced to T[K], for any K that you can actually use. So you get:

declare function single<T, K extends string & keyof ArrayProperties<T>>(
  t: T, k: K): ForceLookup<T[K],number>;

All done for real now.


Hope that helps. Good luck!

Upvotes: 5

Related Questions