Search code examples
typescripttypescript-generics

Generic Settings Utility with Mapped Return Type


I am attempting to create a generic settings utility that provides some basic return types. Our application stores all settings in a centralized dictionary and instead of manually specifying types for each key, I'd like to provide a map of possible types.

As an example, say we have the map of settings keys:

const UserSettings = {
  DEFAULT_ORGANIZATION: 'user_profile.default_organization',
  THEME: 'user_profile.theme'
} as const;

I would like to create a corresponding map which looks like:

type UserSettingsTypes = {
  'user_profile.default_organization': boolean;
  'user_profile.theme': { primary: string; secondary: string };
};

So that I can write a settings look-up function that looks like:

const organization = readSetting<UserSettingsTypes>(UserSettings.DEFAULT_ORGANIZATION);

Where organization has the correct type of boolean.

I've tried adding a generic to readSetting that looks like:

const readSetting = <T extends Record<string, unknown>, K = keyof T>(key: K): T[K] => {
  // ...
};

But it complains that "Type 'K' cannot be used to index type 'T'"

Is it possible to provide a mapped return type like this?

If so, how should I structure K so that it can correctly return the mapped type in T?

Here is an example showing the issue: https://tsplay.dev/m0d2qm


Solution

  • Conceptually you want something like

    declare const readSetting:
        <T extends object, K extends keyof T>(key: K) => T[K]
    

    where K is constrained to keyof T. Unfortunately you can't call a generic function like readSetting<UserSettingsTypes>(UserSettings.DEFAULT_ORGANIZATION) where you manually specify T but have the compiler infer K for you. TypeScript doesn't support partial type argument inference as requested in microsoft/TypeScript#26242. You either have to let the compiler infer both T and K (which is impossible without an argument of type T), or you have to manually specify both T and K (which is redundant for K). No, using a type argument default like K = keyof T doesn't help; you'd just get the default instead of inference.

    Until and unless microsoft/TypeScript#26242 is implemented, you need to work around it somehow. The most common workaround I use here is currying, where you split the single generic function into multiple. In your case that looks like:

    declare const readSetting:
        <T extends object>() => <K extends keyof T>(key: K) => T[K]
    

    Now you can call readSetting<UserSettingsTypes>(), manually specifying T. That returns a new generic function of type <K extends keyof UserSettingsTypes>(key: K) => UserSettingsTypes[K], which you can call with a function argument and K will be inferred for you:

    const organization =
        readSetting<UserSettingsTypes>()(UserSettings.DEFAULT_ORGANIZATION);
    // const organization: boolean
    

    So, while there's that weird function call () in the middle there, it's very close to what you wanted originally and it gives you the return type you expect.

    Playground link to code