Skip to content
Merged
26 changes: 26 additions & 0 deletions .changeset/strict-visitor-plugin-common-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@graphql-codegen/visitor-plugin-common': patch
---

Fix type errors in `visitor-plugin-common` (`utils`, `imports`, `naming`, `mappers`, `types`, `variables-to-object`, `convert-schema-enum-to-declaration-block-string`, `selection-set-processor/base` and `optimize-operations`) under `strict: true`:

- `transformComment` accepts `undefined` and `null`, which it already handles by returning an empty string.
- `block` declares its param as `string[] | null | undefined`, which it already handles by returning an empty string.
- `DeclarationBlock`'s fields are typed as `string | null` (`_name` as `string | NameNode | null`), and `withBlock` accepts `null`, which renders the same as no block.
- `DeclarationBlock` falls back to returning the block unchanged when `blockTransformer` is passed as `undefined`, instead of throwing.
- `buildScalars` accepts an `undefined` scalars mapping, which it already handles by using only the defaults and the schema.
- `buildTypeImport` accepts a nullish `identifier` and `source`, and throws a descriptive error when either is missing.
- `ParsedEnumValuesMap`'s `mappedValues`, `sourceIdentifier`, `sourceFile` and `importIdentifier` accept `null`, which `parseEnumValues` already emits.
- `transformMappers` defaults `rawMappers` to `{}`.
- `OperationVariablesToObject.getName`, `OperationVariablesToObject.transform` and `BaseSelectionSetProcessor.buildSelectionSetFromStrings` may return `null`, which they already return.
- `buildEnumValuesBlock` throws a descriptive error when an enum value is not defined on the schema's enum.
- `optimizeOperations` throws a descriptive error when a document file has no parsed document.
- `unique`'s default key uses `String(item)`.
- `convertSchemaEnumToDeclarationBlockString` treats an enum node without `values` as empty instead of throwing.
- `generateFragmentImportStatement` treats a missing `identifiers` as empty.
- `getDeprecationReason` returns the default reason for a `@deprecated` directive without arguments.
- `isOneOfInputObjectType` returns `false` instead of `undefined`.
- `buildEnumValuesBlock` treats a non-enum schema type with the enum's name as having no schema enum.
- `OperationVariablesToObject` emits `''` instead of the text `undefined`/`null` when a variable has no name or no type identifier.

Generated output is unchanged, except in the cases above.
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ export class BaseTypesVisitor<
): string {
if (this.config.onlyEnums) return '';

const comment = transformComment(node.description?.value || '', 1);
const comment = transformComment(node.description?.value, 1);
const { input } = this._parsedConfig.declarationKind;

// `node.type` is already printed to a string by the visitor; removing the cast needs a visited-AST type for `node`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type {
EnumTypeDefinitionNode,
EnumValueDefinitionNode,
GraphQLEnumType,
GraphQLSchema,
import {
isEnumType,
type EnumTypeDefinitionNode,
type EnumValueDefinitionNode,
type GraphQLSchema,
} from 'graphql';
import { convertName } from './naming.js';
import type { ConvertFn, ParsedEnumValuesMap } from './types.js';
Expand Down Expand Up @@ -75,7 +75,7 @@ export const convertSchemaEnumToDeclarationBlockString = ({
.withName(enumTypeName)
.withContent(
'\n' +
node.values
(node.values || [])

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: values is optional on EnumTypeDefinitionNode, and this function is exported, so a caller can pass in a node that has none. That makes || [] real handling, which is why it has no comment. Each if branch returns early and reads node.values only once, so the fallback stays inline in each branch (same at L98, L133 and L167) instead of going in a local at the top.


Generated by Claude Code

.map(enumOption => {
const name = enumOption.name.value;
const enumValue: string | number = getValueFromConfig(name) ?? name;
Expand All @@ -95,7 +95,7 @@ export const convertSchemaEnumToDeclarationBlockString = ({
.withName(enumTypeName)
.asKind('enum')
.withBlock(
node.values
(node.values || [])
.map((enumOption, i) => {
const valueFromConfig = getValueFromConfig(enumOption.name.value);
const enumValue: string | number = valueFromConfig ?? i;
Expand Down Expand Up @@ -130,7 +130,7 @@ export const convertSchemaEnumToDeclarationBlockString = ({
.withName(enumTypeName)
.withComment(node.description?.value)
.withBlock(
node.values
(node.values || [])
.map(enumOption => {
const optionName = makeValidEnumIdentifier(
convertName({
Expand Down Expand Up @@ -164,7 +164,7 @@ export const convertSchemaEnumToDeclarationBlockString = ({
.withBlock(
buildEnumValuesBlock({
typeName: enumName,
values: node.values,
values: node.values || [],
schema,
naming,
ignoreEnumValuesFromSchema,
Expand All @@ -189,9 +189,8 @@ export const buildEnumValuesBlock = ({
typeName: string;
values: ReadonlyArray<EnumValueDefinitionNode>;
}): string => {
const schemaEnumType: GraphQLEnumType | undefined = schema
? (schema.getType(typeName) as GraphQLEnumType)
: undefined;
const schemaType = schema?.getType(typeName);
const schemaEnumType = isEnumType(schemaType) ? schemaType : undefined;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: This uses an isEnumType guard instead of the as GraphQLEnumType cast, so the narrowing is checked rather than asserted. If a schema type with this name isn't an enum, it's treated as having no schema enum instead of crashing on .getValue. That's also why isEnumType is now a value import, with inline type specifiers for the rest.


Generated by Claude Code


return values
.map(enumOption => {
Expand All @@ -212,10 +211,16 @@ export const buildEnumValuesBlock = ({
}),
);
const comment = getNodeComment(enumOption);
const schemaEnumValue =
schemaEnumType && !ignoreEnumValuesFromSchema
? schemaEnumType.getValue(enumOption.name.value).value
: undefined;
let schemaEnumValue;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: The ternary became a let and an if, because throwing on a missing enum value needs a statement. The let has no annotation: TypeScript infers its type from the assignment below.


Generated by Claude Code

if (schemaEnumType && !ignoreEnumValuesFromSchema) {
const schemaEnumValueDefinition = schemaEnumType.getValue(enumOption.name.value);
if (!schemaEnumValueDefinition) {
throw new Error(
`Enum value "${enumOption.name.value}" is not defined on enum "${typeName}" in the schema`,
);
}
schemaEnumValue = schemaEnumValueDefinition.value;
}
let enumValue: string | number =
typeof schemaEnumValue === 'undefined' ? enumOption.name.value : schemaEnumValue;

Expand Down
18 changes: 12 additions & 6 deletions packages/plugins/other/visitor-plugin-common/src/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function generateFragmentImportStatement(
const { importSource: fragmentImportSource, ...rest } = statement;
const { identifiers, path, namespace } = fragmentImportSource;
const importSource: ImportSource<string> = {
identifiers: identifiers
identifiers: (identifiers || [])
.filter(fragmentImport => kind === 'both' || kind === fragmentImport.kind)
.map(({ name }) => name),
path,
Expand Down Expand Up @@ -126,9 +126,9 @@ export function getEnumsImports({
useTypeImports,
}: {
typeIdentifierConverted: string;
importIdentifier: string | null;
sourceIdentifier: string | null;
sourceFile: string | null;
importIdentifier?: string | null;
sourceIdentifier?: string | null;
sourceFile?: string | null;
Comment on lines +129 to +131

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: These are the params of the nested handleEnumValueMapper, not of the exported getEnumsImports, whose signature is unchanged. They're optional to match the ParsedEnumValuesMap fields that get passed straight in.


Generated by Claude Code

useTypeImports: boolean;
}): string[] {
if (importIdentifier !== sourceIdentifier) {
Expand Down Expand Up @@ -196,11 +196,17 @@ export function buildTypeImport({
useTypeImports,
asDefault = false,
}: {
identifier: string;
source: string;
identifier: string | null | undefined;
source: string | null | undefined;
useTypeImports: boolean;
asDefault?: boolean;
}): string {
// `== null` (not a falsy check) so only a missing identifier or source throws; an empty string is still allowed
if (identifier == null || source == null) {
Comment thread
eddeee888 marked this conversation as resolved.
throw new Error(
`Cannot build a type import without an identifier and a source (identifier: ${identifier}, source: ${source})`,
);
}
if (asDefault) {
if (useTypeImports) {
return `import type { default as ${identifier} } from '${source}';`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function isExternalMapper(value: string): boolean {
}

export function transformMappers(
rawMappers: RawResolversConfig['mappers'],
rawMappers: RawResolversConfig['mappers'] = {},

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: I gave the param a default instead of adding || {} inside the function, because mappers is optional in the raw config. Callers that pass undefined get an empty map, and the body stays as it was.


Generated by Claude Code

mapperTypeSuffix?: string,
): ParsedResolversConfig['mappers'] {
const result: ParsedResolversConfig['mappers'] = {};
Expand Down
15 changes: 8 additions & 7 deletions packages/plugins/other/visitor-plugin-common/src/naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function getKind(node: ASTNode | string): keyof NamingConventionMap {
return 'typeNames';
}

function getName(node: ASTNode | string): string | undefined {
function getName(node: ASTNode | string | undefined): string | undefined {
if (node == null) {
return undefined;
}
Expand Down Expand Up @@ -100,10 +100,11 @@ export function convertFactory(config: { namingConvention?: NamingConvention }):
}

if (typeof config.namingConvention === 'function') {
const namingConvention = config.namingConvention;
return (str: string, opts: ConvertOptions = {}) => {
return convertNameParts(
str,
config.namingConvention as (str: string) => string,
namingConvention,
getConfigValue(opts?.transformUnderscore, false),
);
};
Expand All @@ -114,25 +115,25 @@ export function convertFactory(config: { namingConvention?: NamingConvention }):
}

if (typeof config.namingConvention === 'object') {
if (!config.namingConvention[type]) {
const namingConvention = config.namingConvention;
if (!namingConvention[type]) {
return (str: string, opts: ConvertOptions = {}) => {
const transformUnderscore =
(config.namingConvention as NamingConventionMap).transformUnderscore ||
opts?.transformUnderscore;
namingConvention.transformUnderscore || opts?.transformUnderscore;
return convertNameParts(str, pascalCase, getConfigValue(transformUnderscore, false));
};
}

return (str: string, opts: ConvertOptions = {}) => {
return convertNameParts(
str,
resolveExternalModuleAndFn(config.namingConvention[type]),
resolveExternalModuleAndFn(namingConvention[type]),
getConfigValue(opts?.transformUnderscore, true),
);
};
}

return config.namingConvention[type] as any;
return config.namingConvention[type];
}

return (node, opts) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ export function optimizeOperations(
): Types.DocumentFile[] {
const newDocuments = optimizeDocuments(
schema,
documents.map(s => s.document),
documents.map(s => {
if (!s.document) {
throw new Error(
`Cannot optimize the document at "${s.location}": it has no parsed document`,
);
}
return s.document;
}),
options,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class BaseSelectionSetProcessor<Config extends SelectionSetProcessorConfi
return `{ ${allObjectsMerged.join(', ')} }`;
}

buildSelectionSetFromStrings(pieces: string[]): string {
buildSelectionSetFromStrings(pieces: string[]): string | null {
if (pieces.length === 0) {
return null;
}
Expand Down
8 changes: 4 additions & 4 deletions packages/plugins/other/visitor-plugin-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,17 @@ export type EnumValuesMap<AdditionalProps = {}> =
export type ParsedEnumValuesMap = {
[enumName: string]: {
// If values are explictly set, this will include the mapped values
mappedValues?: { [valueName: string]: string | number };
mappedValues?: { [valueName: string]: string | number } | null;
// The GraphQL enum name
typeIdentifier: string;
// The GraphQL enum name after namingConvention conversion
typeIdentifierConverted: string;
// The actual identifier that you should use in the code (original or aliased)
sourceIdentifier?: string;
sourceIdentifier?: string | null;
// In case of external enum, this will contain the source file path
sourceFile?: string;
sourceFile?: string | null;
// If the identifier is external (imported) - this will contain the imported expression (including alias), otherwise null
importIdentifier?: string;
importIdentifier?: string | null;
// Is defualt import is used to import the enum
isDefault?: boolean;
};
Expand Down
40 changes: 22 additions & 18 deletions packages/plugins/other/visitor-plugin-common/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function quoteIfNeeded(array: string[], joinWith = ' & '): string {
return `(${array.join(joinWith)})`;
}

export function block(array) {
export function block(array: string[] | null | undefined): string {
return array && array.length !== 0 ? '{\n' + array.join('\n') + '\n}' : '';
}

Expand Down Expand Up @@ -107,7 +107,7 @@ export interface DeclarationBlockConfig {
}

export function transformComment(
comment: string | StringValueNode,
comment: string | StringValueNode | undefined | null,
indentLevel = 0,
disabled = false,
): string {
Expand All @@ -129,14 +129,14 @@ export function transformComment(
}

export class DeclarationBlock {
_decorator = null;
_decorator: string | null = null;
_export = false;
_name = null;
_kind = null;
_methodName = null;
_content = null;
_block = null;
_nameGenerics = null;
_name: string | NameNode | null = null;
_kind: string | null = null;
_methodName: string | null = null;
_content: string | null = null;
_block: string | null = null;
_nameGenerics: string | null = null;
_comment: string | null = null;
_ignoreBlockWrapper = false;

Expand Down Expand Up @@ -189,7 +189,7 @@ export class DeclarationBlock {
return this;
}

withBlock(block: string): DeclarationBlock {
withBlock(block: string | null): DeclarationBlock {
this._block = block;

return this;
Expand All @@ -210,6 +210,7 @@ export class DeclarationBlock {

public get string(): string {
let result = '';
const blockTransformer = this._config.blockTransformer || (block => block);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: The fallback is in the getter rather than the constructor's defaults. The constructor spreads the caller's config over the defaults, so an explicit blockTransformer: undefined still gets through. A single local here covers all three call sites below.


Generated by Claude Code


if (this._decorator) {
result += this._decorator + '\n';
Expand Down Expand Up @@ -245,14 +246,14 @@ export class DeclarationBlock {
const block = [before, this._block, after].filter(val => !!val).join('\n');

if (this._methodName) {
result += `${this._methodName}(${this._config.blockTransformer(block)})`;
result += `${this._methodName}(${blockTransformer(block)})`;
} else {
result += this._config.blockTransformer(block);
result += blockTransformer(block);
}
} else if (this._content) {
result += this._content;
} else if (this._kind) {
result += this._config.blockTransformer('{}');
result += blockTransformer('{}');
}

return stripTrailingSpaces(
Expand Down Expand Up @@ -293,7 +294,7 @@ export function buildScalarsFromConfig(

export function buildScalars(
schema: GraphQLSchema | undefined,
scalarsMapping: ScalarsMap,
scalarsMapping: ScalarsMap | undefined,
defaultScalarsMapping: NormalizedScalarsMap = DEFAULT_SCALARS,
defaultScalarType: string | null = 'unknown',
): ParsedScalarsMap {
Expand Down Expand Up @@ -641,8 +642,11 @@ export function isOneOfInputObjectType(

isOneOfType =
isInputObjectType(namedType) &&
((namedType as unknown as Record<'isOneOf', boolean | undefined>).isOneOf ||
namedType.astNode?.directives?.some(d => d.name.value === 'oneOf'));
// `isOneOf` only exists on GraphQLInputObjectType from graphql 16.9; removing this cast needs
// dropping support for older graphql versions
(((namedType as unknown as Record<'isOneOf', boolean | undefined>).isOneOf ||
namedType.astNode?.directives?.some(d => d.name.value === 'oneOf')) ??
false);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: The ?? false is needed because astNode?.directives?.some(...) gives undefined when there are no directives, and the cache is a Map<…, boolean>. Callers only check truthiness, so caching false doesn't change what they get.


Generated by Claude Code


isOneOfTypeCache.set(namedType, isOneOfType);

Expand All @@ -666,7 +670,7 @@ export function flatten<T>(array: Array<Array<T>>): Array<T> {

export function unique<T>(
array: Array<T>,
key: (item: T) => string | number = item => item.toString(),
key: (item: T) => string | number = item => String(item),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: T is unconstrained, so under strict TypeScript doesn't know item.toString() exists. String(item) gives the same key for the strings and numbers this is used with.


Generated by Claude Code

): Array<T> {
return Object.values(array.reduce((acc, item) => ({ [key(item)]: item, ...acc }), {}));
}
Expand Down Expand Up @@ -752,7 +756,7 @@ export const getNodeComment = (
const getDeprecationReason = (directive: DirectiveNode): string | void => {
if (directive.name.value === 'deprecated') {
let reason = 'Field no longer supported';
const deprecatedReason = directive.arguments[0];
const deprecatedReason = directive.arguments?.[0];
if (deprecatedReason && deprecatedReason.value.kind === Kind.STRING) {
reason = deprecatedReason.value.value;
}
Expand Down
Loading
Loading