Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Graphcache strong typing #3538

Open
BickelLukas opened this issue Mar 19, 2024 · 3 comments
Open

RFC: Graphcache strong typing #3538

BickelLukas opened this issue Mar 19, 2024 · 3 comments
Labels
future 🔮 An enhancement or feature proposal that will be addressed after the next release

Comments

@BickelLukas
Copy link

Summary

Currently all the operations on the graphcache are completely untyped. There is a old graphql-codegen project to support typing but that is apparently unmaintained: #2323

Taking the lessons learned from https://gql-tada.0no.co/ it should be possible to add typings to graphcache operations when an introspection schema is provided.

Proposed Solution

I have already played around and come up with a proof of concept that works using the instrospection created by 0no-co/graphqlsp

import { graphql } from "./graphql";
import { introspection } from "./introspection";
import { CacheExchangeOpts, UpdateResolver } from "@urql/exchange-graphcache";
import { GraphQLTadaAPI } from "gql.tada";

type Introspection = typeof introspection;
type IntrospectionTypes = {
  [K in Introspection["__schema"]["types"][number]["name"]]: Introspection["__schema"]["types"][number] extends infer Type
    ? Type extends {
        readonly name: K;
      }
      ? Type extends IntrospectionObjectType
        ? mapObject<Type>
        : Type
      : never
    : never;
};
type IntrospectionMutations = IntrospectionTypes[Introspection["__schema"]["mutationType"]["name"]]["fields"];

type ExtractSchema<T> = T extends GraphQLTadaAPI<infer S, any> ? S : never;
type Schema = ExtractSchema<typeof graphql>;

type Mutations = Schema["types"][Schema["mutation"]]["fields"];
type Subscriptions = Schema["types"][Schema["subscription"]]["fields"];

type getResultType<Name extends string, T> = { [K in Name]: unwrapTypeRec<T, Schema, true> };
type getVariablesType<T> = T extends keyof IntrospectionMutations ? getInputObjectTypeRec<IntrospectionMutations[T]["args"], Schema> : never;

export type TypedCacheExchangeOpts = CacheExchangeOpts & {
  updates: {
    Mutation: {
      [key in keyof Mutations]?: UpdateResolver<getResultType<key, Mutations[key]["type"]>, getVariablesType<Mutations[key]["name"]>>;
    };
    Subscription: {
      [key in keyof Subscriptions]?: UpdateResolver<getResultType<key, Subscriptions[key]["type"]>, getVariablesType<Subscriptions[key]["name"]>>;
    };
  };
};

/* Copied and slightly modified from gql-tada */
interface IntrospectionObjectType {
  readonly kind: "OBJECT";
  readonly name: string;
  readonly fields: readonly any[];
}
interface IntrospectionField {
  readonly name: string;
  readonly type: IntrospectionTypeRef;
  readonly args: readonly any[];
}
interface IntrospectionNamedTypeRef {
  readonly name: string;
}
interface IntrospectionListTypeRef {
  readonly kind: "LIST";
  readonly ofType: IntrospectionTypeRef;
}
interface IntrospectionNonNullTypeRef {
  readonly kind: "NON_NULL";
  readonly ofType: IntrospectionTypeRef;
}
type IntrospectionTypeRef = IntrospectionNamedTypeRef | IntrospectionListTypeRef | IntrospectionNonNullTypeRef;

type IntrospectionLikeType = {
  query: string;
  mutation?: any;
  subscription?: any;
  types: {
    [name: string]: any;
  };
};
type mapField<T> = T extends IntrospectionField
  ? {
      name: T["name"];
      args: T["args"];
    }
  : never;
type mapObject<T extends IntrospectionObjectType> = {
  kind: "OBJECT";
  name: T["name"];
  fields: obj<{
    [P in T["fields"][number]["name"]]: T["fields"][number] extends infer Field
      ? Field extends {
          readonly name: P;
        }
        ? mapField<Field>
        : never
      : never;
  }>;
  args: true;
};

type obj<T> = T extends {
  [key: string | number]: any;
}
  ? {
      [K in keyof T]: T[K];
    }
  : never;

type getInputObjectTypeRec<InputFields, Introspection extends IntrospectionLikeType, InputObject = {}> = InputFields extends readonly [
  infer InputField,
  ...infer Rest,
]
  ? getInputObjectTypeRec<
      Rest,
      Introspection,
      (InputField extends {
        name: any;
        type: any;
      }
        ? InputField extends {
            defaultValue?: undefined | null;
            type: {
              kind: "NON_NULL";
            };
          }
          ? {
              [Name in InputField["name"]]: unwrapTypeRec<InputField["type"], Introspection, true>;
            }
          : {
              [Name in InputField["name"]]?: unwrapTypeRec<InputField["type"], Introspection, true> | null;
            }
        : {}) &
        InputObject
    >
  : InputObject;
type unwrapTypeRec<TypeRef, Introspection extends IntrospectionLikeType, IsOptional> = TypeRef extends {
  kind: "NON_NULL";
  ofType: any;
}
  ? unwrapTypeRec<TypeRef["ofType"], Introspection, false>
  : TypeRef extends {
        kind: "LIST";
        ofType: any;
      }
    ? IsOptional extends false
      ? Array<unwrapTypeRec<TypeRef["ofType"], Introspection, true>>
      : null | Array<unwrapTypeRec<TypeRef["ofType"], Introspection, true>>
    : TypeRef extends {
          name: any;
        }
      ? IsOptional extends false
        ? _getScalarType<TypeRef["name"], Introspection>
        : null | _getScalarType<TypeRef["name"], Introspection>
      : unknown;
type _getScalarType<TypeName, Introspection extends IntrospectionLikeType> = TypeName extends keyof Introspection["types"]
  ? Introspection["types"][TypeName] extends {
      kind: "SCALAR" | "ENUM";
      type: any;
    }
    ? Introspection["types"][TypeName]["type"]
    : Introspection["types"][TypeName] extends {
          kind: "INPUT_OBJECT";
          inputFields: any;
        }
      ? obj<getInputObjectTypeRec<Introspection["types"][TypeName]["inputFields"], Introspection>>
      : Introspection["types"][TypeName] extends {
            kind: "OBJECT";
            fields: any;
          }
        ? getObjectTypeRec<Introspection["types"][TypeName]["fields"], Introspection>
        : never
  : unknown;

type getObjectTypeRec<Fields, Introspection extends IntrospectionLikeType> = Fields extends {
  [key: string]: { name: string; type: any };
}
  ? {
      [K in keyof Fields]?: unwrapTypeRec<Fields[K]["type"], Introspection, true>;
    }
  : never;
@BickelLukas BickelLukas added the future 🔮 An enhancement or feature proposal that will be addressed after the next release label Mar 19, 2024
@kitten
Copy link
Member

kitten commented Mar 19, 2024

This is definitely something we intend to include in gql.tada. The only issue I can foresee is that the introspection output there isn't 100% stabilised, and we plan to actually output the pre-formatted version of it as well.

So, if we write up a version that's targeting the current output in gql.tada (from mapIntrospection) that'd likely help with performance of inference in the future.

There's also some old issues to revisit with the typings and how they're being consumed by Graphcache, but that's likely TBD until we explore this again.

But yea, I'd currently favour absorbing the complexity here into a gql.tada sub-module (under maybe gql.tada/addons/graphcache maybe)
cc @JoviDeCroock

@BickelLukas
Copy link
Author

Including it in gql.tada would definitely be a step in the right direction.
But I was thinking since graphcache already supports a schma aware mode, this could be implemented with any schema that is supplied to graphcache, regardless of where it comes from.
This would decouple it completely from gql.tata and make it compatible with other graphql type generators

@brazelja
Copy link

I figured I'd add what I've been patching into my current project as it might be useful in further implementing this feature.

When using Cache.resolve, I noticed that sometimes a scalar value is returned and other times a reference is returned if the result would have been an object due to the cache normalizing data. It also seemed like it would be nice if there was type-aware autocompletion regarding the field parameter of the resolve function as well, so I came up with the following:

Cache Normalization

import type { DataField, Entity, FieldArgs } from '@urql/exchange-graphcache';

/**
 * This utility type represents a reference returned from `Cache.resolve`,
 * typically looking like `Book:123` but with the full type being referenced
 * stored alongside it in branded-type fashion
 */
type Reference<T> = string & { __def: T };

/**
 * This utility type is used to "normalize" an object type and return it
 * as either a reference type or a scalar.
 */
type Normalize<T> =
  T extends Array<infer U>
    ? Array<Normalize<U>>
    : T extends object // Check if T can be normalized
      ? Reference<T> // If it can, return the normalized reference type
      : T; // If it doesn't, return T

// Example
type Book = {
  __typename: 'Book';
  id: string;
  title: string;
  publicationDate: string;
  author: {
    __typename: 'Author';
    id: string;
    name: string;
  };
};
type BookReference = Reference<Book>;
//   ▲ string & { __def: Book; }

Helpers

/**
 * This helper casts any `Entity` to a reference type, essentially prepping it
 * for use in `Cache.resolve` without actually modifying its underlying type.
 */
const toReference = <T>(entity: Entity): Reference<T> => entity as Reference<T>;

/**
 * This utility type is used to resolve a reference type to its full type
 */
type Resolved<T extends Reference<any>> = T extends Reference<infer U> ? U : T;

@urql/exchange-graphcache Type Override

By overriding the definition of the Cache.resolve function, I am able to
hook into the type E of the entity parameter, which then gives me access to the underlying type being obfuscated by Reference. This can then be leveraged to add autocompletion of the field parameter by limiting it to properties that exist on the resolved type of E. The return type will be the normalized value of the property accessed via F, which will either be a scalar or another Reference.

declare module '@urql/exchange-graphcache' {
  interface Cache {
    // Custom typing
    resolve<E extends Reference<any>, F extends keyof Resolved<E>>(
      entity: E | undefined,
      field: F, // You get type completion here
      args?: FieldArgs,
    ): Normalize<Resolved<E>[F]>;

    // Original typing
    resolve(entity: Entity | undefined, fieldName: string, args?: FieldArgs): DataField | undefined;
  }
}

Usage

Within an optimistic update:

type Book = {
  __typename: 'Book';
  id: string;
  title: string;
  publicationDate: string;
  author: {
    __typename: 'Author';
    id: string;
    name: string;
  };
};

const cache = cacheExchange({
  optimistic: {
    updateBook: (args, cache) => {
      const bookRef = toReference<Book>({ __typename: 'Book', id: args.id });
      //    ▲ Reference<Book>

      return {
        __typename: 'Book',
        id: args.id,
        title: args.title,
        author: () => {
          //                                       ▼ intellisense limits this to keyof Book
          const authorRef = cache.resolve(bookRef, 'author');
          //    ▲ Reference<{
          //         __typename: 'Author';
          //        id: string;
          //        name: string;
          //      }>

          return {
            __typename: 'Author',
            //                           ▼ intellisense limits this to '__typename' | 'id' | 'name'
            id: cache.resolve(authorRef, 'id'), // string
            //                                   ▼ intellisense limits this to '__typename' | 'id' | 'name'
            name: () => cache.resolve(authorRef, 'name'), // string
          };
        },
      };
    },
  },
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
future 🔮 An enhancement or feature proposal that will be addressed after the next release
Projects
None yet
Development

No branches or pull requests

3 participants