69

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

Trevortni
  • 688
  • 8
  • 23
Aron
  • 8,696
  • 6
  • 33
  • 59
  • *Possibly* duplicating [Define generic typescript sort function of a certain type](https://stackoverflow.com/questions/51573206/define-generic-typescript-sort-function-of-a-certain-type) or at least my answer is kind of the same – jcalz Feb 04 '19 at 16:56
  • 1
    If using a library is allowed, `ts-toolbelt` has [`Object.SelectKeys`](https://millsp.github.io/ts-toolbelt/modules/object_selectkeys.html#selectkeys) that could be useful. – Flynn Hou Aug 04 '22 at 10:00

3 Answers3

115

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This does seem to work! Could you briefly explain why this works and my attempt doesn't? – Aron Feb 04 '19 at 16:59
  • 2
    Ok that makes sense, though not sure I'd ever have come up with that myself. Thanks @jcalz! – Aron Feb 04 '19 at 17:02
  • This works perfectly! Could you explain how it works? Thanks! – Akash Jul 06 '21 at 09:20
  • Could you explain in detail how this code works? I've recently started using typescript and I know the basics but not advanced combinations of stuff like this. Thanks! – Akash Jul 06 '21 at 09:23
  • @jcalz This is beautiful. Thank you. – s.alem Oct 11 '21 at 09:52
  • 3
    What is that "-" before the "?" ? – Trevortni Mar 28 '22 at 20:01
  • 3
    It's a [mapping modifier](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#mapping-modifiers) that removes optionality from all props – jcalz Mar 29 '22 at 01:19
  • 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? – LoyalPotato Dec 02 '22 at 14:49
  • Yes, it returns the union of the properties, which is `"id" | never | never` which collapses to `"id"` – jcalz Dec 02 '22 at 14:53
  • 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` – coatesap Jan 18 '23 at 15:34
36

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

  • 2
    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)" – Michal Minich Mar 10 '21 at 15:43
  • In TS 4.3? I thought we barely have 4.2.3 (probably just a typo) Re: indexing - am I missing something here (https://tsplay.dev/m3Aabw), could you add a link to a playground to take a look at? – Oleg Valter is with Ukraine Mar 10 '21 at 17:03
  • 1
    Please look at https://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...` 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. – Michal Minich Apr 19 '21 at 09:00
  • 1
    @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 answer – Oleg Valter is with Ukraine Apr 19 '21 at 10:44
  • ^ 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 `keyof`s :) – Oleg Valter is with Ukraine Apr 19 '21 at 10:57
  • 1
    @OlegValteriswithUkraine is there a way to let the compiler know that in this case, `thing[key]` is of type number? – goerwin Oct 30 '22 at 03:49
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' />
}
Velixo
  • 694
  • 6
  • 11