Search code examples
typescriptgenericstypescript-genericsmapped-typeskeyof

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


Solution

  • 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