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

[typescript-resolvers] Extract union types to ResolversUnionTypes #9069

Conversation

eddeee888
Copy link
Collaborator

@eddeee888 eddeee888 commented Feb 26, 2023

Description

As discussed in a proof of concept, we thought that by creating a ResolversUnionTypes type and use reference unions in ResolversTypes and ResolversParentTypes give us a bit more control in the future.

As the result, the union types are no longer inlined in ResolversTypes and ResolversParentTypes

Example use case (future)

In the future, we could make union members' __typename non-nullable whilst leaving the counterparts in ResolversTypes and ResolversParentTypes as optional:

// typescript plugin types
export type BookResult = {
  __typename?: "BookResult";
  result?: Maybe<Book>;
};

export type StandardError = {
  __typename?: "StandardError";
  error: string;
};

// typescript-resolvers types
export type ResolversUnionTypes = {
  BookPayload: (BookResult & { __typename: "BookResult" }) | (StandardError & { __typename: "StandardError" }); // Non-nullable typename are not implemented. This is here as proof of concept
};

export type ResolversTypes = {
  // ...
  BookPayload: ResolverTypeWrapper<ResolversUnionTypes["BookPayload"]>; // This forces `BookPayload` require __typename from its union members `BookResult` and `StandardError`
  BookResult: ResolverTypeWrapper<BookResult>; // __typename of BookResult is still optional
  StandardError: ResolverTypeWrapper<StandardError>;
  // ...
};

export type ResolversParentTypes = {
  // ...
  BookPayload: ResolversUnionTypes["BookPayload"]; // This means BookPayload.__resolveType has access to non-nullable __typename from its union members.
  BookResult: BookResult;
  StandardError: StandardError;
  // ...
};

Type of change

Please delete options that are not relevant.

  • Refactor: change how union types are referenced

How Has This Been Tested?

  • Unit tests

Test Environment:

  • OS: MacOS
  • @graphql-codegen/typescript-resolvers:
  • NodeJS: 16.15.0

Checklist:

  • I have followed the CONTRIBUTING doc and the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@changeset-bot
Copy link

changeset-bot bot commented Feb 26, 2023

🦋 Changeset detected

Latest commit: 7758126

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@graphql-codegen/visitor-plugin-common Patch
@graphql-codegen/typescript-resolvers Patch
@graphql-codegen/typescript-document-nodes Patch
@graphql-codegen/gql-tag-operations Patch
@graphql-codegen/typescript-operations Patch
@graphql-codegen/typed-document-node Patch
@graphql-codegen/typescript Patch
@graphql-codegen/graphql-modules-preset Patch
@graphql-codegen/client-preset Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@@ -880,6 +876,82 @@ export class BaseResolversVisitor<
return `Array<${t}>`;
}

protected createResolversUnionTypes(): Record<string, string> {
Copy link
Collaborator Author

@eddeee888 eddeee888 Feb 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createResolversUnionTypes is the simplified version of createResolversFields that is targeted for Unions.
I chose to create a new function because createResolversFields is quite large and rigid so it's hard to implement/maintain


return unionMemberValue;
});
res[typeName] = referencedTypes.map(type => `( ${type} )`).join(' | '); // Must wrap every union member in explicit "( )" to separate the members
Copy link
Collaborator Author

@eddeee888 eddeee888 Feb 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must wrap every union member in explicit "( )" to separate the members

Result usually looks like this:

export type ResolversUnionTypes =  {
  UnionThing: ( TypeA ) | ( TypeB )
}

Prettier should remove the round brackets automatically i.e. result will be UnionThing: TypeA | TypeB


I've added it this way so it's a bit easier to add types to the union members without ambiguity e.g.

// This is ambiguous
UnionThing: TypeA & { __typename: 'TypeA' } | TypeB & { __typename: 'TypeB' }

// Prettier will try to turn it into this:
UnionThing: ( TypeA & { __typename: 'TypeA' } ) | ( TypeB & { __typename: 'TypeB' } ) 

@@ -1426,10 +1426,38 @@ export type ResolverFn<TResult, TParent, TContext, TArgs> = (
union CCCUnion = CCCFoo | CCCBar
`);

const tsContent = (await tsPlugin(testSchema, [], {}, { outputFile: 'graphql.ts' })) as Types.ComplexPluginOutput;
const tsContent = await tsPlugin(testSchema, [], {}, { outputFile: 'graphql.ts' });
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tsContent should be typed as Types.ComplexPluginOutput so we don't need to do as Types.ComplexPluginOutput.

Happy to put this back if it was here for some particular reasons

Comment on lines +34 to +51
expect(result.content).toBeSimilarStringTo(`
export type ResolversParentTypes = {
MyType: Omit<MyType, 'unionChild'> & { unionChild?: Maybe<ResolversParentTypes['ChildUnion']> };
String: Scalars['String'];
Child: Child;
MyOtherType: MyOtherType;
ChildUnion: ResolversUnionTypes['ChildUnion'];
Query: {};
Subscription: {};
Node: ResolversParentTypes['SomeNode'];
ID: Scalars['ID'];
SomeNode: SomeNode;
MyUnion: ResolversUnionTypes['MyUnion'];
MyScalar: Scalars['MyScalar'];
Int: Scalars['Int'];
Boolean: Scalars['Boolean'];
};
`);
Copy link
Collaborator Author

@eddeee888 eddeee888 Feb 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ResolversParentTypes to all related tests in this file because I want to be sure that ResolversUnionTypes is being used correctly to reference.

expect(result.content).toBeSimilarStringTo(`
export type ResolversTypes = {
MyType: ResolverTypeWrapper<Omit<MyType, 'unionChild'> & { unionChild?: Maybe<ResolversTypes['ChildUnion']> }>;
String: ResolverTypeWrapper<Scalars['String']>;
Child: ResolverTypeWrapper<Child>;
MyOtherType: ResolverTypeWrapper<MyOtherType>;
ChildUnion: ResolversTypes['Child'] | ResolversTypes['MyOtherType'];
ChildUnion: ResolverTypeWrapper<ResolversUnionTypes['ChildUnion']>;
Copy link
Collaborator Author

@eddeee888 eddeee888 Feb 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, this is ResolversTypes['Child'] | ResolversTypes['MyOtherType'] which translate to:

ResolverTypeWrapper<Child> | ResolverTypeWrapper<MyOtherType>

Now, using ResolverTypeWrapper<ResolversUnionTypes['ChildUnion']> translate to:

ResolverTypeWrapper<Child | MyOtherType>

I think these 2 are the same semantically. Happy to hear explanation if they are not 🙂


(This is applicable to all updated tests in this file)

expect(result.content).toBeSimilarStringTo(`
export type ResolversUnionTypes = {
ChildUnion: ( Omit<Child, 'parent'> & { parent?: Maybe<ResolversTypes['MyType']> } ) | ( MyOtherType );
MyUnion: ( CustomPartial<Omit<MyType, 'unionChild'> & { unionChild?: Maybe<ResolversTypes['ChildUnion']> }> ) | ( MyOtherType );
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be related to #9043 as well.

Ideally, the MyType is should be treated the same way as ResolversParentTypes['MyType']. However:

  • The ResolversParentTypes['MyType'] is using the type from typescript plugin.
  • The mentioned issue seems to suggest that we should use the "Omit type" instead

Currently, ResolversUnionTypes is using "Omit type" to replace the { T } placeholder here but happy to change to match whichever direction ResolversParentTypes['MyType'] uses.

cc @saihaj @gilgardosh

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, according to #1631, the parent type should use the "Omit type", and #1677 implements it. Not sure when it broke between then and now, but I suspect it was unintentional.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eddeee888 awesome work!
I implemented a fix here #9110 , but it's conflicting. I'll adjust rebase it on top of yours after it's merged

@eddeee888 eddeee888 force-pushed the typescript-resolvers-refactor-union-types branch from a65e065 to 4291915 Compare February 26, 2023 06:11
Comment on lines +791 to +799
const originalTypeName = isScalar ? this._getScalar(typeName) : prev[typeName];

// Don't wrap Union with ResolverTypeWrapper, each inner type already has it
if (isUnionType(schemaType)) {
prev[typeName] = replaced;
// Don't clear ResolverTypeWrapper from Unions
prev[typeName] = replacePlaceholder(this.config.defaultMapper.type, originalTypeName);
} else {
prev[typeName] = applyWrapper(replacePlaceholder(this.config.defaultMapper.type, name));
const name = clearWrapper(originalTypeName);
const replaced = replacePlaceholder(this.config.defaultMapper.type, name);
prev[typeName] = applyWrapper(replaced);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous logic suggests that it's possible for a union to be in the scalar map 🤔 I'm unsure when/if it's possible.

However, the updated logic should be backward compatible as it generates the same partial and wrapper order.

@eddeee888 eddeee888 force-pushed the typescript-resolvers-refactor-union-types branch from 89a4288 to 09b4d59 Compare February 28, 2023 09:01
@eddeee888 eddeee888 marked this pull request as ready for review February 28, 2023 09:01
@eddeee888 eddeee888 merged commit 4b49f6f into dotansimha:master Mar 6, 2023
@eddeee888 eddeee888 deleted the typescript-resolvers-refactor-union-types branch March 6, 2023 11:27
@hector-del-rio
Copy link

This caused an issue in my project due to the fact that now the first argument for __resolveType resolver function definition is wrapped in ResolverTypeWrapper where before it wasn't, which makes it possibly a promise.

@ryanrhee
Copy link

type ResolverTypeWithoutPromise = Exclude<ResolverTypeWrapper, Promise<unknown>> should do what you want.

@ryanrhee
Copy link

you can also extract all the sub-types as well.

import type { ResolversTypes } from "./path/to/generated/graphql"

type ShallowTypes = {
  [Key in keyof ResolversTypes]: Exclude<ResolversTypes[Key], Promise<unknown>>
}

@hector-del-rio
Copy link

@ryanrhee thanks for your answer. The issue is with the parent argument (first argument) of the resolver for __resolveType field.

export type ResolversParentTypes = ResolversObject<{
  ...
  MyType: ResolversUnionTypes['MyType'];
  AnotherType: ResolverTypeWrapper<Mapper>
  ...
}>;

export type ResolversUnionTypes = ResolversObject<{
  MyType: { contract: ResolversTypes['AnotherType'] }; // This now uses ResolversTypes which uses ResolverTypeWrapper
}>;

I guess the only way to fix it is to add this type to the mappers as well?

@ryanrhee
Copy link

ryanrhee commented Mar 15, 2023

You could add it to the mappers, yes.

Does __resolveType() allow async functions? If so, you could check if the first argument is a promise and then resolve it so that you are always operating on the underlying type.

{
  __resolveType: async (objOrPromise, ...) => {
    // objOrPromise is `Promise<T> | T`
    const obj = Promise.resolve(objOrPromise)
    // your code here; obj is now of type `T`
  }
}

@hector-del-rio
Copy link

Thanks @ryanrhee. I needed to add await, but it worked:

{
  __resolveType: async (objOrPromise, ...) => {
    // objOrPromise is `Promise<T> | T`
    const obj = await Promise.resolve(objOrPromise)
    // your code here; obj is now of type `T`
  }
}

However, it is a bit concerning that this happened in a patch update from @graphql-codegen/typescript-resolvers 3.1.0 to 3.1.1.

@eddeee888
Copy link
Collaborator Author

Hi @hector-del-rio ,

Do you mind creating a small repo or sandbox with a small schema and minimal codegen config? This will help me understand the problem better 🙂

For context, this went in as a patch because we only move type references around. Maybe I missed a use case. Sorry!

@eddeee888
Copy link
Collaborator Author

Hi @hector-del-rio,

This should be fixed in #9206 🙂
It'll go out in the next version. Thanks for reporting the issue 🙌

@hector-del-rio
Copy link

Thank you @eddeee888 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants