Working efficiently with GraphQL-CodeGen types in TypeScript

In our project, we’re using GraphQL, and we’re using graphql-codegen to generate types for our queries. While this is very convenient, it’s almost impossible to extract sub-types from these generated types. Let me explain.

For a query like this

query AllUsersWithComments {
  allUsers {
    nodes {
      commentsByUserId(first: 50) {
        nodes {
          id
          content
          nodeId
          postId
        }
      }
      email
      id
      nodeId
    }
  }
}

a generated type would look like this:

export type AllUsersWithCommentsQuery = { __typename?: "Query" } & {
  allUsers: Maybe<
    { __typename?: "UsersConnection" } & {
      nodes: Array<
        Maybe<
          { __typename?: "User" } & Pick<User, "email" | "id" | "nodeId"> & {
              commentsByUserId: { __typename?: "CommentsConnection" } & {
                nodes: Array<Maybe<{ __typename?: "Comment" } & Pick<Comment, "id" | "content" | "nodeId" | "postId">>>;
              };
            }
        >
      >;
    }
  >;
};

Extracting the returned type

So if we wanted to extract the returned type for a Comment for this, we could not just do something like

type Comment = AllUsersWithCommentsQuery["allUsers"]["nodes"]["commentsByUserId"]["nodes"];

First problems would be the arrays, we can’t just skip those. A more correct type would look like this:

type Comment = AllUsersWithCommentsQuery["allUsers"]["nodes"][number]["commentsByUserId"]["nodes"][number];

But that wouldn’t work either. All those intermediate types are Maybe<…>-wrapped. They are nullable.

Now you might say that’s the fault of our GrahpQL schema, but our schema is absolutely correct. We’re using row-level security, so depending on the user’s permissions, they might have access to none of these properties. Everything can be null.

So how to correctly access those values?

Accessing the values done right

type Comment = NonNullable<
  NonNullable<
    NonNullable<
      NonNullable<NonNullable<NonNullable<AllUsersWithCommentsQuery["allUsers"]>["nodes"]>[number]>["commentsByUserId"]
    >["nodes"]
  >[number]
>;

Ouch! That’s not feasible. So, what do we do?

„Easy.“ We write our own helper so we can do something like

type Comment = DeepExtractTypeSkipArrays<AllUsersWithCommentsQuery, ["allUsers", "nodes", "commentsByUserId", "nodes"]>;

Doesn’t that look better? Yes? Then let’s get started!

The building blocks

Head and Tail

To do this, we’re gonna have to do tail recursion on our path tuple. So we need to split ['allUsers', 'nodes', 'commentsByUserId', 'nodes'] up into the Head allUsers and the Tail ['nodes', 'commentsByUserId', 'nodes'].

Getting the head is quite simple: type Head<Path extends [...any[]]> = Path[0];

As Head<Path> is longer than writing Path[0], we’re gonna inline that one in the future.

Getting the tail is a little less straightforward. We can take a look at the library typescript-tuple to get an idea on how they’re doing it:

type Tail<Tuple extends any[]> = ((...args: Tuple) => any) extends (_: any, ..._1: infer Rest) => any ? Rest : never;

Like most tuple operations in TypeScript, we can’t just work on a tuple, but we have to use that tuple in a method argument position and infer from there.

Do you think this is complicated? You really don’t want to see the code to get the Last element of a tuple … Anyways, we’ve got a type here, so let’s be happy about it and use it.

Ignoring undefined|null and skipping Arrays

Extracting our data from these Maybe types isn’t that hard. TypeScript already ships with the NonNullable<...> generic that makes short work of those. But how do we deal with arrays?

Having worked with too many conditional types in my life, my first instinct here was to create a type like

type ExtractArrayValue<A> = A extends Array<infer V> ? V : A;

and that type seems to work. If it is passed an array, it returns the type of the array value, otherwise it returns the value it is passed. But there’s an edge case. If you have a type like

type UserConnectionNode = Array<{ name: string }> & Array<{ title: string }>;

it won’t extract {name: string; title: string} like you’d expect. It pattern matches the first thing it finds and returns {name: string}. Highly problematic.

Fortunately, there’s a much simpler way to extract the value of an array. Just index it – so for an array we can go with A[number]. Our type would look like this:

type ExtractArrayValue<A> = A extends Array<unknown> ? A[number] : A;

The returned value would still be {name: string} & {title: string} – which is a little ugly, but we can worry about that later. At least it’s correct.

Now, to combine both we create a type:

type NonNullSkipArray<T> = NonNullable<T> extends infer T1
  ? T1 extends unknown[]
    ? NonNullable<T1[number]>
    : T1
  : never;

The outermost conditional would remove a second layer of null, so something like NonNullSkipArray<null | Array<null | string>> would become string. Let’s move on.

Recursion

So now, we have everything to build our DeepExtractTypeSkipArrays type. We’ll do this recursively, so something like

DeepExtractTypeSkipArrays<AllUsersWithCommentsQuery, ["allUsers", "nodes", "commentsByUserId", "nodes"]>

should call
DeepExtractTypeSkipArrays<AllUsersWithCommentsQuery["allUsers"],
["nodes", "commentsByUserId", "nodes"]>,
DeepExtractTypeSkipArrays<AllUsersWithCommentsQuery["allUsers"]["nodes"],
["commentsByUserId", "nodes"]> and so on, ignoring all nullable values and arrays on the way.

So we have two rules for now:

  • If Path is an empty array, we have reached our stop condition
  • If Path is not empty, call DeepExtractTypeSkipArrays<Source[Head<Path>], Tail<Path> and recurse deeper.

This might look like this:

type DeepExtractTypeSkipArrays<Source, Path extends [...any[]]> = Path extends [any, ...any[]]
  ? DeepExtractTypeSkipArrays<NonNullSkipArray<Source[Path[0]]>, Tail<Path>>
  : Source;

But TypeScript doesn’t like that:

Type alias ‚DeepExtractTypeSkipArrays‘ circularly references itself.ts(2456)

This is because types are evaluated eagerly, and the TypeScript compiler will get into an infinite recursion. Well, types are eager, but interfaces are lazy. So we can kinda circumvent this limitation by putting this into an interface and fishing it out when it is actually accessed. And because the compiler still gets a little eager if the key we are using to extract the value is known, we have to use a key that is not known beforehand. Let’s just use our Path-Head for that, since that seems to work fine 🤷.

type DeepExtractTypeSkipArrays<Source, Path extends [...any[]]> = Path extends [any, ...any[]]
  ? { [K in Path[0]]: DeepExtractTypeSkipArrays<NonNullSkipArray<Source[K]>, Tail<Path>> }[Path[0]]
  : Source;

Yup. Nobody can possibly read that. That’s why I’m writing this blog post: to explain it before I forget what I did here. Oh, and it works™️.

Error Handling

This type does not handle errors for now. So if you have a typo somewhere in that Path of yours, you’ll get unknown back. Not too user-friendly. Let’s add an early-exit case to our recursion. We can’t make TS throw an error, but at least we can return some kind of readable error.

  • If Path is an empty array, we have reached our stop condition
  • If Path is not empty, and Head<Path> is a key of Source call DeepExtractTypeSkipArrays<Source[Head<Path>], Tail<Path>, and recurse deeper.
  • If Path is not empty, but Head<Path> is something else, return an error object describing what went wrong.
type KeyNotFoundTypeError<O, K> = {
  error: "key not found";
  path: K;
  object: O;
};

type DeepExtractTypeSkipArrays<Source, Path extends [...any[]]> = Path extends [keyof Source, ...any[]]
  ? { [K in Path[0]]: DeepExtractTypeSkipArrays<NonNullSkipArray<Source[K]>, Tail<Path>> }[Path[0]]
  : Path extends [any, ...any[]]
  ? KeyNotFoundTypeError<Source, Path[0]>
  : Source;

This starts getting usable.

Cleaning it up

Remember that {name: string} & {title: string} we had when extracting values of arrays? Let’s merge that before we wrap it up. To do that, we can use a type like this: type Id<T> = { [K in keyof T]: T[K] } & {};

If we were to skip that & {}, the TS compiler would see that this is an identity type and optimize it away, but like this is does exactly what we want. {name: string} & {title: string} finally becomes { name: string; title: string }.

Now, some edge cases: Right now, we don’t handle if the input source is nullable. So let’s rename our DeepExtractTypeSkipArrays type to _DeepExtractTypeSkipArrays and create a wrapper around it:

export type DeepExtractTypeSkipArrays<Source, Path extends [...string[]]> = Id<
  NonNullSkipArray<_DeepExtractTypeSkipArrays<NonNullable<Source>, Path>>
>;

And we’re finally done.

It’s already on npm!

Of course, you don’t have to write this on your own. It’s already on npm: ts-deep-extract-types

Also, that one has some sweet type tests, so you won’t have to worry about that stuff.

So, the plan is obvious:

  • install ts-deep-extract-types
  • extract some types
  • ???
  • profit!

Für neue Blogupdates anmelden:


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.