Typesafe wrap of autogenerated functions and then call dynamically by just using `__typename` as parameter. Typescript
Asked Answered
E

2

5

I have fully typesafe auto-generated code by the awesome graphql-codgen/vue. I use it in my project by building a little wrapper so my Users don't have to do common config tasks every call. Like e.g. defining cache-behavior, auto-update cache, destructuring result in right types and format.

The wrapper workers with JS and with any but I also want it to be typesafe and since graphql-codegen already generates all types and methods in a typesafe way, I figure there has to be a way to do this. Somehow with discriminating unions I figure...

So boiled down to example code my question is: I have this autogenerated code:

//File GQLService.ts
export type CustodiansList = (
  { __typename: 'Query' }
  & { custodiansList?: Maybe<Array<(
    { __typename: 'Custodian' }
    & Pick<Custodian, 'id' | 'name' | 'street' | 'zip' | 'city' | 'telephone' | 'createdAt' | 'updatedAt'>
  )>> }
);

type ReactiveFunctionCustodiansList = () => CustodiansListVariables

/**
 * __useCustodiansList__
 *
 * To run a query within a Vue component, call `useCustodiansList` and pass it any options that fit your needs.
 * When your component renders, `useCustodiansList` returns an object from Apollo Client that contains result, loading and error properties
 * you can use to render your UI.
 *
 * @param baseOptions options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
 *
 * @example
 * const { result, loading, error } = useCustodiansList(
 *   {
 *   }
 * );
 */
export function useCustodiansList(variables?: CustodiansListVariables | VueCompositionApi.Ref<CustodiansListVariables> | ReactiveFunctionCustodiansList, baseOptions?: VueApolloComposable.UseQueryOptions<CustodiansList, CustodiansListVariables>) {
          return VueApolloComposable.useQuery<CustodiansList, CustodiansListVariables>(CustodiansListDocument, variables, baseOptions);
        }

export type CustodiansListCompositionFunctionResult = ReturnType<typeof useCustodiansList>;

and now I want to just use it "dynamically" like this with the least amount of DRY:

import * as Service from "./GQLService"; // from above
// e.g. typename = "custodian"
function useQueryList(typename:string) {
 const fnName = toFunctionName(typename) // e.g. useCustodiansList
 const result = Service[fnName](); //! this is the problem

 // we also want to return everything including a parsedResult 
 const listName = `${typename}sList`
 return {
    [listName]: parseResult(result),
    ...result
  }
}

INTENT

I really don't want to have to recreate all the work done by graphql-codgen by having to create a discriminated union TypeTable like in some other question answered, because I figure all that work is already done by graphql-codegen.

My goal would be that somebody could just create a new ExamplesList.graphql, graphql-codegen wraps it and then its ready to use by useQueryList("example")

So although this is a dynamically passed parameter, it also has to be possible to get the right static types by somehow map the return types of all Service functions and then get the one that return a Array<__typename> or am I wrong? And I think I have to somehow have to boil down typename parameter from a string to a string literal by parsing all possibles __typenames from Service

const result = Service[fnName](); //! this is the problem

is not actually all we are doing, we wrap and transform it some more, but once I get the correct type here, everything should be fine.

Eolanda answered 16/5, 2020 at 9:38 Comment(0)
J
3

I think this question is more related to TypeScript than to GraphQL Codegen. Basically, what you are trying to do is to get a function property from an object dynamically, I'm not sure that's possible with TypeScript without adding something to the codegen output.

You can create a custom codegen plugin that will generate an object based on all your queries, with the singular key that you wish to have (or, maybe just the operation name). This way you'll be able to get a mapping between "example" and useExamplesListQuery.

Jamboree answered 17/5, 2020 at 7:12 Comment(3)
Thanks for following this up. I think it has to be possible somehow though, because all the information is already statical provided by your project. But yeah maybe it's better to build graphql-codegen-pluginEolanda
It is possible, but only with generated code as far as I know. I think there is no way to do string manipulation in TypeScript types system.Jamboree
I forgot I had the String manipulation still in there, sry! I would settle for have a string literal generated out of all generated __typenames with the more general ones like Query omitted. Something like this: typescript function useQueryList<T extends keyof AllAutogeneratedFunctions>(fnName:T) { const result = Service[fnName](); //! this is the problem // we also want to return everything including a parsedResult return { [ReturnType<Service[fnName]>["result"]]: parseResult(result), ...result } } But I think I then can just call Fn direct.Eolanda
O
3

I played around with your setup a bit since I found it super interesting!.

In this case you need to do some TypeScript forensics :) With the help of the mapped types I was able to put together the following solution. I didn't know what your parsing function does so I let it return unknown but that should be an easy thing to fix.

// Basic shape of a query result with __typename.
//
// I know your example only worked with lists,
// I added the singular form just in case :)
type QueryResultWithTypeName<T> = { __typename: T } | Array<{ __typename: T }>;

// A __typename (Custodian etc) based on a query result (CustodiansList etc)
type TypeNameForResult<R> = NonNullable<
  {
    [K in keyof R]: NonNullable<R[K]> extends QueryResultWithTypeName<infer T> ? T : never;
  }[keyof R]
>;

// A result property name (custodiansList etc) based on a query result object (CustodiansList etc)
type PropertyNameForResult<R> = NonNullable<
  {
    [K in keyof R]: NonNullable<R[K]> extends QueryResultWithTypeName<string> ? K : never;
  }[keyof R]
>;

// List of all available type names (Custodian etc)
type TypeName = {
  [K in keyof ServiceType]: ServiceType[K] extends () => UseQueryReturn<infer TResult, any>
    ? TypeNameForResult<TResult>
    : never;
}[keyof ServiceType];

// Map of type names (Custodian etc) and functions (useCustodianList etc)
//
// e.g. type UseCustodiansList = FunctionByTypeName['Custodian']
type FunctionByTypeName = {
  [K in TypeName]: {
    [L in keyof ServiceType]: ServiceType[L] extends () => UseQueryReturn<infer TResult, any>
      ? TypeNameForResult<TResult> extends K
        ? ServiceType[L]
        : never
      : never;
  }[keyof ServiceType];
};

// Map of type names (Custodian) and property names (custodiansList etc)
//
// e.g. type CustodianProperty = PropertyNameByTypeName['Custodian'] // will be 'custodiansList'
type PropertyNameByTypeName = {
  [K in keyof FunctionByTypeName]: FunctionByTypeName[K] extends () => UseQueryReturn<infer TResult, any>
    ? PropertyNameForResult<TResult>
    : never;
};

// Map of type names (Custodian) and function return types
//
// e.g. type CustodianProperty = ReturnTypeByTypeName['Custodian'] // will be UseQueryReturn<CustodiansList, CustodiansListVariables>
type ReturnTypeByTypeName = {
  [K in keyof FunctionByTypeName]: ReturnType<FunctionByTypeName[K]>;
};

// Type for the the return object from useQueryList
// (I was not sure what the result of your parsing is so I just used unknown)
//
// e.g. type UseCustodiansQueryReturnType = UseQueryListReturnType<'Custodian'> // will be { custodiansList: {}, /* the rest of UseQueryReturn */ }
type UseQueryListReturnType<T extends TypeName> = ReturnTypeByTypeName[T] &
  {
    [K in PropertyNameByTypeName[T]]: unknown;

    // I would suggest though to not name the parsed result depending on the type name
    // and make it consistent for all the types, e.g. call it parsedResult:
    //
    // parsedResult: unknown;
  };

// A helper function to turn 'Custodian' into 'custodian' etc to get the property name from type name later
const lowercaseFirstLetter = (value: string) => (value ? value[0].toLowerCase() + value.slice(1) : value);

// This was undefined in your example
const parseResult = <T>(a: T): T => a;

// Convert typename to a function
const toFunction = <T extends TypeName>(typename: T): FunctionByTypeName[T] => {
  // This is the first type casting you need to make since string manipulation and types don't go together
  return Service[`use${typename}sList` as keyof ServiceType];
};

// Convert typename to property name (e.g. 'Custodian' => 'custodiansList')
const toPropertyName = <T extends TypeName>(typename: T): PropertyNameByTypeName[T] =>
  // Again the same string manipulation problem
  `${lowercaseFirstLetter(typename)}sList` as PropertyNameByTypeName[T];

function useQueryList<T extends TypeName>(typename: T): UseQueryListReturnType<T> {
  const fn: FunctionByTypeName[T] = toFunction(typename); // e.g. useCustodiansList
  const result: ReturnTypeByTypeName[T] = fn(); //! this is the problem

  // we also want to return everything including a parsedResult
  const listName: PropertyNameByTypeName[T] = toPropertyName(typename);

  // Now the third type casting is something I am not proud of but unfortunately
  // TypeScript does not want to agree with me that listName is not just a string
  // but a very special string :)
  return {
    ...result,
    [listName]: parseResult(result),
  } as UseQueryListReturnType<T>;
}

When I now try:

const custodians = useQueryList('Custodian');

I can see that the usersList property is there! Yay!

Os answered 26/5, 2020 at 10:49 Comment(1)
Thx, I still hadn't time to test it, but it looks really good. I awarded you the bounty and doubled it to recompense for the late response!Eolanda

© 2022 - 2024 — McMap. All rights reserved.