Skip to content

Invalid constraints for non-nullable array #489

Closed

Description

@rwysocki-equinix

When array elements are not nullable, the array validation constraints are applied not to array, but each element
I've modified an existing zod testcase which now fails to visualize this problem:

  it('list field non-null', async () => {
    const schema = buildSchema(/* GraphQL */ `
      input UserCreateInput {
        profile: [String!]! @constraint(minLength: 1, maxLength: 5000)
      }

      directive @constraint(minLength: Int!, maxLength: Int!) on INPUT_FIELD_DEFINITION
    `);
    const result = await plugin(
        schema,
        [],
        {
          schema: 'zod',
          directives: {
            constraint: {
              minLength: ['min', '$1', 'Please input more than $1'],
              maxLength: ['max', '$1', 'Please input less than $1'],
            },
          },
        },
        {}
    );
    const wantContains = [
      'export function UserCreateInputSchema(): z.ZodObject<Properties<UserCreateInput>>',
      'profile: z.array(z.string()).min(1, "Please input more than 1").max(5000, "Please input less than 5000")',
    ];
    for (const wantContain of wantContains) {
      expect(result.content).toContain(wantContain);
    }
  });

Activity

  1. Code-Hex commented on Dec 9, 2023

    @Code-Hex
    Owner

    @rwysocki-equinix Could you send me PR for this fix?

  2. ashar-nadeem-lumi commented on Feb 8, 2024

    @ashar-nadeem-lumi

    Bumping this, is there any workaround? Or a way to enforce a min length on the zod array ?

  3. llwire commented on Nov 15, 2024

    @llwire

    Bumping this again.

    There is actually no scenario that produces a near-desired outcome.

    [String!]! @constraint(min: 5) produces z.array(z.string().min(5))

    [String]! @constraint(min: 5) produces z.array(z.string().nullable())

    [String!] @constraint(min: 5) produces z.array(z.string().min(1).max(5)).min(1).max(5).nullish()

    [String] @constraint(min: 5) produces z.array(z.string().nullable()).min(1).max(5).nullish()

    The last one might be the closest but making the array entries nullable isn't great when you generate a client-side schema.

  4. Code-Hex commented on Sep 23, 2025

    @Code-Hex
    Owner

    Could someone please provide the schema definition and the desired output?

  5. hiro1989 commented on Oct 21, 2025

    @hiro1989

    @Code-Hex
    Thank you for providing such an excellent library — it's been incredibly helpful in my projects.

    I've encountered the same issue and created a minimal reproducible example to demonstrate the problem. I hope this will help you investigate it further.

    Versions

    All are the current latest version at 2025-10-21

    • @graphql-codegen/cli: 6.0.0
    • @graphql-codegen/typescript: 5.0.2
    • graphql-codegen-typescript-validation-schema: 0.18.1
    • typescript: 5.9.3
    • zod: 4.1.1

    Summary of Actual vs. Expected Behavior

    In short, Array-level @constraint(minItems, maxItems) is incorrectly applied to element schemas instead of the array schema in generated zod code.

    Case 1. [String!]! @constraint(minItems: *, maxItems: *)

    GraphQL schema:

    nicknames: [String!]! @constraint(minItems: 20, maxItems: 21)

    Generated code:

    // Actual
    nicknames: z.array(z.string().min(20).max(21)),
    
    // Expected
    nicknames: z.array(z.string()).min(20).max(21),

    Case 2. [String!] @constraint(minItems: *, maxItems: *)

    GraphQL schema:

    nicknamesNullable: [String!] @constraint(minItems: 40, maxItems: 41)

    Generated code:

    // Actual
    nicknamesNullable: z.array(z.string().min(40).max(41)).min(40).max(41).nullish(),
    
    // Expected
    nicknamesNullable: z.array(z.string()).min(40).max(41).nullish(),

    Case 3. [String]! @constraint(minItems: *, maxItems: *)

    GraphQL schema:

    nicknamesPartialNullable: [String]! @constraint(minItems: 50, maxItems: 51)

    Generated code:

    // Actual
    nicknamesPartialNullable: z.array(z.string().nullable()),
    
    // Expected
    nicknamesPartialNullable: z.array(z.string().nullable()).min(50).max(51),

    Case 4. [SkillInput!]! @constraint(minItems: *, maxItems: *)

    GraphQL schema:

    skills: [SkillInput!]! @constraint(minItems: 60, maxItems: 61)

    Generated code:

    // Actual
    skills: z.array(z.lazy(() => SkillInputSchema().min(60).max(61))),
    
    // Expected
    skills: z.array(z.lazy(() => SkillInputSchema())).min(60).max(61),

    Case 5. [SkillInput!] @constraint(minItems: *, maxItems: *)

    GraphQL schema:

    skillsNullable: [SkillInput!] @constraint(minItems: 80, maxItems: 81)

    Generated code:

    // Actual
    skillsNullable: z.array(z.lazy(() => SkillInputSchema().min(80).max(81))).min(80).max(81).nullish(),
    
    // Expected
    skillsNullable: z.array(z.lazy(() => SkillInputSchema())).min(80).max(81).nullish(),

    Case 6. [SkillInput]! @constraint(minItems: *, maxItems: *)

    GraphQL schema:

    skillsPartialNullable: [SkillInput]! @constraint(minItems: 90, maxItems: 91)

    Generated code:

    // Actual
    skillsPartialNullable: z.array(z.lazy(() => SkillInputSchema().nullable()))
    
    // Expected
    skillsPartialNullable: z.array(z.lazy(() => SkillInputSchema().nullable())).min(90).max(91),

    Thank you in advance for your time and support. I really appreciate all the work you're doing on this project!

  6. vinverdy commented on Nov 26, 2025

    @vinverdy
    diff --git a/node_modules/graphql-codegen-typescript-validation-schema/dist/cjs/zodv4/index.js b/node_modules/graphql-codegen-typescript-validation-schema/dist/cjs/zodv4/index.js
    index 97519d5..a78c421 100644
    --- a/node_modules/graphql-codegen-typescript-validation-schema/dist/cjs/zodv4/index.js
    +++ b/node_modules/graphql-codegen-typescript-validation-schema/dist/cjs/zodv4/index.js
    @@ -68,7 +68,7 @@ class ZodV4SchemaVisitor extends schema_visitor_js_1.BaseSchemaVisitor {
                             return (new visitor_plugin_common_1.DeclarationBlock({})
                                 .export()
                                 .asKind('const')
    -                            .withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema`)
                                 .withContent([`z.object({`, shape, '})'].join('\n'))
                                 .string + appendArguments);
                         case 'function':
    @@ -76,7 +76,7 @@ class ZodV4SchemaVisitor extends schema_visitor_js_1.BaseSchemaVisitor {
                             return (new visitor_plugin_common_1.DeclarationBlock({})
                                 .export()
                                 .asKind('function')
    -                            .withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema()`)
                                 .withBlock([(0, visitor_plugin_common_1.indent)(`return z.object({`), shape, (0, visitor_plugin_common_1.indent)('})')].join('\n'))
                                 .string + appendArguments);
                     }
    @@ -100,7 +100,7 @@ class ZodV4SchemaVisitor extends schema_visitor_js_1.BaseSchemaVisitor {
                             return (new visitor_plugin_common_1.DeclarationBlock({})
                                 .export()
                                 .asKind('const')
    -                            .withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema`)
                                 .withContent([
                                 `z.object({`,
                                 (0, visitor_plugin_common_1.indent)(`__typename: z.literal('${node.name.value}').optional(),`, 2),
    @@ -113,7 +113,7 @@ class ZodV4SchemaVisitor extends schema_visitor_js_1.BaseSchemaVisitor {
                             return (new visitor_plugin_common_1.DeclarationBlock({})
                                 .export()
                                 .asKind('function')
    -                            .withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema()`)
                                 .withBlock([
                                 (0, visitor_plugin_common_1.indent)(`return z.object({`),
                                 (0, visitor_plugin_common_1.indent)(`__typename: z.literal('${node.name.value}').optional(),`, 2),
    @@ -189,7 +189,7 @@ class ZodV4SchemaVisitor extends schema_visitor_js_1.BaseSchemaVisitor {
                     return new visitor_plugin_common_1.DeclarationBlock({})
                         .export()
                         .asKind('const')
    -                    .withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
    +                    .withName(`${name}Schema`)
                         .withContent(['z.object({', shape, '})'].join('\n'))
                         .string;
                 case 'function':
    @@ -197,7 +197,7 @@ class ZodV4SchemaVisitor extends schema_visitor_js_1.BaseSchemaVisitor {
                     return new visitor_plugin_common_1.DeclarationBlock({})
                         .export()
                         .asKind('function')
    -                    .withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
    +                    .withName(`${name}Schema()`)
                         .withBlock([(0, visitor_plugin_common_1.indent)(`return z.object({`), shape, (0, visitor_plugin_common_1.indent)('})')].join('\n'))
                         .string;
             }
    @@ -211,12 +211,13 @@ function generateFieldZodSchema(config, visitor, field, indentCount) {
     function generateFieldTypeZodSchema(config, visitor, field, type, parentType) {
         if ((0, graphql_js_1.isListType)(type)) {
             const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type);
    +        const item = maybeLazy(visitor, type.type, gen);
    +        const maybeLazyGen = applyDirectives(config, field, `z.array(${item})`);
    +        
             if (!(0, graphql_js_1.isNonNullType)(parentType)) {
    -            const arrayGen = `z.array(${maybeLazy(visitor, type.type, gen)})`;
    -            const maybeLazyGen = applyDirectives(config, field, arrayGen);
                 return `${maybeLazyGen}.nullish()`;
             }
    -        return `z.array(${maybeLazy(visitor, type.type, gen)})`;
    +        return maybeLazyGen;
         }
         if ((0, graphql_js_1.isNonNullType)(type)) {
             const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type);
    @@ -257,7 +258,25 @@ function generateFieldTypeZodSchema(config, visitor, field, type, parentType) {
     }
     function applyDirectives(config, field, gen) {
         if (config.directives && field.directives) {
    -        const formatted = (0, directive_js_1.formatDirectiveConfig)(config.directives);
    +        const {constraint, ...rest} = config.directives ?? {};
    +        const {minItems, maxItems, ...restConstraint} = constraint ?? {};
    +        
    +        const newConfig = {
    +            ...rest,
    +            constraint: restConstraint
    +        }
    +        
    +        if(gen.startsWith('z.array(')) {
    +            newConfig.constraint = {};
    +            if(minItems) { 
    +                newConfig.constraint.minItems = minItems;
    +            }
    +            if(maxItems) {
    +                newConfig.constraint.maxItems = maxItems;
    +            }
    +        }
    +        
    +        const formatted = (0, directive_js_1.formatDirectiveConfig)(newConfig);
             return gen + (0, directive_js_1.buildApi)(formatted, field.directives);
         }
         return gen;
    diff --git a/node_modules/graphql-codegen-typescript-validation-schema/dist/esm/zodv4/index.js b/node_modules/graphql-codegen-typescript-validation-schema/dist/esm/zodv4/index.js
    index 0faadd6..9249af9 100644
    --- a/node_modules/graphql-codegen-typescript-validation-schema/dist/esm/zodv4/index.js
    +++ b/node_modules/graphql-codegen-typescript-validation-schema/dist/esm/zodv4/index.js
    @@ -65,7 +65,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
                             return (new DeclarationBlock({})
                                 .export()
                                 .asKind('const')
    -                            .withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema`)
                                 .withContent([`z.object({`, shape, '})'].join('\n'))
                                 .string + appendArguments);
                         case 'function':
    @@ -73,7 +73,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
                             return (new DeclarationBlock({})
                                 .export()
                                 .asKind('function')
    -                            .withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema()`)
                                 .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
                                 .string + appendArguments);
                     }
    @@ -97,7 +97,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
                             return (new DeclarationBlock({})
                                 .export()
                                 .asKind('const')
    -                            .withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema`)
                                 .withContent([
                                 `z.object({`,
                                 indent(`__typename: z.literal('${node.name.value}').optional(),`, 2),
    @@ -110,7 +110,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
                             return (new DeclarationBlock({})
                                 .export()
                                 .asKind('function')
    -                            .withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
    +                            .withName(`${name}Schema()`)
                                 .withBlock([
                                 indent(`return z.object({`),
                                 indent(`__typename: z.literal('${node.name.value}').optional(),`, 2),
    @@ -186,7 +186,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
                     return new DeclarationBlock({})
                         .export()
                         .asKind('const')
    -                    .withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
    +                    .withName(`${name}Schema`)
                         .withContent(['z.object({', shape, '})'].join('\n'))
                         .string;
                 case 'function':
    @@ -194,7 +194,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
                     return new DeclarationBlock({})
                         .export()
                         .asKind('function')
    -                    .withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
    +                    .withName(`${name}Schema()`)
                         .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
                         .string;
             }
    
    
    input SkillInput {
      name: String!
      level: Int
    }
    
    input TestCase {
      # Case 1: [String!]!
      nicknames: [String!]! @constraint(minItems: 20, maxItems: 21)
    
      # Case 2: [String!]
      nicknamesNullable: [String!] @constraint(minItems: 40, maxItems: 41)
    
      # Case 3: [String]!
      nicknamesPartialNullable: [String]! @constraint(minItems: 50, maxItems: 51)
    
      # Case 4: [SkillInput!]!
      skills: [SkillInput!]! @constraint(minItems: 60, maxItems: 61)
    
      # Case 5: [SkillInput!]
      skillsNullable: [SkillInput!] @constraint(minItems: 80, maxItems: 81)
    
      # Case 6: [SkillInput]!
      skillsPartialNullable: [SkillInput]! @constraint(minItems: 90, maxItems: 91)
    }
    export function SkillInputSchema() {
      return z.object({
        level: z.number().nullish(),
        name: z.string()
      })
    }
    export function TestCaseSchema() {
      return z.object({
        nicknames: z.array(z.string()).min(20).max(21),
        nicknamesNullable: z.array(z.string()).min(40).max(41).nullish(),
        nicknamesPartialNullable: z.array(z.string().nullable()).min(50).max(51),
        skills: z.array(z.lazy(() => SkillInputSchema())).min(60).max(61),
        skillsNullable: z.array(z.lazy(() => SkillInputSchema())).min(80).max(81).nullish(),
        skillsPartialNullable: z.array(z.lazy(() => SkillInputSchema().nullable())).min(90).max(91)
      })
    }

    i dont check thoroughly, its not ideal, but have a try with this patch, its zodv4

  7. Code-Hex commented on Apr 25, 2026

    @Code-Hex
    Owner

    Fixed in #1423. Zod directives are now applied to non-null array fields instead of being skipped because of the non-null wrapper.

  8. Kretiss commented on Sep 23, 2026

    @Kretiss

    The fix in #1423 covers the Zod generators; valibot still applies list field directives to the items. #1583 ports the same handling to valibot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions