Aron
Aron

Reputation: 9258

In TypeScript, how to get the keys of an object type whose values are of a given type?

I've been trying to create a type that consists of the keys of type T whose values are strings. In pseudocode it would be keyof T where T[P] is a string.

The only way I can think of doing this is in two steps:

// a mapped type that filters out properties that aren't strings via a conditional type
type StringValueKeys<T> = { [P in keyof T]: T[P] extends string ? T[P] : never };

// all keys of the above type
type Key<T> = keyof StringValueKeys<T>;

However the TS compiler is saying that Key<T> is simply equal to keyof T, even though I've filtered out the keys whose values aren't strings by setting them to never using a conditional type.

So it is still allowing this, for example:

interface Thing {
    id: string;
    price: number;
    other: { stuff: boolean };
}

const key: Key<Thing> = 'other';

when the only allowed value of key should really be "id", not "id" | "price" | "other", as the other two keys' values are not strings.

Link to a code sample in the TypeScript playground

Upvotes: 89

Views: 58539

Answers (3)

jcalz
jcalz

Reputation: 330411

There is a feature request at microsoft/TypeScript#48992 to support this natively. Until and unless that's implemented though, you an make your own version in a number of ways.

One way is with conditional types and indexed access types, like this:

type KeysMatching<T, V> = {[K in keyof T]-?: T[K] extends V ? K : never}[keyof T];

and then you pull out the keys whose properties match string like this:

const key: KeysMatching<Thing, string> = 'other'; // ERROR!
// '"other"' is not assignable to type '"id"'

In detail:

KeysMatching<Thing, string> ➡

{[K in keyof Thing]-?: Thing[K] extends string ? K : never}[keyof Thing] ➡

{ 
  id: string extends string ? 'id' : never; 
  price: number extends string ? 'number' : never;
  other: { stuff: boolean } extends string ? 'other' : never;
}['id'|'price'|'other'] ➡

{ id: 'id', price: never, other: never }['id' | 'price' | 'other'] ➡

'id' | never | never ➡

'id'

Note that what you were doing:

type SetNonStringToNever<T> = { [P in keyof T]: T[P] extends string ? T[P] : never };

was really just turning non-string property values into never property values. It wasn't touching the keys. Your Thing would become {id: string, price: never, other: never}. And the keys of that are the same as the keys of Thing. The main difference with that and KeysMatching is that you should be selecting keys, not values (so P and not T[P]).

Playground link to code

Upvotes: 145

Velixo
Velixo

Reputation: 724

In case anyone else had the same questions as myself, I was trying to use a pattern like this for indexing into a a generic object property with type inference in React, but couldn't get it to work.

function ListWithSum<T>({
    data,
    value,
}: {
    data: T
    value: KeysMatching<T, number>
}) {
    // 'item[value]' would not have type 'number', causing a type mismatch
    const sum = data.reduce((total, item) => total + item[value], 0)
    // ...
}

By introducing an extra type PickKeysInMatching:

type PickKeysMatching<T, V> = {
    [key in KeysMatching<T, V>]: V
}

And using it to constrain T, I can safely index into the value prop, correctly resolving to type number.

function ListWithSum<T extends PickKeysMatching<T, number>>({
    data,
    value,
}: {
    data: T
    value: KeysMatching<T, number>
}) {
    // 'item[value]' is now a 'number'
    const sum = data.reduce((total, item) => total + item[value], 0)

    return (
        <ul>
            {data.map((item) => (
                <li>{item[value]}</li>
            ))}
            <li><b>Sum: {sum}</b></li>
        </ul>
    )
}

When using the component, type is also checked on keys passed to the props. As the prop value expects a key to number property, passing value='title' will cause an error as Contract.title has type string.

type Contract = { title: string; payment: number}

function Example(){
    const contracts: Contract[] = [
        { title: 'Walking neighbourhood dogs', payment: 300 },
        { title: 'Built website for client', payment: 2000 },
        { title: 'Mowed parents lawn', payment: 50 },
    ]

    return <ListWithSum data={contracts} value='payment' />
}

Upvotes: 0

0Valt
0Valt

Reputation: 10375

As a supplementary answer:

Since version 4.1 you can leverage key remapping for an alternative solution (note that core logic does not differ from jcalz's answer). Simply filter out keys that, when used to index the source type, do not produce a type assignable to the target type and extract the union of remaining keys with keyof:

type KeysWithValsOfType<T,V> = keyof { [ P in keyof T as T[P] extends V ? P : never ] : P };

interface Thing {
    id: string;
    price: number;
    test: number;
    other: { stuff: boolean };
}

type keys1 = KeysWithValsOfType<Thing, string>; //id -> ok
type keys2 = KeysWithValsOfType<Thing, number>; //price|test -> ok

Playground


As rightfully mentioned by Michal Minich:

Both can extract the union of string keys. Yet, when they should be used in more complex situation - like T extends Keys...<T, X> then TS is not able to "understand" your solution well.

Because the type above does not index with keyof T and instead uses keyof of the mapped type, the compiler cannot infer that T is indexable by the output union. To ensure the compiler about that, one can intersect the latter with keyof T:

type KeysWithValsOfType<T,V> = keyof { [ P in keyof T as T[P] extends V ? P : never ] : P } & keyof T;

function getNumValueC<T, K extends KeysWithValsOfType<T, number>>(thing: T, key: K) {
    return thing[key]; //OK
}

Updated Playground

Upvotes: 41

Related Questions