2

I saw this question which is very close to what I'm trying to accomplish, but it didn't quite work for me because the endpoint is a graphQL endpoint and there's another nested property by the name of the query. For example, if my query is:

const query = `query Query($age: Int!){
    users(age: $age) {
      name
      birthday
    }
  }`;

Then the fetched object from the above linked answer is data.data.users, where the last property comes from the graphql query name itself. I was able to modify the code from the above link to the following:

function graphQLFetch<T>(url: string, query: string, variables = {}): Promise<T> {
  return fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  }).then((response) => {
    if (!response.ok) {
      throw new Error(response.statusText);
    }
    return response.json();
  })
    .then((responseJson) => responseJson.data[Object.keys(responseJson.data)[0]] as Promise<T>);
}

...which works when I'm only providing a single query to the graphQL endpoint. How could I generalize so that it would work for whatever number of queries? For example, what if I know my query will return User[] and Post[], as a tuple, due to the following graphql query:

const query = `query Query($age: Int!, $username: String!){
    users(age: $age) {
      name
      birthday
    }
    posts(username: $username) {
      date
    }
  }`;
}

Then I would like something like the following to work:

const myData = graphQLFetch<[User[], Post[]]>(url, query, variables);

Is something like this possible?

Isaac Torres
  • 151
  • 7

1 Answers1

2

Right now I'd say your problem is this...

responseJson.data[Object.keys(responseJson.data)[0]]

That will only ever return the first value from data.

I'd advise against tuples for this though. Instead, just return the data object typed to your expected response.

Let's start with a generic object type to represent GraphQL data

type GraphQlData = { [key: string]: any, [index: number]: never };

This describes the most generic form the data can take. It's basically a plain object with string keys. The never on numeric indexes prevents it from being an array.

Next, let's describe the GraphQL response form

interface GraphQlResponse<T extends GraphQlData> {
  data: T;
  errors?: Array<{ message: string }>;
}

This represents the response JSON you get from GraphQL, including the previous GraphQlData type or anything that specialises on that. For example, you could specify a particular response type as...

type UsersAndPostsResponse = GraphQlResponse<{ users: Users[], posts: Posts[] }>;

Here, { users: Users[], posts: Posts[] } is a more specialised version of GraphQlData with keys restricted to users and posts and specific value types.

Finally, define the function, incorporating the same generic data type

async function graphQLFetch<T extends GraphQlData>(
  url: string,
  query: string,
  variables = {}
): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) {
    throw new Error(`${res.status}: ${res.statusText}`);
  }

  // cast the response JSON to GraphQlResponse with the supplied data type `T`
  const graphQlRes: GraphQlResponse<T> = await res.json();
  if (graphQlRes.errors) {
    throw new Error(graphQlRes.errors.map((err) => err.message).join("\n")); // you might want to create a custom Error class
  }
  return graphQlRes.data;
}

Then you can make your request like this

const { users, posts } = await graphQLFetch<{ users: User[]; posts: Post[] }>(
  url,
  query
);

Otherwise, you'll want to get all the data values and if more than one, return your tuple instead of a singular record.

In order to support such a generic return type, you'll need to specify T as a union of singular or array type.

Note: There's an inherent risk here that the data values are not in the order you specify. It's much better to use the values by key.

// Note there's no generic type now
interface GraphQlResponse {
  data: GraphQlData;
  errors?: Array<{ message: string }>;
}

async function graphQLFetch<T extends any | Array<any>>(
  url: string,
  query: string,
  variables = {}
): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) {
    throw new Error(`${res.status}: ${res.statusText}`);
  }

  const graphQlRes: GraphQlResponse = await res.json();
  if (graphQlRes.errors) {
    throw new Error(graphQlRes.errors.map((err) => err.message).join("\n"));
  }

  const values = Object.values(graphQlRes.data);
  if (values.length === 1) {
    return values[0] as T;
  }
  return values as T;
}
Phil
  • 157,677
  • 23
  • 242
  • 245
  • Thanks for your thorough response. If I understand correctly, the bottom section of your code is if I insist on getting the data as a tuple, but you recommend the first section which gets it by keys (which is safer and recommended), correct? I don't mind getting it by keys if that's the case. Also, I'm a little new to typescript, could you elaborate on what all is going on in the first code block between the type and interface? Not exactly sure what Record is or why the type needs to be extended. Thanks! – Isaac Torres May 05 '22 at 19:51
  • 1
    That's right, I added the second example just to be thorough but strongly recommend the first. I'll edit my answer to explain the interface I'm using. FYI [Record](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type) is just a utility type. It's like a plain object or map – Phil May 05 '22 at 22:54
  • 1
    @IsaacTorres I've updated my answer with extra details. I've also removed `Record` as I found a better way to represent the data – Phil May 05 '22 at 23:19
  • `graphQLFetch` needs to be declared as `async` – Neil Morgan Jan 09 '23 at 14:26
  • @NeilMorgan it already is – Phil Jan 09 '23 at 20:16