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

3

80

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

Ijssel answered 4/2, 2019 at 16:46 Comment(2)
Possibly duplicating Define generic typescript sort function of a certain type or at least my answer is kind of the sameNoto
If using a library is allowed, ts-toolbelt has Object.SelectKeys that could be useful.Leifeste
N
131

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

Noto answered 4/2, 2019 at 16:55 Comment(9)
This does seem to work! Could you briefly explain why this works and my attempt doesn't?Ijssel
Ok that makes sense, though not sure I'd ever have come up with that myself. Thanks @jcalz!Ijssel
This works perfectly! Could you explain how it works? Thanks!Burnette
What is that "-" before the "?" ?Scathing
It's a mapping modifier that removes optionality from all propsNoto
At the end of the type definition you have [keyof T]. Is this to access each key of the created type and it returns just "id", since only "id" is not never. Is my thinking correct?Booklet
Yes, it returns the union of the properties, which is "id" | never | never which collapses to "id"Noto
For use-cases where you want to match any of the possible values of a property, such as finding all the nullable properties - swapping the 'extends' around is useful, eg: V extends T[K], which can then be used as KeysMatching<Thing, null>Amphi
@Noto I think -? does not work, e.g. if I turn Thing into id?: stringPartiality
A
40

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

Arabela answered 10/2, 2021 at 20:48 Comment(6)
Although this answer and answer from jcalz generate same type, the one from jcalz is better because Typescripts remembers that the resulting keys come original object T and can be used later to index it as in T[K]. with this answer, the TS 4.3 does not know that and issues error "Type 'K' cannot be used to index type 'T'.ts(2536)"Palpitate
In TS 4.3? I thought we barely have 4.2.3 (probably just a typo) Re: indexing - am I missing something here (tsplay.dev/m3Aabw), could you add a link to a playground to take a look at?Arabela
Please look at tsplay.dev/mxozbN It shows usage of both solutions (from jcalz and yours). 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. Maybe it is bug worth reporting. I noticed the jcalz solution is even better in nightly, where it understands what the type of property of computed keys used on and object is.Palpitate
@MichalMinich - thanks, I thought you'd never get back to that :) Will take a closer look today, but it seems that indexing with keyof T does let the compiler know that the resulting type is keyof T while in my version, due to using keyof of the mapped type, the compiler does not know that. Does not look like a bug, I understand the logic behind that. I see two ways out of it. The first is to intersect with keyof T to assure the compiler. The second - Extract<[our type], keyof T>. Both work, I will amend the answerArabela
^ but the latter is obviously more verbose to little to no benefit, so I'd stick with keyof T intersection - it is much more elegant. But that's probably one too many keyofs :)Arabela
@OlegValteriswithUkraine is there a way to let the compiler know that in this case, thing[key] is of type number?Spacing
G
0

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' />
}
Goddamned answered 24/3, 2023 at 19:56 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.