From 7f99817b84bbe7473ce85e21dae6ff92fd3a3198 Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Mon, 1 Dec 2025 20:41:44 -0500 Subject: [PATCH 01/10] input types, input/output enums are generated to the target files --- dev-test/star-wars/types.avoidOptionals.ts | 10 ++ dev-test/star-wars/types.excludeQueryAlpha.ts | 10 ++ dev-test/star-wars/types.excludeQueryBeta.ts | 10 ++ .../star-wars/types.globallyAvailable.d.ts | 10 ++ dev-test/star-wars/types.immutableTypes.ts | 10 ++ ...ypes.preResolveTypes.onlyOperationTypes.ts | 10 ++ dev-test/star-wars/types.preResolveTypes.ts | 10 ++ dev-test/star-wars/types.skipSchema.ts | 10 ++ dev-test/star-wars/types.ts | 10 ++ .../typescript/operations/src/index.ts | 30 ++-- .../typescript/operations/src/visitor.ts | 142 ++++++++++++++++++ .../tests/extract-all-types.spec.ts | 40 ++++- .../tests/ts-documents.standalone.spec.ts | 14 +- 13 files changed, 291 insertions(+), 25 deletions(-) diff --git a/dev-test/star-wars/types.avoidOptionals.ts b/dev-test/star-wars/types.avoidOptionals.ts index 96a96ea0b4d..be304fc5c1f 100644 --- a/dev-test/star-wars/types.avoidOptionals.ts +++ b/dev-test/star-wars/types.avoidOptionals.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.excludeQueryAlpha.ts b/dev-test/star-wars/types.excludeQueryAlpha.ts index 7ccbc0a787c..540f85e5c25 100644 --- a/dev-test/star-wars/types.excludeQueryAlpha.ts +++ b/dev-test/star-wars/types.excludeQueryAlpha.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.excludeQueryBeta.ts b/dev-test/star-wars/types.excludeQueryBeta.ts index 9f20261977e..9233ecfac9f 100644 --- a/dev-test/star-wars/types.excludeQueryBeta.ts +++ b/dev-test/star-wars/types.excludeQueryBeta.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.globallyAvailable.d.ts b/dev-test/star-wars/types.globallyAvailable.d.ts index 0e376af86bc..3e569d17f5b 100644 --- a/dev-test/star-wars/types.globallyAvailable.d.ts +++ b/dev-test/star-wars/types.globallyAvailable.d.ts @@ -247,6 +247,16 @@ type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.immutableTypes.ts b/dev-test/star-wars/types.immutableTypes.ts index bb485fff875..bea03b64839 100644 --- a/dev-test/star-wars/types.immutableTypes.ts +++ b/dev-test/star-wars/types.immutableTypes.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts index 9597c939d8f..c503da95b02 100644 --- a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts @@ -58,6 +58,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.preResolveTypes.ts b/dev-test/star-wars/types.preResolveTypes.ts index 99c11f7e757..42cd01885f4 100644 --- a/dev-test/star-wars/types.preResolveTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.skipSchema.ts b/dev-test/star-wars/types.skipSchema.ts index 99c11f7e757..42cd01885f4 100644 --- a/dev-test/star-wars/types.skipSchema.ts +++ b/dev-test/star-wars/types.skipSchema.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.ts b/dev-test/star-wars/types.ts index 99c11f7e757..42cd01885f4 100644 --- a/dev-test/star-wars/types.ts +++ b/dev-test/star-wars/types.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/packages/plugins/typescript/operations/src/index.ts b/packages/plugins/typescript/operations/src/index.ts index 8dd2c1b3439..82a6ed6d94d 100644 --- a/packages/plugins/typescript/operations/src/index.ts +++ b/packages/plugins/typescript/operations/src/index.ts @@ -25,25 +25,14 @@ export const plugin: PluginFunction typeof def === 'string').join('\n'); + const schemaTypesDefinitions = schemaTypes.definitions.filter(def => typeof def === 'string'); + + let content = [...schemaTypesDefinitions, ...operationsDefinitions].join('\n'); - const content: string[] = []; - if (schemaTypesContent) { - content.push(schemaTypesContent); + if (config.globalNamespace) { + content = ` + declare global { + ${content} + }`; } - content.push(operationsContent); return { prepend: [ @@ -66,7 +58,7 @@ export const plugin: PluginFunction = { [K in keyof T]: T[K] };', ], - content: content.join('\n'), + content, }; }; diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 3e4ae45692b..f2ad7853da9 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -2,9 +2,12 @@ import { BaseDocumentsVisitor, type ConvertSchemaEnumToDeclarationBlockString, convertSchemaEnumToDeclarationBlockString, + DeclarationBlock, DeclarationKind, generateFragmentImportStatement, getConfigValue, + indent, + isOneOfInputObjectType, LoadedFragment, normalizeAvoidOptionals, NormalizedAvoidOptionalsConfig, @@ -14,6 +17,7 @@ import { PreResolveTypesProcessor, SelectionSetProcessorConfig, SelectionSetToObject, + transformComment, wrapTypeWithModifiers, } from '@graphql-codegen/visitor-plugin-common'; import autoBind from 'auto-bind'; @@ -21,6 +25,7 @@ import { type DocumentNode, EnumTypeDefinitionNode, type FragmentDefinitionNode, + getNamedType, GraphQLEnumType, GraphQLInputObjectType, type GraphQLNamedInputType, @@ -28,10 +33,17 @@ import { type GraphQLOutputType, GraphQLScalarType, type GraphQLSchema, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, isEnumType, isNonNullType, Kind, + ListTypeNode, + NamedTypeNode, + NonNullTypeNode, + TypeInfo, visit, + visitWithTypeInfo, } from 'graphql'; import { TypeScriptDocumentsPluginConfig } from './config.js'; import { TypeScriptOperationVariablesToObject } from './ts-operation-variables-to-object.js'; @@ -168,6 +180,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< ); this._declarationBlockConfig = { ignoreExport: this.config.noExport, + enumNameValueSeparator: ' =', }; } @@ -196,6 +209,110 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< }); } + InputObjectTypeDefinition(node: InputObjectTypeDefinitionNode): string | null { + const inputTypeName = node.name.value; + if (!this._usedNamedInputTypes[inputTypeName]) { + return null; + } + + if (isOneOfInputObjectType(this._schema.getType(inputTypeName))) { + return this.getInputObjectOneOfDeclarationBlock(node).string; + } + + return this.getInputObjectDeclarationBlock(node).string; + } + + private getInputObjectDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock { + return new DeclarationBlock(this._declarationBlockConfig) + .export() + .asKind('type') + .withName(this.convertName(node)) + .withComment(node.description?.value) + .withBlock((node.fields || []).join('\n')); + } + + private getInputObjectOneOfDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock { + const declarationKind = (node.fields?.length || 0) === 1 ? 'type' : 'type'; + return new DeclarationBlock(this._declarationBlockConfig) + .export() + .asKind(declarationKind) + .withName(this.convertName(node)) + .withComment(node.description?.value) + .withContent(`\n` + (node.fields || []).join('\n |')); + } + + private isValidVisitor(ancestors: any): boolean { + const currentVisitContext = this.getVisitorKindContextFromAncestors(ancestors); + const isVisitingInputType = currentVisitContext.includes(Kind.INPUT_OBJECT_TYPE_DEFINITION); + const isVisitingEnumType = currentVisitContext.includes(Kind.ENUM_TYPE_DEFINITION); + const isVisitingOperation = currentVisitContext.includes(Kind.OPERATION_DEFINITION); + + if (isVisitingOperation) { + return false; + } + + if (!isVisitingInputType && !isVisitingEnumType) { + return false; + } + + return true; + } + + InputValueDefinition(node: InputValueDefinitionNode): string { + const comment = transformComment(node.description?.value || '', 1); + const type: string = node.type as any as string; + return comment + indent(`${node.name.value}: ${type};`); + } + + NamedType(node: NamedTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { + if (!this.isValidVisitor(ancestors)) { + return undefined; + } + + const schemaType = this._schema.getType(node.name.value); + + // For scalars, use the configured scalar type (use input property for input context) + if (schemaType instanceof GraphQLScalarType) { + const scalarConfig = this.scalars[node.name.value]; + if (scalarConfig && 'input' in scalarConfig) { + // scalarConfig.input is already the type string (extracted from ParsedMapper in BaseVisitor) + const inputType = scalarConfig.input; + // If the type is 'any', use the scalar name itself instead (for custom scalars) + if (inputType === 'any') { + return node.name.value; + } + return inputType; + } + // Fallback to scalar name + return node.name.value; + } + + // For enums and input types, use the converted name + if (schemaType instanceof GraphQLEnumType || schemaType instanceof GraphQLInputObjectType) { + return this.convertName(node.name.value); + } + + return node.name.value; + } + + ListType(node: ListTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { + if (!this.isValidVisitor(ancestors)) { + return undefined; + } + + const asString = node.type as any as string; + const listModifier = this.config.immutableTypes ? 'ReadonlyArray' : 'Array'; + return `${listModifier}<${asString}>`; + } + + NonNullType(node: NonNullTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { + if (!this.isValidVisitor(ancestors)) { + return undefined; + } + + return node.type as any as string; + } + public getImports(): Array { return !this.config.globalNamespace && (this.config.inlineFragmentTypes === 'combine' || this.config.inlineFragmentTypes === 'mask') @@ -225,6 +342,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const usedInputTypes: UsedNamedInputTypes = {}; + // First collect types from variable definitions visit(documentNode, { VariableDefinition: variableDefinitionNode => { visit(variableDefinitionNode, { @@ -243,6 +361,30 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< }, }); + // Only collect enums from output types when not using namespacedImportName + // When namespacedImportName is set, enums should come from the types package + if (!this.config.namespacedImportName) { + const typeInfo = new TypeInfo(schema); + + visit( + documentNode, + visitWithTypeInfo(typeInfo, { + Field: () => { + // Get the type of the current field + const fieldType = typeInfo.getType(); + if (fieldType) { + const namedType = getNamedType(fieldType); + + // If it's an enum, add it + if (namedType instanceof GraphQLEnumType) { + usedInputTypes[namedType.name] = namedType; + } + } + }, + }) + ); + } + return usedInputTypes; } } diff --git a/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts b/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts index c64468cf85c..d24fe00a07c 100644 --- a/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts +++ b/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts @@ -401,7 +401,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; export type ConversationBotSolutionFragment_BotSolution_originatedFrom_EmailInteraction = { __typename: 'EmailInteraction', originalEmailURLPath: string }; @@ -565,7 +571,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( { id: string, htmlUrl: string, title: string, url: string } & { __typename: 'ArchivedArticle' } ); @@ -734,7 +746,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; export type ConversationBotSolutionFragment_BotSolution_originatedFrom_EmailInteraction = ( { __typename: 'EmailInteraction' } @@ -972,7 +990,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; export type ConversationBotSolutionFragment_BotSolution_originatedFrom_EmailInteraction = ( { __typename: 'EmailInteraction' } @@ -1207,7 +1231,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( { __typename: 'ArchivedArticle' } & Pick< ArchivedArticle, diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index 06dd1752443..a6f15184d80 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -84,14 +84,26 @@ describe('TypeScript Operations Plugin - Standalone', () => { } `); - const result = mergeOutputs([await plugin(schema, [{ document }], {})]); + const result = mergeOutputs([await plugin(schema, [{ document }], {})]); // enumType: 'string-literal' expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; + export type ResponseErrorType = + | 'NOT_FOUND' + | 'INPUT_VALIDATION_ERROR' + | 'FORBIDDEN_ERROR' + | 'UNEXPECTED_ERROR'; + export type UserRole = | 'ADMIN' | 'CUSTOMER'; + export type UsersInput = { + from: DateTime; + to: DateTime; + role: UserRole; + }; + export type UserQueryVariables = Exact<{ id: string; }>; From aa5e43a981bf65329782dfc0f07c29a27331bd5f Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Mon, 1 Dec 2025 21:10:03 -0500 Subject: [PATCH 02/10] cleanup --- .../typescript/operations/tests/ts-documents.standalone.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index a6f15184d80..b50688c3f43 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -84,7 +84,7 @@ describe('TypeScript Operations Plugin - Standalone', () => { } `); - const result = mergeOutputs([await plugin(schema, [{ document }], {})]); // enumType: 'string-literal' + const result = mergeOutputs([await plugin(schema, [{ document }], {})]); expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; From 8e15362e0ead7b46077951b5f9b243542e719f86 Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Tue, 2 Dec 2025 10:38:06 -0500 Subject: [PATCH 03/10] better code --- .../typescript/operations/src/visitor.ts | 74 ++++++++----------- .../__snapshots__/ts-documents.spec.ts.snap | 6 +- 2 files changed, 34 insertions(+), 46 deletions(-) diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index f2ad7853da9..3e40584bd26 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -241,31 +241,22 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< .withContent(`\n` + (node.fields || []).join('\n |')); } - private isValidVisitor(ancestors: any): boolean { - const currentVisitContext = this.getVisitorKindContextFromAncestors(ancestors); - const isVisitingInputType = currentVisitContext.includes(Kind.INPUT_OBJECT_TYPE_DEFINITION); - const isVisitingEnumType = currentVisitContext.includes(Kind.ENUM_TYPE_DEFINITION); - const isVisitingOperation = currentVisitContext.includes(Kind.OPERATION_DEFINITION); - - if (isVisitingOperation) { - return false; - } - - if (!isVisitingInputType && !isVisitingEnumType) { - return false; - } - - return true; - } - InputValueDefinition(node: InputValueDefinitionNode): string { const comment = transformComment(node.description?.value || '', 1); const type: string = node.type as any as string; return comment + indent(`${node.name.value}: ${type};`); } + private isValidVisit(ancestors: any): boolean { + const currentVisitContext = this.getVisitorKindContextFromAncestors(ancestors); + const isVisitingInputType = currentVisitContext.includes(Kind.INPUT_OBJECT_TYPE_DEFINITION); + const isVisitingEnumType = currentVisitContext.includes(Kind.ENUM_TYPE_DEFINITION); + + return isVisitingInputType || isVisitingEnumType; + } + NamedType(node: NamedTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { - if (!this.isValidVisitor(ancestors)) { + if (!this.isValidVisit(ancestors)) { return undefined; } @@ -296,17 +287,16 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< } ListType(node: ListTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { - if (!this.isValidVisitor(ancestors)) { + if (!this.isValidVisit(ancestors)) { return undefined; } - const asString = node.type as any as string; const listModifier = this.config.immutableTypes ? 'ReadonlyArray' : 'Array'; - return `${listModifier}<${asString}>`; + return `${listModifier}<${node.type}>`; } NonNullType(node: NonNullTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { - if (!this.isValidVisitor(ancestors)) { + if (!this.isValidVisit(ancestors)) { return undefined; } @@ -342,7 +332,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const usedInputTypes: UsedNamedInputTypes = {}; - // First collect types from variable definitions + // Collect input enums and input types visit(documentNode, { VariableDefinition: variableDefinitionNode => { visit(variableDefinitionNode, { @@ -361,29 +351,23 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< }, }); - // Only collect enums from output types when not using namespacedImportName - // When namespacedImportName is set, enums should come from the types package - if (!this.config.namespacedImportName) { - const typeInfo = new TypeInfo(schema); - - visit( - documentNode, - visitWithTypeInfo(typeInfo, { - Field: () => { - // Get the type of the current field - const fieldType = typeInfo.getType(); - if (fieldType) { - const namedType = getNamedType(fieldType); - - // If it's an enum, add it - if (namedType instanceof GraphQLEnumType) { - usedInputTypes[namedType.name] = namedType; - } + // Collect output enums + const typeInfo = new TypeInfo(schema); + visit( + documentNode, + visitWithTypeInfo(typeInfo, { + Field: () => { + const fieldType = typeInfo.getType(); + if (fieldType) { + const namedType = getNamedType(fieldType); + + if (namedType instanceof GraphQLEnumType) { + usedInputTypes[namedType.name] = namedType; } - }, - }) - ); - } + } + }, + }) + ); return usedInputTypes; } diff --git a/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap b/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap index f364438cc65..3fc18750df5 100644 --- a/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap +++ b/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap @@ -57,7 +57,11 @@ export type ElementMetadataFragment = `; exports[`TypeScript Operations Plugin > Issues > #2916 - Missing import prefix with preResolveTypes: true and near-operation-file preset 1`] = ` -"export type UserQueryVariables = Types.Exact<{ [key: string]: never; }>; +"export type Department = + | 'Direction' + | 'Development'; + +export type UserQueryVariables = Types.Exact<{ [key: string]: never; }>; export type UserQuery = { user: { id: string, username: string, email: string, dep: Types.Department } }; From 1ac8a855166bd3cc799c9519c43ce985a4dc943f Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Tue, 2 Dec 2025 14:07:28 -0500 Subject: [PATCH 04/10] more tests --- .../src/ts-operation-variables-to-object.ts | 2 +- .../typescript/operations/src/visitor.ts | 24 +- .../tests/ts-documents.standalone.spec.ts | 252 ++++++++++++++++++ 3 files changed, 261 insertions(+), 17 deletions(-) diff --git a/packages/plugins/typescript/operations/src/ts-operation-variables-to-object.ts b/packages/plugins/typescript/operations/src/ts-operation-variables-to-object.ts index e49bdf673df..b8ea3e147cd 100644 --- a/packages/plugins/typescript/operations/src/ts-operation-variables-to-object.ts +++ b/packages/plugins/typescript/operations/src/ts-operation-variables-to-object.ts @@ -1,6 +1,6 @@ import { TypeScriptOperationVariablesToObject as TSOperationVariablesToObject } from '@graphql-codegen/typescript'; -const SCALARS = { +export const SCALARS = { ID: 'string | number', String: 'string', Int: 'number', diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 3e40584bd26..0a9c71873cd 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -46,7 +46,7 @@ import { visitWithTypeInfo, } from 'graphql'; import { TypeScriptDocumentsPluginConfig } from './config.js'; -import { TypeScriptOperationVariablesToObject } from './ts-operation-variables-to-object.js'; +import { TypeScriptOperationVariablesToObject, SCALARS } from './ts-operation-variables-to-object.js'; import { TypeScriptSelectionSetProcessor } from './ts-selection-set-processor.js'; export interface TypeScriptDocumentsParsedConfig extends ParsedDocumentsConfig { @@ -175,7 +175,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< this.config.enumValues, this.config.arrayInputCoercion, undefined, - 'InputMaybe' + undefined ) ); this._declarationBlockConfig = { @@ -262,23 +262,15 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const schemaType = this._schema.getType(node.name.value); - // For scalars, use the configured scalar type (use input property for input context) if (schemaType instanceof GraphQLScalarType) { - const scalarConfig = this.scalars[node.name.value]; - if (scalarConfig && 'input' in scalarConfig) { - // scalarConfig.input is already the type string (extracted from ParsedMapper in BaseVisitor) - const inputType = scalarConfig.input; - // If the type is 'any', use the scalar name itself instead (for custom scalars) - if (inputType === 'any') { - return node.name.value; - } + const inputType = this.scalars?.[node.name.value]?.input ?? SCALARS[node.name.value] ?? 'any'; + if (inputType === 'any' && node.name.value) { + return node.name.value; + } return inputType; - } - // Fallback to scalar name - return node.name.value; + } - // For enums and input types, use the converted name if (schemaType instanceof GraphQLEnumType || schemaType instanceof GraphQLInputObjectType) { return this.convertName(node.name.value); } @@ -300,7 +292,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< return undefined; } - return node.type as any as string; + return node.type as any as string | undefined; } public getImports(): Array { diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index b50688c3f43..ad572fe1333 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -139,6 +139,258 @@ describe('TypeScript Operations Plugin - Standalone', () => { // validateTs(content, undefined, undefined, undefined, undefined, true); }); + it('test generating input types lists', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + users(input: UsersInput!): UsersResponse! + } + + type ResponseError { + error: ResponseErrorType! + } + + enum ResponseErrorType { + NOT_FOUND + INPUT_VALIDATION_ERROR + FORBIDDEN_ERROR + UNEXPECTED_ERROR + } + + enum UserRole { + ADMIN + CUSTOMER + } + + type User { + id: ID! + name: String! + role: UserRole! + createdAt: DateTime! + } + + input UsersInput { + from: DateTime + to: DateTime + role: [UserRole!]! + } + + type UsersResponseOk { + result: [User!]! + } + union UsersResponse = UsersResponseOk | ResponseError + + scalar DateTime + `); + const document = parse(/* GraphQL */ ` + query User($id: ID!) { + user(id: $id) { + id + name + role + createdAt + } + } + + query Users($input: UsersInput!) { + users(input: $input) { + ... on UsersResponseOk { + result { + id + } + } + ... on ResponseError { + error + } + } + } + + query UsersWithScalarInput($from: DateTime!, $to: DateTime, $role: UserRole) { + users(input: { from: $from, to: $to, role: $role }) { + ... on UsersResponseOk { + result { + __typename + } + } + ... on ResponseError { + __typename + } + } + } + `); + + const result = mergeOutputs([await plugin(schema, [{ document }], {})]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type ResponseErrorType = + | 'NOT_FOUND' + | 'INPUT_VALIDATION_ERROR' + | 'FORBIDDEN_ERROR' + | 'UNEXPECTED_ERROR'; + + export type UserRole = + | 'ADMIN' + | 'CUSTOMER'; + + export type UsersInput = { + from: DateTime; + to: DateTime; + role: Array; + }; + + export type UserQueryVariables = Exact<{ + id: string; + }>; + + + export type UserQuery = { __typename?: 'Query' }; + + export type UsersQueryVariables = Exact<{ + input: UsersInput; + }>; + + + export type UsersQuery = { __typename?: 'Query', users: + | { __typename?: 'UsersResponseOk', result: Array<{ __typename?: 'User', id: string }> } + | { __typename?: 'ResponseError', error: ResponseErrorType } + }; + + export type UsersWithScalarInputQueryVariables = Exact<{ + from: any; + to?: any | null; + role?: UserRole | null; + }>; + + + export type UsersWithScalarInputQuery = { __typename?: 'Query', users: + | { __typename?: 'UsersResponseOk', result: Array<{ __typename: 'User' }> } + | { __typename: 'ResponseError' } + }; + " + `); + + // FIXME: enable this to ensure type correctness + // validateTs(content, undefined, undefined, undefined, undefined, true); + }); + + it('try different way to generate enums', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + user(id: ID!): User + } + + type User { + id: ID! + name: String! + role: UserRole! + } + + enum UserRole { + ADMIN + CUSTOMER + } + `); + const document = parse(/* GraphQL */ ` + query User($id: ID!) { + user(id: $id) { + id + name + role + } + } + `); + + const result = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'string-literal' })]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type UserRole = + | 'ADMIN' + | 'CUSTOMER'; + + export type UserQueryVariables = Exact<{ + id: string; + }>; + + + export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + " + `); + + const result2 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-numeric' })]); + + expect(result2).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export enum UserRole { + Admin = 0, + Customer = 1 + } + + export type UserQueryVariables = Exact<{ + id: string; + }>; + + + export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + " + `); + + const result3 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'const' })]); + + expect(result3).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export const UserRole = { + Admin: 'ADMIN', + Customer: 'CUSTOMER' + } as const; + + export type UserRole = typeof UserRole[keyof typeof UserRole]; + export type UserQueryVariables = Exact<{ + id: string; + }>; + + + export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + " + `); + + const result4 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-const' })]); + + expect(result4).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export const enum UserRole { + Admin = 'ADMIN', + Customer = 'CUSTOMER' + }; + + export type UserQueryVariables = Exact<{ + id: string; + }>; + + + export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + " + `); + + const result5 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native' })]); + + expect(result5).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export enum UserRole { + Admin = 'ADMIN', + Customer = 'CUSTOMER' + } + + export type UserQueryVariables = Exact<{ + id: string; + }>; + + + export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + " + `); + }); + it('test overrdiding config.scalars', async () => { const schema = buildSchema(/* GraphQL */ ` type Query { From 9480528bf9f32ab16eb727dac088952656e7ae61 Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Tue, 2 Dec 2025 14:10:19 -0500 Subject: [PATCH 05/10] cleanup --- packages/plugins/typescript/operations/src/visitor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 0a9c71873cd..9f65ac616d2 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -266,9 +266,9 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const inputType = this.scalars?.[node.name.value]?.input ?? SCALARS[node.name.value] ?? 'any'; if (inputType === 'any' && node.name.value) { return node.name.value; - } - return inputType; - + } + + return inputType; } if (schemaType instanceof GraphQLEnumType || schemaType instanceof GraphQLInputObjectType) { From 477c9e8728c161e248a30520ec1c84f22b448b11 Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Tue, 2 Dec 2025 14:13:59 -0500 Subject: [PATCH 06/10] better code --- .../plugins/typescript/operations/src/visitor.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 9f65ac616d2..262bcdb83d8 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -222,6 +222,12 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< return this.getInputObjectDeclarationBlock(node).string; } + InputValueDefinition(node: InputValueDefinitionNode): string { + const comment = transformComment(node.description?.value || '', 1); + const type: string = node.type as any as string; + return comment + indent(`${node.name.value}: ${type};`); + } + private getInputObjectDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock { return new DeclarationBlock(this._declarationBlockConfig) .export() @@ -241,12 +247,6 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< .withContent(`\n` + (node.fields || []).join('\n |')); } - InputValueDefinition(node: InputValueDefinitionNode): string { - const comment = transformComment(node.description?.value || '', 1); - const type: string = node.type as any as string; - return comment + indent(`${node.name.value}: ${type};`); - } - private isValidVisit(ancestors: any): boolean { const currentVisitContext = this.getVisitorKindContextFromAncestors(ancestors); const isVisitingInputType = currentVisitContext.includes(Kind.INPUT_OBJECT_TYPE_DEFINITION); From 7094891901cd78e90866d63b2123789796da4364 Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Tue, 2 Dec 2025 15:16:50 -0500 Subject: [PATCH 07/10] better tests --- .../tests/ts-documents.standalone.spec.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index ad572fe1333..e20fdd8bb17 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -268,12 +268,9 @@ describe('TypeScript Operations Plugin - Standalone', () => { }; " `); - - // FIXME: enable this to ensure type correctness - // validateTs(content, undefined, undefined, undefined, undefined, true); }); - it('try different way to generate enums', async () => { + it('try different ways to generate enums', async () => { const schema = buildSchema(/* GraphQL */ ` type Query { user(id: ID!): User @@ -300,9 +297,10 @@ describe('TypeScript Operations Plugin - Standalone', () => { } `); - const result = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'string-literal' })]); + // string-literal + const resultStringLiteral = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'string-literal' })]); - expect(result).toMatchInlineSnapshot(` + expect(resultStringLiteral).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; export type UserRole = | 'ADMIN' @@ -317,9 +315,10 @@ describe('TypeScript Operations Plugin - Standalone', () => { " `); - const result2 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-numeric' })]); + // native-numeric + const resultNativeNumeric = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-numeric' })]); - expect(result2).toMatchInlineSnapshot(` + expect(resultNativeNumeric).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; export enum UserRole { Admin = 0, @@ -335,9 +334,10 @@ describe('TypeScript Operations Plugin - Standalone', () => { " `); - const result3 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'const' })]); + // const + const resultConst = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'const' })]); - expect(result3).toMatchInlineSnapshot(` + expect(resultConst).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; export const UserRole = { Admin: 'ADMIN', @@ -354,9 +354,10 @@ describe('TypeScript Operations Plugin - Standalone', () => { " `); - const result4 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-const' })]); + // native-const + const resultNativeConst = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-const' })]); - expect(result4).toMatchInlineSnapshot(` + expect(resultNativeConst).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; export const enum UserRole { Admin = 'ADMIN', @@ -372,9 +373,10 @@ describe('TypeScript Operations Plugin - Standalone', () => { " `); - const result5 = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native' })]); + // native + const resultNative = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native' })]); - expect(result5).toMatchInlineSnapshot(` + expect(resultNative).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; export enum UserRole { Admin = 'ADMIN', From d4177d80e7c83d179d401283f63ada32de90788d Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Thu, 4 Dec 2025 12:21:23 -0500 Subject: [PATCH 08/10] bugfixing for inner types and outer enums --- dev-test/star-wars/types.avoidOptionals.ts | 7 + dev-test/star-wars/types.excludeQueryAlpha.ts | 7 + dev-test/star-wars/types.excludeQueryBeta.ts | 7 + .../star-wars/types.globallyAvailable.d.ts | 7 + dev-test/star-wars/types.immutableTypes.ts | 7 + ...ypes.preResolveTypes.onlyOperationTypes.ts | 7 + dev-test/star-wars/types.preResolveTypes.ts | 7 + dev-test/star-wars/types.skipSchema.ts | 7 + dev-test/star-wars/types.ts | 7 + .../typescript/operations/src/visitor.ts | 20 + .../tests/ts-documents.standalone.spec.ts | 374 ++++++++++-------- 11 files changed, 285 insertions(+), 172 deletions(-) diff --git a/dev-test/star-wars/types.avoidOptionals.ts b/dev-test/star-wars/types.avoidOptionals.ts index be304fc5c1f..d31bf22709a 100644 --- a/dev-test/star-wars/types.avoidOptionals.ts +++ b/dev-test/star-wars/types.avoidOptionals.ts @@ -240,6 +240,13 @@ export type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.excludeQueryAlpha.ts b/dev-test/star-wars/types.excludeQueryAlpha.ts index 540f85e5c25..142bda39b04 100644 --- a/dev-test/star-wars/types.excludeQueryAlpha.ts +++ b/dev-test/star-wars/types.excludeQueryAlpha.ts @@ -240,6 +240,13 @@ export type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.excludeQueryBeta.ts b/dev-test/star-wars/types.excludeQueryBeta.ts index 9233ecfac9f..ac64e697f8a 100644 --- a/dev-test/star-wars/types.excludeQueryBeta.ts +++ b/dev-test/star-wars/types.excludeQueryBeta.ts @@ -240,6 +240,13 @@ export type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.globallyAvailable.d.ts b/dev-test/star-wars/types.globallyAvailable.d.ts index 3e569d17f5b..8045400d9c8 100644 --- a/dev-test/star-wars/types.globallyAvailable.d.ts +++ b/dev-test/star-wars/types.globallyAvailable.d.ts @@ -238,6 +238,13 @@ type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.immutableTypes.ts b/dev-test/star-wars/types.immutableTypes.ts index bea03b64839..7d97df5dee9 100644 --- a/dev-test/star-wars/types.immutableTypes.ts +++ b/dev-test/star-wars/types.immutableTypes.ts @@ -240,6 +240,13 @@ export type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts index c503da95b02..c96287fddd9 100644 --- a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts @@ -49,6 +49,13 @@ export type ReviewInput = { stars: Scalars['Int']['input']; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.preResolveTypes.ts b/dev-test/star-wars/types.preResolveTypes.ts index 42cd01885f4..11503496d1c 100644 --- a/dev-test/star-wars/types.preResolveTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.ts @@ -240,6 +240,13 @@ export type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.skipSchema.ts b/dev-test/star-wars/types.skipSchema.ts index 42cd01885f4..11503496d1c 100644 --- a/dev-test/star-wars/types.skipSchema.ts +++ b/dev-test/star-wars/types.skipSchema.ts @@ -240,6 +240,13 @@ export type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/dev-test/star-wars/types.ts b/dev-test/star-wars/types.ts index 42cd01885f4..11503496d1c 100644 --- a/dev-test/star-wars/types.ts +++ b/dev-test/star-wars/types.ts @@ -240,6 +240,13 @@ export type StarshipLengthArgs = { unit?: InputMaybe; }; +/** The input object sent when passing a color */ +export type ColorInput = { + blue: number; + green: number; + red: number; +}; + /** The episodes in the Star Wars trilogy */ export type Episode = /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 262bcdb83d8..ecdba9be33b 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -313,6 +313,23 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< return `${prefix}Exact<${variablesBlock === '{}' ? `{ [key: string]: never; }` : variablesBlock}>${extraType}`; } + private collectInnerTypesRecursively(type: GraphQLInputObjectType, usedInputTypes: UsedNamedInputTypes): void { + const fields = type.getFields(); + for (const field of Object.values(fields)) { + const fieldType = getNamedType(field.type); + if (( + fieldType instanceof GraphQLEnumType || + fieldType instanceof GraphQLInputObjectType || + fieldType instanceof GraphQLScalarType + ) && !usedInputTypes[fieldType.name]) { + usedInputTypes[fieldType.name] = fieldType; + if (fieldType instanceof GraphQLInputObjectType) { + this.collectInnerTypesRecursively(fieldType, usedInputTypes); + } + } + } + } + private collectUsedInputTypes({ schema, documentNode, @@ -337,6 +354,9 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< foundInputType instanceof GraphQLEnumType) ) { usedInputTypes[namedTypeNode.name.value] = foundInputType; + if (foundInputType instanceof GraphQLInputObjectType) { + this.collectInnerTypesRecursively(foundInputType, usedInputTypes); + } } }, }); diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index e20fdd8bb17..c82bebfdb5c 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -139,81 +139,45 @@ describe('TypeScript Operations Plugin - Standalone', () => { // validateTs(content, undefined, undefined, undefined, undefined, true); }); - it('test generating input types lists', async () => { + it('test generating input types enums in lists and inner field', async () => { const schema = buildSchema(/* GraphQL */ ` type Query { - users(input: UsersInput!): UsersResponse! + users(input: UsersInput!): [User!]! } - type ResponseError { - error: ResponseErrorType! + type User { + id: ID! } - enum ResponseErrorType { - NOT_FOUND - INPUT_VALIDATION_ERROR - FORBIDDEN_ERROR - UNEXPECTED_ERROR + enum EnumRootLevel { + ENUM_A + ENUM_B } - enum UserRole { - ADMIN - CUSTOMER + enum EnumRootLevelArray { + ENUM_C + ENUM_D } - type User { - id: ID! - name: String! - role: UserRole! - createdAt: DateTime! + enum EnumInnerArray { + ENUM_E + ENUM_F } - input UsersInput { - from: DateTime - to: DateTime - role: [UserRole!]! + input EnumsInner { + enumsDeep: [EnumInnerArray!]! } - type UsersResponseOk { - result: [User!]! + input UsersInput { + enum: EnumRootLevel! + enums: [EnumRootLevelArray!]! + innerEnums: EnumsInner! } - union UsersResponse = UsersResponseOk | ResponseError - - scalar DateTime `); const document = parse(/* GraphQL */ ` - query User($id: ID!) { - user(id: $id) { - id - name - role - createdAt - } - } - query Users($input: UsersInput!) { users(input: $input) { - ... on UsersResponseOk { - result { - id - } - } - ... on ResponseError { - error - } - } - } - - query UsersWithScalarInput($from: DateTime!, $to: DateTime, $role: UserRole) { - users(input: { from: $from, to: $to, role: $role }) { - ... on UsersResponseOk { - result { - __typename - } - } - ... on ResponseError { - __typename - } + id } } `); @@ -222,209 +186,275 @@ describe('TypeScript Operations Plugin - Standalone', () => { expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; - export type ResponseErrorType = - | 'NOT_FOUND' - | 'INPUT_VALIDATION_ERROR' - | 'FORBIDDEN_ERROR' - | 'UNEXPECTED_ERROR'; - - export type UserRole = - | 'ADMIN' - | 'CUSTOMER'; + export type EnumRootLevel = + | 'ENUM_A' + | 'ENUM_B'; - export type UsersInput = { - from: DateTime; - to: DateTime; - role: Array; - }; + export type EnumRootLevelArray = + | 'ENUM_C' + | 'ENUM_D'; - export type UserQueryVariables = Exact<{ - id: string; - }>; + export type EnumInnerArray = + | 'ENUM_E' + | 'ENUM_F'; + export type EnumsInner = { + enumsDeep: Array; + }; - export type UserQuery = { __typename?: 'Query' }; + export type UsersInput = { + enum: EnumRootLevel; + enums: Array; + innerEnums: EnumsInner; + }; export type UsersQueryVariables = Exact<{ input: UsersInput; }>; - export type UsersQuery = { __typename?: 'Query', users: - | { __typename?: 'UsersResponseOk', result: Array<{ __typename?: 'User', id: string }> } - | { __typename?: 'ResponseError', error: ResponseErrorType } - }; - - export type UsersWithScalarInputQueryVariables = Exact<{ - from: any; - to?: any | null; - role?: UserRole | null; - }>; - - - export type UsersWithScalarInputQuery = { __typename?: 'Query', users: - | { __typename?: 'UsersResponseOk', result: Array<{ __typename: 'User' }> } - | { __typename: 'ResponseError' } - }; + export type UsersQuery = { __typename?: 'Query', users: Array<{ __typename?: 'User', id: string }> }; " `); }); - it('try different ways to generate enums', async () => { + it('test generating output enums in lists and inner field', async () => { const schema = buildSchema(/* GraphQL */ ` type Query { - user(id: ID!): User + user(id: ID!): User! } - type User { - id: ID! - name: String! - role: UserRole! + enum EnumRootLevel { + ENUM_A + ENUM_B } - enum UserRole { - ADMIN - CUSTOMER + enum EnumRootLevelArray { + ENUM_C + ENUM_D + } + + enum EnumInnerArray { + ENUM_E + ENUM_F + } + + type EnumsInner { + enumsDeep: [EnumInnerArray!]! + } + + type User { + enum: EnumRootLevel! + enums: [EnumRootLevelArray!]! + innerEnums: EnumsInner! } `); const document = parse(/* GraphQL */ ` query User($id: ID!) { user(id: $id) { - id - name - role + enum + enums + innerEnums { + enumsDeep + } } } `); - // string-literal - const resultStringLiteral = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'string-literal' })]); + const result = mergeOutputs([ + await plugin(schema, [{ document }], { + extractAllFieldsToTypes: true, // Extracts all fields to separate types (similar to apollo-codegen behavior) + printFieldsOnNewLines: true, // Prints each field on a new line (similar to apollo-codegen behavior) + }), + ]); - expect(resultStringLiteral).toMatchInlineSnapshot(` + expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; - export type UserRole = - | 'ADMIN' - | 'CUSTOMER'; + export type EnumRootLevel = + | 'ENUM_A' + | 'ENUM_B'; - export type UserQueryVariables = Exact<{ - id: string; - }>; + export type EnumRootLevelArray = + | 'ENUM_C' + | 'ENUM_D'; + export type EnumInnerArray = + | 'ENUM_E' + | 'ENUM_F'; - export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; - " - `); + export type UserQuery_user_User_innerEnums_EnumsInner = { + __typename?: 'EnumsInner', + enumsDeep: Array + }; - // native-numeric - const resultNativeNumeric = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-numeric' })]); + export type UserQuery_user_User = { + __typename?: 'User', + enum: EnumRootLevel, + enums: Array, + innerEnums: UserQuery_user_User_innerEnums_EnumsInner + }; + + export type UserQuery_Query = { + __typename?: 'Query', + user: UserQuery_user_User + }; - expect(resultNativeNumeric).toMatchInlineSnapshot(` - "type Exact = { [K in keyof T]: T[K] }; - export enum UserRole { - Admin = 0, - Customer = 1 - } export type UserQueryVariables = Exact<{ id: string; }>; - export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + export type UserQuery = UserQuery_Query; " `); + }); - // const - const resultConst = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'const' })]); + it('test overrdiding config.scalars', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + user(id: ID!): User + } - expect(resultConst).toMatchInlineSnapshot(` - "type Exact = { [K in keyof T]: T[K] }; - export const UserRole = { - Admin: 'ADMIN', - Customer: 'CUSTOMER' - } as const; + type User { + id: ID! + name: String! + } + `); + const document = parse(/* GraphQL */ ` + query User($id: ID!) { + user(id: $id) { + id + name + } + } + `); - export type UserRole = typeof UserRole[keyof typeof UserRole]; + const result = mergeOutputs([ + await plugin(schema, [{ document }], { scalars: { ID: 'string | number | boolean' } }), + ]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; export type UserQueryVariables = Exact<{ - id: string; + id: string | number | boolean; }>; - export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string | number | boolean, name: string } | null }; " `); + }); - // native-const - const resultNativeConst = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native-const' })]); - - expect(resultNativeConst).toMatchInlineSnapshot(` - "type Exact = { [K in keyof T]: T[K] }; - export const enum UserRole { - Admin = 'ADMIN', - Customer = 'CUSTOMER' - }; + it('test render output enum from fragment in the same document', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum RoleType { + ROLE_A + ROLE_B + } - export type UserQueryVariables = Exact<{ - id: string; - }>; + type User { + id: ID! + name: String! + role: RoleType + pictureUrl: String + } + type Query { + users: [User!]! + viewer: User! + } + `); + const document = parse(/* GraphQL */ ` + fragment UserBasic on User { + id + name + role + } - export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; - " + query GetUsersAndViewer { + users { + ...UserBasic + } + viewer { + ...UserBasic + } + } `); - // native - const resultNative = mergeOutputs([await plugin(schema, [{ document }], { enumType: 'native' })]); + const result = mergeOutputs([await plugin(schema, [{ document }], {})]); - expect(resultNative).toMatchInlineSnapshot(` + expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; - export enum UserRole { - Admin = 'ADMIN', - Customer = 'CUSTOMER' - } + export type RoleType = + | 'ROLE_A' + | 'ROLE_B'; - export type UserQueryVariables = Exact<{ - id: string; - }>; + export type UserBasicFragment = { __typename?: 'User', id: string, name: string, role?: RoleType | null }; + export type GetUsersAndViewerQueryVariables = Exact<{ [key: string]: never; }>; - export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, name: string, role: UserRole } | null }; + + export type GetUsersAndViewerQuery = { __typename?: 'Query', users: Array<{ __typename?: 'User', id: string, name: string, role?: RoleType | null }>, viewer: { __typename?: 'User', id: string, name: string, role?: RoleType | null } }; " `); }); - it('test overrdiding config.scalars', async () => { + it('test render output enum from fragment in a separate document', async () => { const schema = buildSchema(/* GraphQL */ ` - type Query { - user(id: ID!): User + enum RoleType { + ROLE_A + ROLE_B } type User { id: ID! name: String! + role: RoleType + pictureUrl: String + } + + type Query { + users: [User!]! + viewer: User! } `); - const document = parse(/* GraphQL */ ` - query User($id: ID!) { - user(id: $id) { - id - name + + const documentWithFragment = parse(/* GraphQL */ ` + fragment UserBasic on User { + id + name + role + } + `); + + const documentMain = parse(/* GraphQL */ ` + query GetUsersAndViewer { + users { + ...UserBasic + } + viewer { + ...UserBasic } } `); const result = mergeOutputs([ - await plugin(schema, [{ document }], { scalars: { ID: 'string | number | boolean' } }), + await plugin(schema, [{ document: documentMain }, { document: documentWithFragment }], {}), ]); expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; - export type UserQueryVariables = Exact<{ - id: string | number | boolean; - }>; + export type RoleType = + | 'ROLE_A' + | 'ROLE_B'; + export type GetUsersAndViewerQueryVariables = Exact<{ [key: string]: never; }>; - export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string | number | boolean, name: string } | null }; + + export type GetUsersAndViewerQuery = { __typename?: 'Query', users: Array<{ __typename?: 'User', id: string, name: string, role?: RoleType | null }>, viewer: { __typename?: 'User', id: string, name: string, role?: RoleType | null } }; + + export type UserBasicFragment = { __typename?: 'User', id: string, name: string, role?: RoleType | null }; " `); }); From d1fc7cc81e979f1d6c34ceb592fb8798b5569521 Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Thu, 4 Dec 2025 13:45:38 -0500 Subject: [PATCH 09/10] bugfixing after merge --- .../typescript/operations/src/visitor.ts | 19 +++++++++++ .../tests/ts-documents.standalone.spec.ts | 34 ++++++++++--------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 8dab920d00c..6e79104771e 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -42,6 +42,7 @@ import { ListTypeNode, NamedTypeNode, NonNullTypeNode, + ScalarTypeDefinitionNode, TypeInfo, visit, visitWithTypeInfo, @@ -210,6 +211,24 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< }); } + ScalarTypeDefinition(node: ScalarTypeDefinitionNode): string | null { + const scalarName = node.name.value; + + // Don't generate type aliases for built-in scalars + if (SCALARS[scalarName] || !this._usedNamedInputTypes[scalarName]) { + return null; + } + + // Check if a custom scalar mapping is provided in config + const scalarType = this.scalars?.[scalarName]?.input ?? 'any'; + + return new DeclarationBlock(this._declarationBlockConfig) + .export() + .asKind('type') + .withName(this.convertName(node)) + .withContent(scalarType).string; + } + InputObjectTypeDefinition(node: InputObjectTypeDefinitionNode): string | null { const inputTypeName = node.name.value; if (!this._usedNamedInputTypes[inputTypeName]) { diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index 5a05362685a..2c1a105a875 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -161,12 +161,12 @@ describe('TypeScript Operations Plugin - Standalone', () => { id: ID! } - enum EnumRootLevel { + enum EnumRoot { ENUM_A ENUM_B } - enum EnumRootLevelArray { + enum EnumRootArray { ENUM_C ENUM_D } @@ -181,8 +181,8 @@ describe('TypeScript Operations Plugin - Standalone', () => { } input UsersInput { - enum: EnumRootLevel! - enums: [EnumRootLevelArray!]! + enum: EnumRoot! + enums: [EnumRootArray!]! innerEnums: EnumsInner! } `); @@ -198,11 +198,11 @@ describe('TypeScript Operations Plugin - Standalone', () => { expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; - export type EnumRootLevel = + export type EnumRoot = | 'ENUM_A' | 'ENUM_B'; - export type EnumRootLevelArray = + export type EnumRootArray = | 'ENUM_C' | 'ENUM_D'; @@ -215,8 +215,8 @@ describe('TypeScript Operations Plugin - Standalone', () => { }; export type UsersInput = { - enum: EnumRootLevel; - enums: Array; + enum: EnumRoot; + enums: Array; innerEnums: EnumsInner; }; @@ -236,12 +236,12 @@ describe('TypeScript Operations Plugin - Standalone', () => { user(id: ID!): User! } - enum EnumRootLevel { + enum EnumRoot { ENUM_A ENUM_B } - enum EnumRootLevelArray { + enum EnumRootArray { ENUM_C ENUM_D } @@ -256,8 +256,8 @@ describe('TypeScript Operations Plugin - Standalone', () => { } type User { - enum: EnumRootLevel! - enums: [EnumRootLevelArray!]! + enum: EnumRoot! + enums: [EnumRootArray!]! innerEnums: EnumsInner! } `); @@ -282,11 +282,11 @@ describe('TypeScript Operations Plugin - Standalone', () => { expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; - export type EnumRootLevel = + export type EnumRoot = | 'ENUM_A' | 'ENUM_B'; - export type EnumRootLevelArray = + export type EnumRootArray = | 'ENUM_C' | 'ENUM_D'; @@ -301,8 +301,8 @@ describe('TypeScript Operations Plugin - Standalone', () => { export type UserQuery_user_User = { __typename?: 'User', - enum: EnumRootLevel, - enums: Array, + enum: EnumRoot, + enums: Array, innerEnums: UserQuery_user_User_innerEnums_EnumsInner }; @@ -607,6 +607,8 @@ describe('TypeScript Operations Plugin - Standalone', () => { to: DateTime; role: UserRole; }; + + export type DateTime = any; " `); From 3954c3c308ae1af1c42009208190162dc198073f Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Thu, 11 Dec 2025 15:47:46 -0500 Subject: [PATCH 10/10] cleanup --- packages/plugins/typescript/operations/src/visitor.ts | 10 +++++++--- .../operations/tests/ts-documents.standalone.spec.ts | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 6e79104771e..516d17fc9e1 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -258,10 +258,9 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< } private getInputObjectOneOfDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock { - const declarationKind = (node.fields?.length || 0) === 1 ? 'type' : 'type'; return new DeclarationBlock(this._declarationBlockConfig) .export() - .asKind(declarationKind) + .asKind('type') .withName(this.convertName(node)) .withComment(node.description?.value) .withContent(`\n` + (node.fields || []).join('\n |')); @@ -338,6 +337,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< for (const field of Object.values(fields)) { const fieldType = getNamedType(field.type); if ( + fieldType && (fieldType instanceof GraphQLEnumType || fieldType instanceof GraphQLInputObjectType || fieldType instanceof GraphQLScalarType) && @@ -372,7 +372,8 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< foundInputType && (foundInputType instanceof GraphQLInputObjectType || foundInputType instanceof GraphQLScalarType || - foundInputType instanceof GraphQLEnumType) + foundInputType instanceof GraphQLEnumType) && + !usedInputTypes[namedTypeNode.name.value] ) { usedInputTypes[namedTypeNode.name.value] = foundInputType; if (foundInputType instanceof GraphQLInputObjectType) { @@ -388,6 +389,9 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const typeInfo = new TypeInfo(schema); visit( documentNode, + // AST doesn’t include field types (they are defined in schema) - only names. + // TypeInfo is a stateful helper that tracks typing context while walking the AST + // visitWithTypeInfo wires that context into a visitor. visitWithTypeInfo(typeInfo, { Field: () => { const fieldType = typeInfo.getType(); diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index 2c1a105a875..2c9cca7dce5 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -116,6 +116,8 @@ describe('TypeScript Operations Plugin - Standalone', () => { role: UserRole; }; + export type DateTime = any; + export type UserQueryVariables = Exact<{ id: string; }>;