Home > Software engineering >  Return an object that respects the arguments suggested in the function
Return an object that respects the arguments suggested in the function

Time:01-11

I have an interface User which defines the following structure:

interface User extends Model {
    id: number;
    name: string;
    age: number;
}

And I have a function called select<T extends Model>(... columns: (keyof T)[]) whose T can be represented by User. In columns I can only use one of the keyof T arguments (that is, in User, it would be "id", "name" and/or "age").

I can use it as follows:

select<User>("id", "name"); // Works as expected.
select<User>("id", "email"); // Fails as expected ("email" doesn't exists in `User`).

As you can see, the rule works perfectly. Both for the case of correct use (using only the keyof User) and notifying invalid uses (like using "email", which was never defined in keyof User).

However, I need the return type of this function to be a partial object of T that only includes the keyof T that were also used as an argument to the function:

const example = select<User>("id", "name");

typeof example === { "id": number, "name": string } // But not includes "age", for instance.

That way I could use example consistently with the arguments I passed to select() in its own definition.


Edit #1: one of the "quasi-solutions" I got was this way:

function select<T extends Model>(... columns: (keyof T)[]): 
    { [Key in keyof T]: string } { ... }

When using the following syntax (note the lack of defining the generic T as User):

const user = select('id', 'name');
user.id; // Works as expected.
user.name; // Works as expected.
user.email; // Fails as expected.

However, this way the code loses the ability to know that it is about a User (as T), although it respects the definitions of the names passed as an argument.

So the following code is valid, generates a valid response, but is incompatible with User:

const user = select('anything');
user.anything; // Works, but not is present on `User`.

Which means that if I use a Pick<User, "id" | "name"> should work, however, I will lose all Typescript versatility, and the calls will be quite redundant:

const user = select<Pick<User, "id" | "name">>("id", "name");

CodePudding user response:

To make this work you need two generic type parameters. One for the object that you want to enforce keys of, and one for those keys.

With both of those you can then Pick<T, K> to get a type of just those keys on that object.

But the problem here is that You have one explicit type parameter (the object type) and one implicit type parameter that you want to infer from usage. However, Typescript requires that all type parameters are explicit, or all implicit. You cannot mix and match them.

So this can't work:

declare function select<T extends Model, K extends keyof T>(...keys: K): any
select<User>('id') // Error: Expected 2 type arguments, but got 1.
select<User, 'id'>('id') // works but terribly verbose

The work around is typically to split up the type parameters into two functions.

declare function select<T extends Model>():
  <K extends keyof T>(... columns: K[]) => Pick<T, K>

And use it like so:

const a = select<User>()("id", "name"); // Works as expected.
//                    ^ call the first function to lock in the <User> type

Here you have one function that is intended to be called with an explicit type parameter. That function returns another function that expect the object keys to be inferred from the arguments. By splitting this into two function you can split up the assignment of the type parameters.

Playground


Although if you can expect an actual object value the infer the object type from, this gets easier:

declare function select<
  T extends Model,
  K extends (keyof T)[]
>(
  obj: T,
  ...keys: K
): Pick<T, K[number]>

declare const user: User
const a = select(user, "id", "name"); // Works as expected.
const b = select(user, "id", "email"); // Fails as expected ("email" doesn't exists in `User`).

Playground

  • Related