This can be done 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