lcsellers
lcsellers

Reputation: 23

Typescript: Mapped return type based on keys present in argument

Let's say I'm writing some database interface with which I can submit multiple queries at once as an object keyed by table names, and expect to receive an object with results at the same keys that were in the query.

interface Query {
    users?: UsersQuery
    products?: ProductsQuery
    orders?: OrdersQuery
}
interface Result {
    users?: User[]
    products?: Product[]
    orders?: Order[]
}

declare function readData(query: Query): Result;

Is there any way to be more specific about the return type based on which keys are actually present in the passed in object?

It seems like I'm pretty close with something like this:

declare function readData<T extends Query>(query: T): { [K in keyof T]: T[K] }

But instead of the mapped return type using T[K], I need it to use Result[K], which doesn't compile (with error TS2536: Type 'K' cannot be used to index type 'Result'). Based on the docs, I'm guessing it's because anything other than T[K] isn't homomorphic, though I'm not super clear on that.

Is there something I'm missing here in using mapped types, or is there some other way to do this?

Upvotes: 2

Views: 228

Answers (3)

Tyler Church
Tyler Church

Reputation: 669

You're on the right track!

I'd do something like this:

// Create a mapping between the query type used, and the result type expected:
type QueryResult<T> =
    T extends UsersQuery ? User[] :
    T extends ProductsQuery ? Product[] :
    T extends OrdersQuery ? Order[] :
    never
;

// Same as what you started with, but using our new QueryResult<T> mapping:
declare function readData<T extends Query>(query: T): { [K in keyof T]: QueryResult<T[K]> };

And that should give you the correct typings based on which things you pass in.

For reference, I tested this with the following interfaces:

interface UsersQuery {
    user_id: string;
}

interface ProductsQuery {
    product_id: string;
}

interface OrdersQuery {
    order_id: string;
}

interface User {
    _id: string;
    userName: string;
}

interface Product {
    _id: string;
    productName: string;
}

interface Order {
    _id: string;
    orderName: string;
}

interface Query {
    users?: UsersQuery;
    products?: ProductsQuery;
    orders?: OrdersQuery;
}

Upvotes: 0

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250406

One of the more unexpected consequences of how types work in this case is that since T extends Query it can have not just properties of Query, but, as long as there is some overlap, it can have any key which might not be desirable for checking if the query object is correct:

declare function readData<T extends Query>(query: T): { [K in keyof T]: T[K] }

readData({
    bla: 1, // not in Query but the compiler is fine, no excess property checks since T just has to extend Query
    orders: null
})

This is also the reason for the error you are getting T may have other keys beside those in query.

One option is to specify the argument to the function is a Pick of Query with the keys being picked being the type parameter to the function:

declare function readData<K extends keyof Query>(query: Pick<Query, K>): { [P in K]: Result[K] }


readData({
    users: null!,
    bla: 0 // error now
})

While this does type check as expected the problem is it will not offer code completion on the keys of the object literal, which is unfortunate.

If we add an intersection with the partial of Query, we get back good code completion and we capture in K the actual keys passed in (although they can de undefined, but you probably were already checking for that)

declare function readData<K extends keyof Query>(query: Pick<Query, K> & Partial<Query>): { [P in K]: Result[K] }


readData({
    users: null!,
    // we get suggestions here
})

Upvotes: 1

p.s.w.g
p.s.w.g

Reputation: 149108

Start by generalizing the UserQuery, ProductQuery, etc. to use a common, generic base type. For example:

// Entity definitions
type User = { userId: string };
type Product = { productId: string };
type Order = { orderId: string };

type EntityQuery<T> = { [K in keyof T]: T[K] }
type ResultOf<T> = T extends EntityQuery<infer I> ? I[] : undefined;

Then define your Query interface like this:

interface Query {
    users?: EntityQuery<User>
    products?: EntityQuery<Product>
    orders?: EntityQuery<Order>
}

Now you can make readData as generic like this:

declare function readData<T extends Query>(query: T): { [K in keyof T]: ResultOf<T[K]> };

Upvotes: 1

Related Questions