Search code examples
typescriptgenericstypesconstantskeyof

In Typescript, how can I pass an array of (keyof Object) without having to hardcode it every time? How on earth can this be so complicated?


So I have the following very simple User interface and getUserById() method that retrieves a type-safe user that includes only the specified fields/properties (what is usually called a projection):

interface User {
  _id: string,
  username: string,
  foo: string
}

function getUserById<Field extends keyof User>(
  id: string,
  fields: Field[]
): Pick<User, Field> {   
  // In real-life an object with only the specified fields/properties is returned
  // and the id and fields parameters are both used as part of this process
  return {} as User; 
}

This is working perfectly as demonstrated in the usage bellow, because user1 is fully type-safe and accessing user1.foo will raise a Typescript error:

const user1 = getUserById('myId', ['_id', 'username']);
console.log(user1); // user1 is correctly typed as Pick<User, '_id' | 'username'>
console.log(user1.foo); // We get the error: Property 'foo' does not exist. Great!

However now I want to do what I thought should be a fairly easy thing. As I use the ['_id', 'username'] parameter a lot of times, I want to create a DEFAULT_USER_FIELDS constant for it so that I can re-use it everywhere:

const DEFAULT_USER_FIELDS: (keyof User)[] = ['_id', 'username'];

const user2 = getUserById('myId', DEFAULT_USER_FIELDS);
console.log(user2); // user2 is INCORRECTLY typed as Pick<User, keyof User>
console.log(user2.foo); // We do NOT get any error. This is bad!

However as you can see, with this simple change, the type safety of user2 is lost, and now I do not get any error when accessing user2.foo, which is really bad. How can I solve this? I have tried countless things for hours and haven't really found a working solution.

Edit: Code Sandbox with exactly the same code where Typescript errors can be seen and played with.


Solution

  • const DEFAULT_USER_FIELDS: ('_id' | 'username')[] = ['_id', 'username'];
    

    The reason getUserById ever works in the first place is because Field will be inferred to the union of the elements of fields, which will be more specific than just keyof User (Pick<User, keyof User> is just User). Without the function call to guide inference of the list's type you have to give that type yourself.

    You could avoid repeating the fields by repeating the trick from getUserById instead:

    function fieldsOf<T>(): <Field extends keyof T>(fields: Field[]) => Field[] {
      return fields => fields
    }
    
    const DEFAULT_USER_FIELDS = fieldsOf<User>()(['_id', 'username']);
    

    DEFAULT_USER_FIELDS has the same type and value either way.