'How to infer types from an imported file in Typescript

I have a tricky Typescript problem I can't seem to solve. I would like to infer values based on a generic. However, the catch is the inferred types needs to come from an imported file.

To explain in an example, this is my generic function.

import { DocumentNode, print } from "graphql"

type GraphqlRequest = <Query, Variables = {}>({ query, variables }: { query: DocumentNode, variables?: Variables }) => Promise<Query>

const fetchQuery: GraphqlRequest = async ({ query, variables = {} }) => {
  return database(JSON.stringify({ query: print(query), variables }))
}

And I use it in the following way:

import { UserBlogItems, UserBlogItemsQuery, UserBlogItemsQueryVariables } from "~/graphql"

fetchQuery<UserBlogItemsQuery, UserBlogItemsQueryVariables>({ query: UserBlogItems, variables: { user } })

Which works great because I can get the correct types checking on the variables and the query response.


What I would like to be able to do is just include the DocumentNode and have Typescript look up the Query and Variables from the "~/graphql` file. This way I can have the function look a lot cleaner.

import { UserBlogItems } from "~/graphql"

fetchQuery({ query: UserBlogItems, variables: { user }})

I can ensure 100% always that in the ~/graphql file the format will be:

  • DocumentNode: [myquery] (eg UserBlogItems)
  • Query: [myquery]Query (eg UserBlogItemsQuery)
  • Variables: [myquery]QueryVariables (eg UserBlogItemsQueryVariables)

Is this possible in Typescript?



Solution 1:[1]

For the function to infer these generics for us, we need to map each document node to its respective query and query variables types. This is done with a list of tuples like this:

type QueryTypeMap = [
    [UserBlogItems, UserBlogItemsQuery, UserBlogItemsQueryVariables],
    [UserInfo, UserInfoQuery, UserInfoQueryVariables],
];

The first element is the document node, followed by the query type, and finally the query variables. You could also write it as this and edit where we access them later to be more explicit:

type QueryTypeMap = [
    [UserBlogItems, { query: UserBlogItemsQuery; vars: UserBlogItemsQueryVariables }],
    // ...
];

Next, we create the type that will retrieve the types for us based on the given document node:

type FindQueryTypes<T> = {
    [K in keyof QueryTypeMap & string]:
        // If this value is a tuple (AKA one of our map entries)
        QueryTypeMap[K] extends ReadonlyArray<unknown>
            // And the document node matches with the document node of this entry
            ? T extends QueryTypeMap[K][0]
                // Then we have found our types
                ? QueryTypeMap[K]
                // Or not
                : never
            // Not even a map entry
            : never;
}[keyof QueryTypeMap & string];

Why is there keyof QueryTypeMap & string? QueryTypeMap is a tuple, which means keyof QueryTypeMap includes the number type, and that would mess with our type trying to find the correct query type. So we can exclude it by only allowing string keys with & string.

We must also be mindful that K, the key of QueryTypeMap, is any key of an array. So for example, it could be indexOf or splice. That is why we must first check if QueryTypeMap[K] is a tuple (meaning it's an entry of our map and not a built-in array property).

Lastly, we use this type in our function type:

type GraphQLRequest = <
    D extends typeof DocumentNode,
    Types extends QueryTypeMap[number] = FindQueryTypes<InstanceType<D>>
>(
    { query, variables }: { query: D, variables?: Types[2] }
) => Promise<Types[1]>;

TypeScript will infer the generic type D (any class constructor that extends DocumentNode is what typeof DocumentNode is) for us, which means we can directly use it without hassle in the second generic type.

We constrain Types to the type QueryTypeMap[number], which is saying it can only be any entry of QueryTypeMap. Now we give this generic its value with our type from above.

You'll see that there's an extra InstanceType there, and that's because D is not UserBlogItems or UserInfo. It's actually typeof UserBlogItems or typeof UserInfo (the constructors). To get the instance types, we use the aptly named built-in type InstanceType.

And after that, we're sitting pretty. We've got the correct query types. Now we just use them.

For the type of variables, we use Types[2] to get the query variables type.

For the return type, we use Types[1] to get the query type.

Once again, the playground, so you can tinker with this yourself now that you've got some understanding of it.

Solution 2:[2]

Instead of doing some dark magic to infer types by using naming conventions. Why don't you normalize the way you export thing from ~/graphql. like such:

//==== ~/graphql ====

interface UserBlogItems extends DocumentNode{
  foo: string;
} 
type UserBlogItemsQuery = ASTNode & {
  bar: string;
} 
interface UserBlogItemsQueryVariables{
  user:string;
}

export interface UserBlogEntity {
    documentNode:UserBlogItems;
    query: UserBlogItemsQuery;
    variables: UserBlogItemsQueryVariables;
}

//-----------------

And the define the fetchQuery like this:

interface GraphqlRequestData {
    documentNode:DocumentNode;
    query: ASTNode;
    variables?: Record<string,any>;
}


const fetchQuery = async <T extends GraphqlRequestData>({ query, variables }: {query:T['documentNode'], variables?:T['variables']}):Promise<T['query']> => {
  return database(JSON.stringify({ query: print(query), variables })) as Promise<T['query']>;
};

So you finaly can use it like this:

const userBlogItems :UserBlogItems = {
    foo: `test`
} as UserBlogItems;
const userBlogVars: UserBlogItemsQueryVariables = {
    user: 'user_name'
}


const run = async()=>{
    const returnQuery = await fetchQuery<UserBlogEntity>({ query: userBlogItems, variables: userBlogVars });
    console.log(returnQuery.bar)
}

and here is the playgorund link: Playground

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 hittingonme
Solution 2 Tiago Nobrega