Search code examples
typescripttypescript-generics

Return type for a function that returns an object with both fixed and variable keys


I'm converting an existing codebase to TypeScript and am going in circles with one particular function.

The goal is to return an object that has a byID key that holds key/value pairs of generic type T and an arbitrary amount of additional fields that each hold an array of strings.

My current approach is:

function generateEmptyState<T>(additionalFields: string[] = []): {byID: {[key: string]: T}, [key: typeof additionalFields[number]]: string[]} {
  return {
    byID: {},
    ...additionalFields.reduce((a,b) => ({...a, [b]: []}), {}),
  }
}

type Post = {
    id: string;
    title: string;
    content: string;
    authorID: string;
    tag: string;
}

const posts = generateEmptyState<Post>(['byAuthorID', 'byTag']);
// Expected type: { byID: {[key: string]: Post }, byAuthorID: string[], byTag: string[] };

But I run into 2 errors: TS2411: Property  byID  of type  { [key: string]: T; }  is not assignable to  string  index type  string[]

and

TS2322: Type  { byID: {}; }  is not assignable to type
{ [key: string]: string[]; byID: { [key: string]: T; }; }
Property  byID is incompatible with index signature.

It seems that the type for byID is always overwritten by the typing for the ...additionalFields.

Any ideas for how to resolve this?


Solution

  • In your code, the type of additionalFields is just string[] no matter what. So typeof additionalFields[number] is just a compicated way to write string. If you want to keep track of the literal types of the elements of the additionalFields argument, you'll need the function to be generic in that type.

    So the first step would be to add another type parameter to represent the union of elements of additionalFields; let's call that K (since they will be used as keys) in the output type:

    function generateEmptyState<T, K extends string>(
      additionalFields: K[] = []
    ) {
      return {
        byID: {} as { [key: string]: T },
        ...(additionalFields.reduce(
          (a, b) => ({ ...a, [b]: [] }), {}) as { [P in K]: string[] }
        )
      }
    }
    

    The return type of that function is { byID: { [key: string]: T } } & { [P in K]: string[] }, which accurately represents the type you want. The type {[P in K]: string[]} is equivalent to Record<K, string[]> (using the Record utility type) and means "a type where each key of type K has a value of type string[].


    Unfortunately you cannot use this easily. If you try to call it the way you wanted:

    const posts = generateEmptyState<Post>(['byAuthorID', 'byTag']);
    // error, expected 2 type arguments, got one
    

    you get an error. The function takes two type arguments now, and you only passed one. You could manually specify both:

    const posts = generateEmptyState<Post, 'byAuthorID' | 'byTag'>(['byAuthorID', 'byTag']);
    /* const posts: {
        byID: {
            [key: string]: Post;
        };
    } & {
        byAuthorID: string[];
        byTag: string[];
    }) */
    

    and that works. It's exactly the type you want. But now you're writing out your additionalFields elements twice, which is redundant.


    Ideally you'd like the compiler to allow you to just write out the argument for T as <Post> and then it would infer the argument for K. That is, you'd like partial type argument inference. But TypeScript doesn't currently support that. There's a longstanding open feature request for this at microsoft/TypeScript#26242, but there's no indication that it will be implemented anytime soon.

    Until and unless that happens you'll need to work around it. You could either just manually specify both types as shown above. Or you could refactor. The most common refactoring I use is to split the single function into two via currying. You call the generic function with a manually specified type argument, and then it returns another generic function which infers the remaining type argument. It looks like this:

    function generateEmptyState<T>() {
      return function <K extends string>(
        additionalFields: K[] = []
      ) {
        return {
          byID: {} as { [key: string]: T },
          ...(additionalFields.reduce(
            (a, b) => ({ ...a, [b]: [] }), {}) as { [P in K]: string[] }
          )
        }
      }
    }
    
    const posts = generateEmptyState<Post>()(['byAuthorID', 'byTag']);
    

    This requires an extra () when you use it, but you're not being redundant with your elements. Currying is especially useful if you think you're going to be reusing generateEmptyState<Post> a lot. Then you can just give that a name and reuse it:

    const generateEmptyPostState = generateEmptyState<Post>();
    const posts1 = generateEmptyPostState(['byAuthorID', 'byTag']);
    const posts2 = generateEmptyPostState(['abc', 'def', 'ghi']);
    const posts3 = generateEmptyPostState([]);
    

    Another refactoring is to add a dummy function argument of the type you would have normally manually specified as a type argument. Something like this:

    function generateEmptyState<T, K extends string>(
      t: T, additionalFields: K[] = []
    ) {
      return {
        byID: {} as { [key: string]: T },
        ...(additionalFields.reduce(
          (a, b) => ({ ...a, [b]: [] }), {}) as { [P in K]: string[] }
        )
      }
    }
    
    const posts = generateEmptyState(
      null! as Post,
      ['byAuthorID', 'byTag']
    );
    

    Now you have to pass in a Post argument if you have one. If you don't, you can just lie to the compiler about it by asserting that some random thing is a Post. The tersest way to do that is null! as Post since null! is a non-null asserted null, which is the impossible never type, and you can assign never to anything. It works, but it's weird... weirder than currying.

    I'd only do this if you actually have a value of type Post sitting around and if generateEmptyState's implementation could possibly use it instead of just ignoring it. That doesn't seem to be the case here, but there are other use cases where this is reasonable.


    So there you go. The basic answer here is "add another generic type parameter" but then, in the absence of partial type argument inference, you have to decide which of the various workarounds you'd like to use.

    Playground link to code