简体   繁体   中英

Keys that sometimes exist on a typescript object depending on parameters

I have an object that takes a lot of time to construct fully (because it requires querying multiple database tables), so clients of that object specify which parts they need. For instance, let's say that there's a User object that sometimes has Purchases and sometimes has Friends. Right now, I'm using optional properties for that:

interface User {
  id: number
  purchases?: Purchase[]
  friends?: Friend[]
  friendsOfFriends?: Friend[]
}

And clients of a User could say getUser(['purchases']) to get a user where the purchases key is defined.

However, that means that any time I use purchases , friends , or friendsOfFriends , I need to tell typescript that the type is there (eg, user.purchases![0] or user.purchases && user.purchases[0] ), which is a little annoying.

Is there some way to tell Typescript that the passed in parameter determines which keys are present on the returned value? Eg:

  • getUser([]) returns an {id: number}
  • getUser(['purchases']) returns an {id: number; purchases: Purchase[]} {id: number; purchases: Purchase[]}
  • getUser(['network']) returns an {id: number; friends: Friend[]; friendsOfFriends: Friend[]} {id: number; friends: Friend[]; friendsOfFriends: Friend[]} {id: number; friends: Friend[]; friendsOfFriends: Friend[]} -- note that network gets us friends and friendsOfFriends
  • getUser(['purchases', 'network']) returns an {id: number; purchases: Purchase[]; friends: Friend[]; friendsOfFriends: Friend[]} {id: number; purchases: Purchase[]; friends: Friend[]; friendsOfFriends: Friend[]}

In the real case, there are more than 2 possible keys to include, and I don't want to make combinatorially many overloaded types.

Is this possible in Typescript?

Thanks!

I'm going to leave the implementation of getUser() us you, as well as convincing the compiler that your implementation meets the type definition. That is, I'm acting like getUser() is out in pure JavaScript land, and I'm just coming up with its type declaration so that the compiler can correctly handle calls to it.

First, the compiler needs some way of knowing about the mapping between arguments to getUser() and which sets of User keys they pick out. Something like this, given your example:

interface KeyMap {
    purchases: "purchases",
    network: "friends" | "friendsOfFriends"
}

Armed with that, we need to tell the compiler how to calculate UserType<K> for some set of keys K from KeyMap . Here's one way to do it:

type UserType<K extends keyof KeyMap> =
    User & Pick<Required<User>, KeyMap[K]> extends 
    infer O ? { [P in keyof O]: O[P] } : never;

The important bit is User & Pick<Required<User>, KeyMap[K]> : The result is always going to be a User so we include that in an intersection . We also take KeyMap[K] , the User keys pointed out by K , and Pick these properties from Required<User> . The Required<T> type makes all optional keys into required ones.

The next bit that starts extends infer O... is really just using a conditional type inference trick to take the ugly type User & Pick... and convert it into a single object type with spelled out properties. If you prefer to see User & Pick<Required<User>, "purchases"> instead of the object types below, you can remove everything starting with extends there.

Finally, the getUser() function typing looks like this:

declare function getUser<K extends keyof KeyMap>(keys: K[]): UserType<K>;

It takes an array of KeyMap keys and returns UserType<K> for those keys.


Let's make sure it works, using your example calls:

const user = getUser([]);
/* const user: {
    id: number;
    purchases?: Purchase[] | undefined;
    friends?: Friend[] | undefined;
    friendsOfFriends?: Friend[] | undefined;
} */

const userWithPurchases = getUser(['purchases']);
/* const userWithPurchases: {
    id: number;
    purchases: Purchase[];
    friends?: Friend[] | undefined;
    friendsOfFriends?: Friend[] | undefined;
} */

const userWithNetwork = getUser(['network']);
/* const userWithNetwork: {
    id: number;
    purchases?: Purchase[] | undefined;
    friends: Friend[];
    friendsOfFriends: Friend[];
} */

const userWithEverything = getUser(['purchases', 'network']);
/* const userWithEverything: {
    id: number;
    purchases: Purchase[];
    friends: Friend[];
    friendsOfFriends: Friend[];
} */

Those types look right to me.

Playground link to code


Well, looking over your example again, you didn't have the optional properties included in the output as optional; they were just omitted. If you really want to see that match your types exactly it's a bit more involved:

type RequiredKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? never : K }[keyof T];

type UserType<K extends keyof KeyMap> =
    Pick<User, RequiredKeys<User>> & Pick<Required<User>, KeyMap[K]> extends
    infer O ? { [P in keyof O]: O[P] } : never;

declare function getUser<K extends keyof KeyMap>(keys: K[]): UserType<K>;


const user = getUser([]);
/* const user: {
    id: number;
} */

const userWithPurchases = getUser(['purchases']);
/* const userWithPurchases: {
    id: number;
    purchases: Purchase[];
} */

const userWithNetwork = getUser(['network']);
/* const userWithNetwork: {
    id: number;
    friends: Friend[];
    friendsOfFriends: Friend[];
} */

const userWithEverything = getUser(['purchases', 'network']);
/* const userWithEverything: {
    id: number;
    purchases: Purchase[];
    friends: Friend[];
    friendsOfFriends: Friend[];
} */

Playground link to this code


Whichever way you want to go, or some other way, is up to you. Okay, hope that helps; good luck!

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM