1

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.

cprcrack
  • 17,118
  • 7
  • 88
  • 91

2 Answers2

1
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.

HTNW
  • 27,182
  • 1
  • 32
  • 60
  • That's great, thanks for both alternatives. Can you think of any way to actually use the first solution (without having to use the more complex custom `fieldsOf` generic) but be prevented from typing the wrong fields? For example if I do `const DEFAULT_USER_FIELDS: ('_id' | 'usernameee')[] = ['_id', 'usernameee'];` by mistake that will not raise any error until I actually use the constant. – cprcrack Jul 02 '22 at 04:58
  • If anyone finds the `fieldsOf()` function difficult to read, for this particular use-case it can be simplified to `function fieldsOfUser(fields: Field[]) { return fields; }` with usage `const DEFAULT_USER_FIELDS = fieldsOfUser(['_id', 'username']);` – cprcrack Jul 02 '22 at 05:33
0

Completely based on @HTNW's first solution, I found a way to slightly improve it so that I still have to repeat the field names but at least we have type safety in the definition of DEFAULT_USER_FIELDS itself:

const DEFAULT_USER_FIELDS: (keyof Pick<User, '_id' | 'username'>)[] = ['_id', 'username'];

But actually even that is still somewhat error prone because even though we cannot for example mistakenly write usernameee 2 times, the actual value of the array could be empty or have repeated values. After more thinking I think this is the verbose solution that would fully prevent inconsistencies between the declared compile-time types and the run-time values of the array:

const DEFAULT_USER_FIELDS: [
    keyof Pick<User, '_id'>,
    keyof Pick<User, 'username'>
  ] = ['_id', 'username'];

And finally I also found another solution that prevents the repetition without having to use a generic:

const DEFAULT_USER_FIELDS = ['_id', 'username'] as const;

const user2 = getUserById('myId', DEFAULT_USER_FIELDS as unknown as typeof DEFAULT_USER_FIELDS[number][]);
cprcrack
  • 17,118
  • 7
  • 88
  • 91