0

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.

2 Answers 2

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.

Sign up to request clarification or add additional context in comments.

2 Comments

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.
If anyone finds the fieldsOf<T>() function difficult to read, for this particular use-case it can be simplified to function fieldsOfUser<Field extends keyof User>(fields: Field[]) { return fields; } with usage const DEFAULT_USER_FIELDS = fieldsOfUser(['_id', 'username']);
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][]);

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.