2

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?

Charklewis
  • 4,427
  • 4
  • 31
  • 69
  • No but you can write a type to automatically "fill" those generics in based on the type of `query`. – kelsny May 01 '22 at 14:30
  • Agreed, but you can apply some tooling so you don't have to manually implement everything. I've used https://www.graphql-code-generator.com/ pretty heavily in the past. – Todd May 02 '22 at 14:39
  • @catgirlkelly do you mind providing an example of what you mean by filling the generics? – Charklewis May 03 '22 at 05:48
  • @Todd I am using that tool at the moment, but can't seem to get it generate a type that has the document, query and variables on it. – Charklewis May 03 '22 at 05:49
  • 2
    Is [this solution](https://tsplay.dev/m0ygOW) acceptable for you? You will have to still manually fill in the type map but in return your function automatically "fills" in the generics based on the type of `query`. If it is alright I will elaborate more as an answer posted on my other account later. –  May 13 '22 at 14:58
  • @outoftouch I have had a bit of a look and not sure I really understand the details of it. It does look like you have solve the problem though! – Charklewis May 15 '22 at 05:57
  • Having to manually fill in a type map is very do-able for me. It will clean up my code a lot so I don't mind this. – Charklewis May 15 '22 at 05:58

2 Answers2

2

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.

kelsny
  • 23,009
  • 3
  • 19
  • 48
0

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

Tiago Nobrega
  • 505
  • 4
  • 8