Skip to content

Commit 8301e93

Browse files
GlebYavorskiclaude
andcommitted
fix: don't stub every field when a mapper type is unresolvable
`fixObjectTypeResolvers` ('smart' and 'fast') built a mapper's property set from `getProperties()`, which returns `[]` for a TypeScript *error type* (an unresolvable import, e.g. a client that hasn't been generated yet). An error type was therefore indistinguishable from an empty mapper, so a resolver stub was injected for every field, silently overwriting hand-maintained resolver files with broken `Promise<void>` stubs. Detect the error type via `isNodeTypeUnresolved` before deriving the property map; when the mapper is unresolvable, skip stub generation (leaving existing resolvers untouched) and emit a warning naming the mapper so the real problem — the unresolved import — surfaces. Genuinely empty mappers (`type FooMapper = {}`) still stub as before. Fixes #446 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4a68a80 commit 8301e93

5 files changed

Lines changed: 296 additions & 2 deletions

File tree

.changeset/tricky-mappers-warn.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@eddeee888/gcg-typescript-resolver-files': patch
3+
---
4+
5+
Fix `fixObjectTypeResolvers` treating an unresolvable mapper type as an empty mapper and injecting a stub for every field
6+
7+
When a mapper aliases a type whose import cannot be resolved (a TypeScript _error type_ — e.g. a generated client that has not been generated yet, or a module that is not installed on a fresh checkout), `fixObjectTypeResolvers` (both `smart` and `fast`) previously treated the mapper as having zero fields and injected a resolver stub for every field of the schema type, silently overwriting hand-maintained resolver files with broken `Promise<void>` stubs.
8+
9+
The generator now detects the unresolved type, skips stub generation for that mapper (leaving existing resolvers untouched), and emits a warning naming the mapper so the underlying unresolved import surfaces instead. Genuinely empty mappers (e.g. `type FooMapper = {}`) still generate stubs as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Project, SyntaxKind } from 'ts-morph';
2+
import { getGraphQLObjectTypeResolversToGenerate } from './getGraphQLObjectTypeResolversToGenerate';
3+
import { logger } from '../utils';
4+
import type { TypeMappersMap } from '../parseTypeMappers';
5+
import type { GeneratedTypesFileMeta } from '../generateResolverFiles';
6+
import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema';
7+
8+
const MAPPERS_PATH = '/path/to/schema.mappers.ts';
9+
const TYPES_PATH = '/path/to/types.generated.ts';
10+
11+
const setup = ({
12+
mapperContent,
13+
}: {
14+
mapperContent: string;
15+
}): {
16+
tsMorphProject: Project;
17+
typesSourceFile: ReturnType<Project['createSourceFile']>;
18+
typeMappersMap: TypeMappersMap;
19+
userDefinedSchemaObjectTypeMap: ParsedGraphQLSchemaMeta['userDefinedSchemaTypeMap']['object'];
20+
generatedTypesFileMeta: GeneratedTypesFileMeta;
21+
} => {
22+
const tsMorphProject = new Project({ skipAddingFilesFromTsConfig: true });
23+
24+
// A stand-in for the generated `types.generated.ts`: the `User` type has two
25+
// fields, so if `UserMapper` is treated as empty, both would be stubbed.
26+
const typesSourceFile = tsMorphProject.createSourceFile(
27+
TYPES_PATH,
28+
`export type UserResolvers = {
29+
id?: unknown;
30+
name?: unknown;
31+
};`
32+
);
33+
34+
tsMorphProject.createSourceFile(MAPPERS_PATH, mapperContent);
35+
36+
const typeMappersMap: TypeMappersMap = {
37+
User: {
38+
schemaType: 'User',
39+
mapper: {
40+
name: 'UserMapper',
41+
filename: MAPPERS_PATH,
42+
kind: SyntaxKind.TypeAliasDeclaration,
43+
},
44+
configImportPath: '',
45+
},
46+
};
47+
48+
const generatedTypesFileMeta = {
49+
generatedResolverTypes: {
50+
resolversMap: { name: 'Resolvers' },
51+
userDefined: { User: { name: 'UserResolvers' } },
52+
},
53+
} as unknown as GeneratedTypesFileMeta;
54+
55+
const userDefinedSchemaObjectTypeMap = {
56+
User: {},
57+
} as unknown as ParsedGraphQLSchemaMeta['userDefinedSchemaTypeMap']['object'];
58+
59+
return {
60+
tsMorphProject,
61+
typesSourceFile,
62+
typeMappersMap,
63+
userDefinedSchemaObjectTypeMap,
64+
generatedTypesFileMeta,
65+
};
66+
};
67+
68+
describe('getGraphQLObjectTypeResolversToGenerate', () => {
69+
beforeEach(() => {
70+
vi.restoreAllMocks();
71+
});
72+
73+
// Regression test for https://github.com/eddeee888/graphql-code-generator-plugins/issues/446
74+
it.each(['smart', 'fast'] as const)(
75+
'skips stub generation and warns when a mapper type is unresolvable (mode: %s)',
76+
(mode) => {
77+
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {
78+
// noop
79+
});
80+
const {
81+
tsMorphProject,
82+
typesSourceFile,
83+
typeMappersMap,
84+
userDefinedSchemaObjectTypeMap,
85+
generatedTypesFileMeta,
86+
} = setup({
87+
mapperContent: `import type { User } from './does-not-exist';
88+
export type UserMapper = User;`,
89+
});
90+
91+
const result = getGraphQLObjectTypeResolversToGenerate({
92+
mode,
93+
tsMorphProject,
94+
typesSourceFile,
95+
typeMappersMap,
96+
userDefinedSchemaObjectTypeMap,
97+
generatedTypesFileMeta,
98+
});
99+
100+
// No stubs injected: the hand-maintained resolver file is left untouched.
101+
expect(result.User).toBeUndefined();
102+
103+
// The real problem (unresolved import) is surfaced via a warning.
104+
expect(warnSpy).toHaveBeenCalledTimes(1);
105+
expect(warnSpy.mock.calls[0][0]).toContain('UserMapper');
106+
}
107+
);
108+
109+
it.each(['smart', 'fast'] as const)(
110+
'still stubs fields for a mapper that genuinely has no matching fields (mode: %s)',
111+
(mode) => {
112+
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {
113+
// noop
114+
});
115+
const {
116+
tsMorphProject,
117+
typesSourceFile,
118+
typeMappersMap,
119+
userDefinedSchemaObjectTypeMap,
120+
generatedTypesFileMeta,
121+
} = setup({ mapperContent: `export type UserMapper = {};` });
122+
123+
const result = getGraphQLObjectTypeResolversToGenerate({
124+
mode,
125+
tsMorphProject,
126+
typesSourceFile,
127+
typeMappersMap,
128+
userDefinedSchemaObjectTypeMap,
129+
generatedTypesFileMeta,
130+
});
131+
132+
// A genuinely empty mapper still triggers stub generation (unchanged behavior).
133+
expect(result.User).toBeDefined();
134+
expect(result.User.id).toBeDefined();
135+
expect(result.User.name).toBeDefined();
136+
expect(warnSpy).not.toHaveBeenCalled();
137+
}
138+
);
139+
});

packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ import {
1010
Node,
1111
} from 'ts-morph';
1212
import type { TypeMapperDetails, TypeMappersMap } from '../parseTypeMappers';
13-
import { type NodePropertyMap, getNodePropertyMap } from './getNodePropertyMap';
13+
import {
14+
type NodePropertyMap,
15+
getNodePropertyMap,
16+
isNodeTypeUnresolved,
17+
} from './getNodePropertyMap';
1418
import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema';
1519
import type { GeneratedTypesFileMeta } from '../generateResolverFiles';
20+
import { logger } from '../utils';
1621

1722
export type GraphQLObjectTypeResolversToGenerate = Record<
1823
string,
@@ -111,6 +116,17 @@ export const getGraphQLObjectTypeResolversToGenerate = ({
111116
tsMorphProject,
112117
mapper,
113118
});
119+
120+
// If the mapper's type cannot be resolved (e.g. it aliases a type from an
121+
// unresolvable import), it reports zero properties, which is
122+
// indistinguishable from an empty mapper. Generating stubs here would
123+
// overwrite hand-maintained resolvers with broken ones, so we skip this
124+
// mapper and warn instead. See issue #446.
125+
if (isNodeTypeUnresolved({ node: mapperOriginalDeclarationNode })) {
126+
logger.warn(getUnresolvedMapperWarning({ mapper, schemaType }));
127+
return;
128+
}
129+
114130
const mapperPropsMap = getNodePropertyMap({
115131
node: mapperOriginalDeclarationNode,
116132
});
@@ -205,6 +221,17 @@ export const getGraphQLObjectTypeResolversToGenerate = ({
205221
tsMorphProject,
206222
mapper,
207223
});
224+
225+
// If the mapper's type cannot be resolved (e.g. it aliases a type from an
226+
// unresolvable import), it reports zero properties, which is
227+
// indistinguishable from an empty mapper. Generating stubs here would
228+
// overwrite hand-maintained resolvers with broken ones, so we skip this
229+
// mapper and warn instead. See issue #446.
230+
if (isNodeTypeUnresolved({ node: originalDeclarationNode })) {
231+
logger.warn(getUnresolvedMapperWarning({ mapper, schemaType }));
232+
return;
233+
}
234+
208235
const typeMapperPropertyMap = getNodePropertyMap({
209236
node: originalDeclarationNode,
210237
});
@@ -267,6 +294,16 @@ export const getGraphQLObjectTypeResolversToGenerate = ({
267294
return result;
268295
};
269296

297+
const getUnresolvedMapperWarning = ({
298+
mapper,
299+
schemaType,
300+
}: {
301+
mapper: TypeMapperDetails['mapper'];
302+
schemaType: string;
303+
}): string => {
304+
return `Skipping resolver generation for schema type "${schemaType}" because its mapper "${mapper.name}" (${mapper.filename}) could not be resolved. This usually means the mapper aliases a type from an import that does not exist yet (e.g. a generated client that has not been generated). Existing resolvers are left untouched. Fix the unresolved import and re-run codegen.`;
305+
};
306+
270307
const mustGetMapperOriginalDeclarationNode = ({
271308
tsMorphProject,
272309
mapper,

packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Project, Node } from 'ts-morph';
2-
import { getNodePropertyMap } from './getNodePropertyMap';
2+
import { getNodePropertyMap, isNodeTypeUnresolved } from './getNodePropertyMap';
33

44
describe('getNodePropertyMap', () => {
55
it('correctly resolves property map of a typical types.generated.ts', () => {
@@ -113,3 +113,79 @@ describe('getNodePropertyMap', () => {
113113
expect(nodePropertyMap.role.name).toBe('role');
114114
});
115115
});
116+
117+
describe('isNodeTypeUnresolved', () => {
118+
it('returns true when a type alias mapper aliases a type from an unresolvable import', () => {
119+
const project = new Project();
120+
const sourceFile = project.createSourceFile(
121+
'/path/to/mappers.ts',
122+
`import type { User } from './does-not-exist';
123+
export type UserMapper = User;`
124+
);
125+
126+
const node = sourceFile.getFirstDescendant(
127+
(descendant) =>
128+
Node.isTypeAliasDeclaration(descendant) &&
129+
descendant.getName() === 'UserMapper'
130+
);
131+
132+
expect(isNodeTypeUnresolved({ node })).toBe(true);
133+
});
134+
135+
it('returns false for a mapper that genuinely has no properties (empty object type)', () => {
136+
const project = new Project();
137+
const sourceFile = project.createSourceFile(
138+
'/path/to/mappers.ts',
139+
`export type EmptyMapper = {};`
140+
);
141+
142+
const node = sourceFile.getFirstDescendant(
143+
(descendant) =>
144+
Node.isTypeAliasDeclaration(descendant) &&
145+
descendant.getName() === 'EmptyMapper'
146+
);
147+
148+
expect(isNodeTypeUnresolved({ node })).toBe(false);
149+
});
150+
151+
it('returns false for an explicit `any` mapper (not an error type)', () => {
152+
const project = new Project();
153+
const sourceFile = project.createSourceFile(
154+
'/path/to/mappers.ts',
155+
`export type AnyMapper = any;`
156+
);
157+
158+
const node = sourceFile.getFirstDescendant(
159+
(descendant) =>
160+
Node.isTypeAliasDeclaration(descendant) &&
161+
descendant.getName() === 'AnyMapper'
162+
);
163+
164+
expect(isNodeTypeUnresolved({ node })).toBe(false);
165+
});
166+
167+
it('returns false for a mapper whose imported type resolves', () => {
168+
const project = new Project();
169+
project.createSourceFile(
170+
'/path/to/user.ts',
171+
`export type User = { id: string; name: string };`
172+
);
173+
const sourceFile = project.createSourceFile(
174+
'/path/to/mappers.ts',
175+
`import type { User } from './user';
176+
export type UserMapper = User;`
177+
);
178+
179+
const node = sourceFile.getFirstDescendant(
180+
(descendant) =>
181+
Node.isTypeAliasDeclaration(descendant) &&
182+
descendant.getName() === 'UserMapper'
183+
);
184+
185+
expect(isNodeTypeUnresolved({ node })).toBe(false);
186+
});
187+
188+
it('returns false when node is undefined', () => {
189+
expect(isNodeTypeUnresolved({ node: undefined })).toBe(false);
190+
});
191+
});

packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,39 @@ export const getNodePropertyMap = ({
5656
return nodePropertyMap;
5757
};
5858

59+
/**
60+
* Detects whether a node's type failed to resolve i.e. it is the TypeScript
61+
* *error type*. This happens when a mapper aliases a type coming from an import
62+
* that cannot be resolved (e.g. a client that has not been generated yet, or a
63+
* module that is not installed on a fresh checkout).
64+
*
65+
* This must be distinguished from a mapper that genuinely has no properties
66+
* (e.g. `type Mapper = {}`): both report zero properties via `getProperties()`,
67+
* but only the unresolved one should stop resolver generation. If we treated an
68+
* unresolved type as "empty", `getGraphQLObjectTypeResolversToGenerate` would
69+
* inject a stub for every field and silently overwrite hand-maintained
70+
* resolvers. See https://github.com/eddeee888/graphql-code-generator-plugins/issues/446
71+
*
72+
* The error type is an intrinsic type named `error`. This is distinct from a
73+
* real `any` (`intrinsicName === 'any'`) and from an empty object type (which
74+
* is not an intrinsic type at all), so this check does not misfire on those.
75+
*/
76+
export const isNodeTypeUnresolved = ({
77+
node,
78+
}: {
79+
node: Node | undefined;
80+
}): boolean => {
81+
if (!node) {
82+
return false;
83+
}
84+
85+
const { intrinsicName } = node.getType().compilerType as {
86+
intrinsicName?: string;
87+
};
88+
89+
return intrinsicName === 'error';
90+
};
91+
5992
const collectClassNodeProperties = (
6093
classNode: ClassDeclaration,
6194
result: NodePropertyMapValue[]

0 commit comments

Comments
 (0)