A utility type creating a union of every key of type that has a specific property:
export type KeysHaveProperty<T, Prop extends string> = {
[K in keyof T]: Prop extends keyof T[K] ? K : never;
}[keyof T];
Simply provide a type and a property name and we get all keys of that type that have a property with that name.
type ProductStore = {
clothing: Store<HasVariation>;
sporting: Store<HasVariation>;
entertainment: Store<NoVariation>;
}
type ProductKeyWithVariation = KeysHaveProperty<
ProductStore,
'variation'
>; // 'clothing' | 'sporting'
Example: Dynamic Redux Selectors
Recently, I was working with a Redux store which had a number of collections whose state included:
type CollectionState<Item> = {
all: Item[];
current: Item;
};
I wanted to define a dynamic selector that I could provide a store slice name and get all of the collection items like this:
const items = useAppSelector(selectAllItems('cart'));
Defining the selector is relatively simple:
function selectAllItems<K extends keyof RootState>(collection: K) {
return (state: RootState): RootState[K]['all'] => {
state[collection].all
};
}
This works just fine if every slice in your store has an all
property. But it will show a lot of errors if any slice doesn't have it:
type RootState = {
products: CollectionState<Product>;
cart: CollectionState<CartItem> & CartSummary;
settings: Record<'app' | 'checkout' | 'email', Preferences>
}
This RootState
will show a number of errors, and they can feel quite cryptic. But they are all occurring for the because RootState['settings']
doesn't have an all
property.
TS2355: A function whose declared type is neither undefined,
void, nor any must return a value.
Looking at the return value of RootState[K]['all']
it can feel confusing because we expect the all method to return a value. However, RootState['settings']
doesn't have an all
property. If we call state.settings.all
it evaluates to undefined.
TS2536: Type 'all' cannot be used to index type | RootState[K]
For the same reason, RootState[K]['all']
is an invalid type because the property doesn't exist on all properties of RootState
. If cart
, products
and settings
all had a property called all
then this wouldn't be a problem.
TS2339: Property all does not exist on type
| RootState['products']
| RootState['cart']
| RootState['settings']
And finally, state[collection].all
has this error because... wait for it... the all
property doesn't exist on all properties of RootState
.
Aside from these glaring issues, it won't complain if you call selectAllItems('settings')
because keyof RootState
is the valid type.
This is a problem because it's really the only TypeScript error we really want to see. We shouldn't be allowed to call selectAllItems('settings')
because settings
doesn't have an all
property.
This is where this utility comes in handy:
function selectAllItems<
Collection extends KeysHaveProperty<RootState, 'all'>
>(collection: Collection) {
return (state: RootState): RootState[Collection]['all'] => {
state[collection].all
};
}
Now we are warned when we pass an invalid key name, and the correct return type is inferred:
useAppSelector(selectAllItems('settings')); // TS error
useAppSelector(selectAllItems('cart')); // CartItem[]
useAppSelector(selectAllItems('products')); // Product[]
Breaking it down
This utility is a mapped type that returns all keys of the type that have a property with a certain name.
export type KeysHaveProperty<T, Prop extends string> = {
[K in keyof T]: Prop extends keyof T[K] ? K : never;
}[keyof T];
- We accept type (
T
) and property (Prop
) generics. - We iterate over the keys (
K
) ofT
([K in keyof T]
). - We check if
T[K]
hasProp
. - We return
K
ifT[K]
has`Prop. - We return
never
ifT[K]
doesn't haveProp
. - Finally we declare the resulting type
[keyof T]
to get a union of all the keys that have theProp
.
Hopefully you find this useful :)
Top comments (0)