Search code examples
typescriptaws-amplifytypescript-generics

How do I write a type-safe function signature accepting the callback function for an amplify-js v6 graphql subscription notification?


Using the NPM library aws-amplify at version 6.0.9 (not 5.x.x!), I am trying to wrap the call to client.graphql({ query: typedGqlString, variables}).subscribe({ next, error }), so that I can treat all my graphql subscriptions similarly for purposes of unsubscription, error handling, and data refresh upon internet failure and reconnection.

This v6 version of the amplify library uses typed GQL strings similar to the following:

import { generateClient} from "aws-amplify/api";
const client = generateClient();

type GeneratedSubscription<InputType, OutputType> = string & {
  __generatedSubscriptionInput: InputType;
  __generatedSubscriptionOutput: OutputType;
};

interface SubscriptionOnCreateClubDeviceArgs {
  clubId: string;
}

interface SubscriptionOnCreateClubDeviceCallbackPayload {
  onCreateClubDevice: ClubDevice; /* ClubDevice typescript type defined elsewhere */
}

const subscriptionOnCreateClubDevice = /* GraphQL */ `
  subscription OnCreateClubDevice($clubId: String!) {
    onCreateClubDevice(clubId: $clubId) {
      (... clubDevice gql type field names)
    }
  }
` as GeneratedSubscription<
  SubscriptionOnCreateClubDeviceArgs,
  SubscriptionOnCreateClubDeviceCallbackPayload
>

client.graphql({
  query: subscriptionOnCreateClubDevice,
  variables: { clubId: "foo" }
}).subscribe({
  next: (payload/* ": SubscriptionOnCreateClubDeviceCallbackPayload" not necessary! */) => {
    // payload is known at compile time to be a SubscriptionOnCreateClubDeviceCallbackPayload
    ...
  }
});

This is exciting, because the amplify library type-checks that the variables field has the right fields defined, and that next function only references fields on its argument which exist.

My goal is to be able to wrap this call to client.graphql(...).subscribe(...) in a function taking the query, variables, and next values as arguments. By design, the internal types for the amplify-js library are not exported.

I will jump to the the main difficulty I'm encountering in doing this, and then go slowly piece-by-piece to how I got there. My sticking point is that the signature of subscribe has a single argument which is the union of two types that I would like my wrapping function to only take one of:

Partial<Observer<NeverEmpty<OUTPUT_TYPE>>> | ((value: NeverEmpty<OUTPUT_TYPE>) => void)

I will only ever be passing what amounts to the Partial<Observer<NeverEmpty<OUTPUT_TYPE>>>, because this is what actually contains a next field. I will never be passing (value: NeverEmpty<OUTPUT_TYPE>) => void, which is deprecated anyway. However, I'm having a difficult time getting my signature just right on the wrapping function mainly because Observer and NeverEmpty are complicated and not-exported from amplify.

First of all, this bit of code copied from the amplify-js library shows how the arguments are ultimately passed type-safely into the library:

/**
 * The expected return type with respect to the given `FALLBACK_TYPE`
 * and `TYPED_GQL_STRING`.
 */
export type GraphQLResponseV6<
    FALLBACK_TYPE = unknown,
    TYPED_GQL_STRING extends string = string,
> = TYPED_GQL_STRING extends GeneratedQuery<infer IN, infer QUERY_OUT>
    ? Promise<GraphQLResult<FixedQueryResult<QUERY_OUT>>>
    : TYPED_GQL_STRING extends GeneratedMutation<infer IN, infer MUTATION_OUT>
      ? Promise<GraphQLResult<NeverEmpty<MUTATION_OUT>>>
      : TYPED_GQL_STRING extends GeneratedSubscription<infer IN, infer SUB_OUT>
        ? GraphqlSubscriptionResult<NeverEmpty<SUB_OUT>>
        : FALLBACK_TYPE extends GraphQLQuery<infer T>
          ? Promise<GraphQLResult<FALLBACK_TYPE>>
          : FALLBACK_TYPE extends GraphQLSubscription<infer T>
            ? GraphqlSubscriptionResult<FALLBACK_TYPE>
            : FALLBACK_TYPE extends GraphQLOperationType<
                                infer IN,
                                infer CUSTOM_OUT
                >
              ? CUSTOM_OUT
              : UnknownGraphQLResponse;

Now, because I'm a typescript n00b, I went slowly naming each type as I extracted them using tools I'm just learning. This shows my thinking:

type TypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE> = typeof client.graphql<
  unknown /* FALLBACK_TYPES */,
  GeneratedSubscription<INPUT_TYPE, OUTPUT_TYPE> /* TYPED_GQL_STRING */
>;
// == GraphQLMethod<unknown, GeneratedSubscription<INPUT_TYPE, OUTPUT_TYPE>>

type ArgTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE> = Parameters<
  TypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>
>[0];
// == GraphQLOptionsV6<unknown, GeneratedSubscription<INPUT_TYPE, OUTPUT_TYPE>>

type NamedArgQueryTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE> =
  ArgTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>["query"];
// == GeneratedSubscription<INPUT_TYPE, OUTPUT_TYPE>

type NamedArgVariablesTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE> =
  ArgTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>["variables"];
// == GraphQLVariablesV6<unknown, GeneratedSubscription<INPUT_TYPE, OUTPUT_TYPE>>

type ReturnTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE> = ReturnType<
  TypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>
>;
// == GraphQLResponseV6<unknown, GeneratedSubscription<INPUT_TYPE, OUTPUT_TYPE>>
// == GraphqlSubscriptionResult<NeverEmpty<OUTPUT_TYPE>>
// == Observable<GraphqlSubscriptionMessage<NeverEmpty<OUTPUT_TYPE>>>

type TypeOfClientGraphqlSubscribe<INPUT_TYPE, OUTPUT_TYPE> =
  ReturnTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>["subscribe"];
// == (observerOrNext?: Partial<Observer<NeverEmpty<OUTPUT_TYPE>>> | ((value: NeverEmpty<OUTPUT_TYPE>) => void)) => Subscription

type ArgTypeOfClientGraphqlSubscribe<INPUT_TYPE, OUTPUT_TYPE> = Parameters<
  TypeOfClientGraphqlSubscribe<INPUT_TYPE, OUTPUT_TYPE>
>[0];
// == Partial<Observer<NeverEmpty<OUTPUT_TYPE>>> | ((value: NeverEmpty<OUTPUT_TYPE>) => void)

Now I would like to get at Partial<Observer<NeverEmpty<OUTPUT_TYPE>>>["next"], because that will be the type for the parameter on my wrapping function:

export const errorCatchingSubscription = <INPUT_TYPE, OUTPUT_TYPE>({
  query,
  variables,
  next,
}: {
  query: NamedArgQueryTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>;
  variables: NamedArgVariablesTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>;
  next: ArgTypeOfClientGraphqlSubscribe<INPUT_TYPE, OUTPUT_TYPE>["next"];
}) => {
   ... 
}

but this does not work, I think because ArgTypeOfClientGraphqlSubscribe<INPUT_TYPE, OUTPUT_TYPE>["next"] is trying to pull the type of the next field from the union type Partial<Observer<NeverEmpty<OUTPUT_TYPE>>> | ((value: NeverEmpty<OUTPUT_TYPE>) => void) rather than the type Partial<Observer<NeverEmpty<OUTPUT_TYPE>>>, and the type ((value: NeverEmpty<OUTPUT_TYPE>) => void) has no field named "next".

I've tried to use the Exclude<T, U> utility, but because Observer and NeverEmpty are complicated and not-exported from amplify, I cannot explicitly and exactly state the function type I want to exclude from the union. I've tried using the infer keyword as well, but then the inferred type loses the static type information about Partial<Observer<NeverEmpty<OUTPUT_TYPE>>> that it contains a next field which is a function taking OUTPUT_TYPE.

Is there any other way to statically, at compile time, get rid of that | ((value: NeverEmpty<OUTPUT_TYPE>) => void) so that I can use["next"] to extract the type of the subscription notification callback out of Partial<Observer<NeverEmpty<OUTPUT_TYPE>>>? Or else am I going about this all wrong anyway and there's some better way to wrap this call type-safely?


Solution

  • I found an answer to my difficulty in another post: it seems I cannot do what I would like to.

    In fact, the issue was earlier in the chain of types than I had thought. The comment in:

    type TypeOfClientGraphqlSubscribe<INPUT_TYPE, OUTPUT_TYPE> =
      ReturnTypeOfClientGraphql<INPUT_TYPE, OUTPUT_TYPE>["subscribe"];
    // == (observerOrNext?: Partial<Observer<NeverEmpty<OUTPUT_TYPE>>> | ((value: NeverEmpty<OUTPUT_TYPE>) => void)) => Subscription
    

    was incorrect. In actuality, the subscribe function is overloaded once in the rxjs library that aws-amplify is depending upon, and (observerOrNext?: Partial<Observer<NeverEmpty<OUTPUT_TYPE>>> | ((value: NeverEmpty<OUTPUT_TYPE>) => void)) => Subscription is the earlier of the two signatures. The above-linked post points out that this typescript documentation explicitly indicates that the final signature is the type that will be returned by the ["subscribe"] operator, so the type I'm interested in extracting, Partial<...>, is simply not available at all.

    Also, for the record, I was mistaken when I said in the question that the (value: NeverEmpty<OUTPUT_TYPE) => void part of the union was deprecated. Even in v8 of rxjs, that part of the union will still be a recommended means of invocation of the subscribe method. Only the second, overloading method definition is deprecated and will be removed in rxjs v8. Once that occurs and is consumed by amplify, the overload will be gone and I'll be able to use ["subscribe"] to access the union type listed rather than the deprecated type, but I expect I will still have the problem I thought I was having as the question is posed: that I still will be unable to use Exclude<T, U> on the union type since NeverEmpty is not exported from amplify, and ["next"] without using Exclude<T, U> will fail because it can only apply to the first half of the union.