I'm trying to make a function that returns the key to a given value.
type Entries<T> = Exclude<{
[P in keyof T]: [P, T[P]];
}[keyof T], undefined>[];
export const getKeyByValue = <T extends Record<PropertyKey, unknown>, V>(
map: T,
value: V,
): V extends T[infer K extends keyof T] ? K : undefined => {
const entry = (Object.entries(map) as Entries<typeof map>).find(([_, v]) => v === value);
const key = entry?.[0];
// @ts-expect-error avoiding current type inference limitation
return key;
};
const map = {
foo: 1,
boo: 2,
} as const;
// I want the type of `expectFoo` to be "foo", not "foo" | "boo"
const expectFoo = getKeyByValue(map, 1 as const);
In the last line, I want the type of key
to be "expectFoo", not "foo" | "boo".
How to make it?
CodePudding user response:
I assume, that only Primitives are allowed values in map
object since you are using ===
for comparison. This is why we need Primitives
type:
type Primitives =
| null
| string
| number
| boolean
| undefined
| symbol
| bigint
Since we are using entries
, we also need Entries<T>
type:
// https://stackoverflow.com/questions/60141960/typescript-key-value-relation-preserving-object-entries-type
type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
const entries = <
Obj extends Record<PropertyKey, unknown>
>(obj: Obj) =>
Object.entries(obj) as Entries<Obj>
Now we can implement our function. Please keep in mind, if you want to infer exact return type, you need implement almost same logic in type scope as you have in runtime. In other words, you need to do the same on type level.
Consider this type utilities which reproduces runtime function:
// https://github.com/microsoft/TypeScript/issues/31751#issuecomment-498526919
type IsNever<T> = [T] extends [never] ? true : false
type UseUndefined<T> = IsNever<T> extends true ? undefined : T
type GetKeyByValue<
Obj extends Record<PropertyKey, Primitives>,
Value extends Primitives
> =
/**
* Extract<> - is used to mock Array.prototype.find
* UseUndefined - returns [undefined] is value don't match
*/
UseUndefined<Extract<Entries<Obj>[number], [PropertyKey, Value]>[0]>
IsNever
- checks whether type is never or not. In our case, never
is returned if value has not been found
UseUndefined
- if value is not found replace never
with undefined
GetKeyByValue
- searches appropriate tuple in entries with expected value.
Let's check how it works:
type Primitives =
| null
| string
| number
| boolean
| undefined
| symbol
| bigint
// https://stackoverflow.com/questions/60141960/typescript-key-value-relation-preserving-object-entries-type
type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
const entries = <
Obj extends Record<PropertyKey, unknown>
>(obj: Obj) =>
Object.entries(obj) as Entries<Obj>
// https://github.com/microsoft/TypeScript/issues/31751#issuecomment-498526919
type IsNever<T> = [T] extends [never] ? true : false
type UseUndefined<T> = IsNever<T> extends true ? undefined : T
type GetKeyByValue<
Obj extends Record<PropertyKey, Primitives>,
Value extends Primitives
> =
Readonly<Obj> extends Obj ?
/**
* Extract<> - is used to mock Array.prototype.find
* UseUndefined - returns [undefined] is value don't match
*/
UseUndefined<Extract<Entries<Obj>[number], [PropertyKey, Value]>[0]>
: [123]
function getKeyByValue<
Values extends Primitives,
Obj extends Record<PropertyKey, Values>,
V extends Primitives
>(
map: Obj,
value: V,
): GetKeyByValue<Obj, V> & Primitives
function getKeyByValue(
map: Record<PropertyKey, Primitives>,
value: Primitives,
): Primitives {
const entry = entries(map).find(([_, v]) => v === value);
const key = entry?.[0];
return key;
};
// foo
const _ = getKeyByValue({
foo: 1,
boo: 2,
}, 1);
// bar
const __ = getKeyByValue({
foo: 1,
boo: 2,
}, 2);
// undefined
const ___ = getKeyByValue({
foo: 1,
boo: 2,
}, 3)
If you are interested in function arguments inference, you can check my article Type Inference on function arguments
P.S. As you might have noticed, I have used function overloading, this is because conditional types does not supported in a place of return type
but could you explain me why generic parameter Values ...
This is how inference on function arguments works. If you use one generic, you can infer literal type of primitive:
const foo = <T,>(a: T) => a
// const foo: <42>(a: 42) => 42
foo(42)
However, if you pass an object instead of 42
and still use generic without appropriate constraint, you can only infer object shape but not literal object values.
const foo = <T,>(a: T) => a
// { num: number }
foo({ num: 42 })
If you need to infer literal type of object value you need to use extra generic for that:
const foo = <
Val extends number,
Obj extends { num: Val }
>(a: Obj) => a
// { num: 42 }
foo({ num: 42 })
I have described this behavior in my article