Search code examples
typescriptautocompletereturn-typetypeofkeyof

TypeScript Autocomplete Nested Input Object Key on Nested Return Object


I'm trying to create a factory that takes in a config and returns an object with some autocomplete ability. The functionality is there, but I'm stuck on getting a proper type autocompletion on part of the return value.

This is effectively what I'm trying to do.

const result = createSomething({
  apis: {
    foo: { baseURL: "http://localhost:3000" },
    bar: { baseURL: "http://localhost:3001" },
  },
});

// foo & bar should autocomplete immediately and be known as type `AxiosInstance`
result.apis.foo
result.apis.bar

Here is the broken implementation (autocomplete not working). I know this should be possible, and I know the type is getting lost in the Object.entries stuff somewhere, but I've tried casting and doing all kinds of crazy things without good result.

I was under the impression that keyof typeof config.apis was all that I needed, but the autocomplete key isn't narrowing to anything other than "string".

const createSomething = (config: {
  apis: Record<string, CreateAxiosDefaults>;
}) => {
  const apis = Object.entries(config.apis).reduce(
    (acc, [apiKey, axiosConfig]) => {
      acc[apiKey] = axios.create(axiosConfig);
      return acc;
    },
    {} as Record<keyof typeof config.apis, AxiosInstance>
  );

  return {
    apis,
  };
};

The below code actually works to autocomplete, but it doesn't help me because I need to effectively map all the axios configs to created axios instances.

const createSomething = (config: {
  apis: Record<string, CreateAxiosDefaults>;
}) => {
  return {
    apis: {
      foo: axios.create(),
      bar: axios.create()
    }
  };
};

Is there an alternative to my Object.entries approach that would preserve the typing? I feel like I'm missing something very obvious.

The structure of the input and output needs to stay the same because, in reality, I'm providing more keys on the config and on the output, and I would like to preserve that interface.


Solution

  • You need a type parameter in order to have dynamic input/output for a function.

    declare function createSomething<T extends string>(config: {
      apis: Record<T, CreateAxiosDefaults>;
    }): { apis: Record<T, AxiosInstance> }
    

    Unfortunately, building a dynamic object isn't very TS-friendly. In my experience, the easiest way to do this is to use a non-generic implementation signature that TS will only use to type check your implementation.

    function createSomething<T extends string>(config: {
      apis: Record<T, CreateAxiosDefaults>;
    }): { apis: Record<T, AxiosInstance> }
    function createSomething(config: {
      apis: Record<string, CreateAxiosDefaults>;
    }) {
      const apis = Object.entries(config.apis).reduce(
        (acc, [apiKey, axiosConfig]) => {
          acc[apiKey] = axios.create(axiosConfig);
          return acc;
        },
        {} as Record<string, AxiosInstance>
      );
    
      return {
        apis,
      };
    };