Home > Back-end >  How to infer exact key of object by value in typescript?
How to infer exact key of object by value in typescript?

Time:10-02

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)

Playground

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

  • Related