Search code examples
typescripttypescript-generics

Restrict the inferred type from a Record to its corresponding key


I am trying to create a function that accepts either keys of an object, or an object referencing both the key and a given value.

When providing an object, I would like to infer the type of the value from the given key.

Currently I'm almost there, the only issue is with the last line: I can provide a combination of any key and any accepted type, whereas I would like to be able to provide only the corresponding type.

Here is the code :

import { HttpContextToken } from '@angular/common/http';

const tokens = {
  omitBaseUrl: new HttpContextToken(() => true),
  test: new HttpContextToken(() => ({ some: 'value' })),
} as const;
type Tokens = typeof tokens;

type ContextParameter =
  { [K in keyof Tokens]: HttpContextToken<any> } extends Record<infer KK extends keyof Tokens, HttpContextToken<any>>
    ? Tokens[KK] extends HttpContextToken<infer X>
      ? KK | { context: KK; value: X }
      : never
    : never;

export function withContext(...contexts: ContextParameter[]) {}

withContext('omitBaseUrl');
withContext('test');
withContext({ context: 'omitBaseUrl', value: true });
withContext({ context: 'test', value: { some: '' } });

withContext('xxx');
withContext(12);
withContext({ context: 'omitBaseUrl', value: 12 });
withContext({ context: 'omitBaseUrl', value: { some: '' } });

StackBlitz


Solution

  • The Record<K, V> utility type doesn't track individual key-value type mappings. Instead it associates all keys with the union of values. If you try to infer {a: string, b: number} to Record<infer K, infer V> you'll get Record<"a" | "b", string | number> and thus {a: string | number, b: string | number}. Similarly if you index into an object type with its full union of keys, you'll get its full union of value types. So {a: string, b: number}["a" | "b"] is just string | number. Your ContextParameter definition does both of these, hopelessly mixing all property value types together.

    Instead, you should consider using a distributive object type as coined in microsoft/TypeScript#47109. Instead of inferring, just map over every property key K of Tokens and do your analysis there. Then when you're done, index into that mapped type to get the union of analyzed types. This way, K will always be a single key and your computed type won't mix them:

    type ContextParameter =
      { [K in keyof Tokens]: Tokens[K] extends HttpContextToken<infer X>
        ? K | { context: K; value: X }
        : never }[keyof Tokens]
    
    /* type ContextParameter = "omitBaseUrl" | "test" | {
        context: "omitBaseUrl";
        value: boolean;
    } | {
        context: "test";
        value: {
            some: string;
        };
    } */
    

    That looks like what you want; the context/value pairs are in separate union members as desired.

    Playground link to code