Skip to content

Commit

Permalink
[typescript-resolvers] Add resolversNonOptionalTypename config (#9146)
Browse files Browse the repository at this point in the history
  • Loading branch information
eddeee888 committed Mar 19, 2023
1 parent b7dacb2 commit 9f4d9c5
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 1 deletion.
108 changes: 108 additions & 0 deletions .changeset/spicy-worms-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
'@graphql-codegen/visitor-plugin-common': minor
'@graphql-codegen/typescript-resolvers': minor
---

[typescript-resolvers] Add `resolversNonOptionalTypename` config option.

This is extending on `ResolversUnionTypes` implemented in https://github.com/dotansimha/graphql-code-generator/pull/9069

`resolversNonOptionalTypename` adds non-optional `__typename` to union members of `ResolversUnionTypes`, without affecting the union members' base intefaces.

A common use case for non-optional `__typename` of union members is using it as the common field to work out the final schema type. This makes implementing the union's `__resolveType` very simple as we can use `__typename` to decide which union member the resolved object is. Without this, we have to check the existence of field/s on the incoming object which could be verbose.

For example, consider this schema:

```graphql
type Query {
book(id: ID!): BookPayload!
}

type Book {
id: ID!
isbn: String!
}

type BookResult {
node: Book
}

type PayloadError {
message: String!
}

union BookPayload = BookResult | PayloadError
```

*With optional `__typename`:* We need to check existence of certain fields to resolve type in the union resolver:

```ts
// Query/book.ts
export const book = async () => {
try {
const book = await fetchBook();
// 1. No `__typename` in resolver results...
return {
node: book
}
} catch(e) {
return {
message: "Failed to fetch book"
}
}
}

// BookPayload.ts
export const BookPayload = {
__resolveType: (parent) => {
// 2. ... means more checks in `__resolveType`
if('message' in parent) {
return 'PayloadError';
}
return 'BookResult'
}
}
```

*With non-optional `__typename`:* Resolvers declare the type. This which gives us better TypeScript support in resolvers and simplify `__resolveType` implementation:

```ts
// Query/book.ts
export const book = async () => {
try {
const book = await fetchBook();
// 1. `__typename` is declared in resolver results...
return {
__typename: 'BookResult', // 1a. this also types `node` for us 🎉
node: book
}
} catch(e) {
return {
__typename: 'PayloadError',
message: "Failed to fetch book"
}
}
}

// BookPayload.ts
export const BookPayload = {
__resolveType: (parent) => parent.__typename, // 2. ... means a very simple check in `__resolveType`
}
```

*Using `resolversNonOptionalTypename`:* add it into `typescript-resolvers` plugin config:

```ts
// codegen.ts
const config: CodegenConfig = {
schema: 'src/schema/**/*.graphql',
generates: {
'src/schema/types.ts': {
plugins: ['typescript', 'typescript-resolvers'],
config: {
resolversNonOptionalTypename: true // Or `resolversNonOptionalTypename: { unionMember: true }`
}
},
},
};
```
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
EnumValuesMap,
NormalizedScalarsMap,
ParsedEnumValuesMap,
ResolversNonOptionalTypenameConfig,
} from './types.js';
import {
buildScalarsFromConfig,
Expand Down Expand Up @@ -73,6 +74,7 @@ export interface ParsedResolversConfig extends ParsedConfig {
internalResolversPrefix: string;
onlyResolveTypeForInterfaces: boolean;
directiveResolverMappings: Record<string, string>;
resolversNonOptionalTypename: ResolversNonOptionalTypenameConfig;
}

export interface RawResolversConfig extends RawConfig {
Expand Down Expand Up @@ -537,6 +539,11 @@ export interface RawResolversConfig extends RawConfig {
* @description Turning this flag to `true` will generate resolver signature that has only `resolveType` for interfaces, forcing developers to write inherited type resolvers in the type itself.
*/
onlyResolveTypeForInterfaces?: boolean;
/**
* @description Makes `__typename` of resolver mappings non-optional without affecting the base types.
* @default false
*/
resolversNonOptionalTypename?: boolean | ResolversNonOptionalTypenameConfig;
/**
* @ignore
*/
Expand Down Expand Up @@ -604,6 +611,9 @@ export class BaseResolversVisitor<
mappers: transformMappers(rawConfig.mappers || {}, rawConfig.mapperTypeSuffix),
scalars: buildScalarsFromConfig(_schema, rawConfig, defaultScalars),
internalResolversPrefix: getConfigValue(rawConfig.internalResolversPrefix, '__'),
resolversNonOptionalTypename: normalizeResolversNonOptionalTypename(
getConfigValue(rawConfig.resolversNonOptionalTypename, false)
),
...additionalConfig,
} as TPluginConfig);

Expand Down Expand Up @@ -899,7 +909,11 @@ export class BaseResolversVisitor<
return replacePlaceholder(this.config.defaultMapper.type, finalTypename);
}

return unionMemberValue;
const nonOptionalTypenameModifier = this.config.resolversNonOptionalTypename.unionMember
? ` & { __typename: "${unionMemberType}" }`
: '';

return `${unionMemberValue}${nonOptionalTypenameModifier}`;
});
res[typeName] = referencedTypes.map(type => `( ${type} )`).join(' | '); // Must wrap every union member in explicit "( )" to separate the members
}
Expand Down Expand Up @@ -1583,3 +1597,22 @@ function replacePlaceholder(pattern: string, typename: string): string {
function hasPlaceholder(pattern: string): boolean {
return pattern.includes('{T}');
}

function normalizeResolversNonOptionalTypename(
input?: boolean | ResolversNonOptionalTypenameConfig
): ResolversNonOptionalTypenameConfig {
const defaultConfig: ResolversNonOptionalTypenameConfig = {
unionMember: false,
};

if (typeof input === 'boolean') {
return {
unionMember: input,
};
}

return {
...defaultConfig,
...input,
};
}
4 changes: 4 additions & 0 deletions packages/plugins/other/visitor-plugin-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,7 @@ export interface ParsedImport {
moduleName: string | null;
propName: string;
}

export interface ResolversNonOptionalTypenameConfig {
unionMember?: boolean;
}
32 changes: 32 additions & 0 deletions packages/plugins/typescript/resolvers/tests/ts-resolvers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,38 @@ export type MyTypeResolvers<ContextType = any, ParentType extends ResolversParen

await resolversTestingValidate(result);
});

it('resolversNonOptionalTypename - adds non-optional typenames to implemented types', async () => {
const result = await plugin(
resolversTestingSchema,
[],
{ resolversNonOptionalTypename: true },
{ outputFile: '' }
);

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

it('resolversNonOptionalTypename - adds non-optional typenames to ResolversUnionTypes', async () => {
const result = await plugin(
resolversTestingSchema,
[],
{ resolversNonOptionalTypename: { unionMember: true } },
{ outputFile: '' }
);

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

it('directiveResolverMappings - should generate correct types (import definition)', async () => {
Expand Down

0 comments on commit 9f4d9c5

Please sign in to comment.