Reputation: 9258
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
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]
).
Upvotes: 145
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
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
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
}
Upvotes: 41