This approach is based on Nigel Sampson’s post on GraphQL validation metadata, extended with a recursive validation middleware and a codegen pipeline for the frontend.

Versions: HotChocolate v16.2.0 is the current stable release. The TypeInterceptor and DirectiveType APIs shown here have been stable since v12 and are unchanged through v16. v15+ dropped .NET 6/7 support - .NET 8 or later is required. The middleware syntax uses C# 12 primary constructors; see the note in that section if you are on an older SDK.

The classic validation split: backend has [Required], [EmailAddress], [StringLength(100, MinimumLength = 6)]. Frontend has a Formik form with a hand-written Yup schema. The two diverge within weeks. Someone loosens a constraint on one side and forgets the other. A user submits a form that passes client validation and gets a 400 back from the API.

The fix is to make the backend the single source of truth and generate the frontend validators from it automatically. HotChocolate makes this straightforward via schema directives and the typescript-validation-schema codegen plugin.


The pieces

The approach has three layers:

  1. Directive types that wrap each ValidationAttribute
  2. A TypeInterceptor that reads attributes off C# input types and attaches the corresponding directives at schema build time
  3. A field middleware that runs System.ComponentModel.DataAnnotations.Validator at runtime

The frontend then runs graphql-codegen with the typescript-validation-schema plugin, which reads the directives out of the introspected schema and emits a Yup schema for every input type.


Server: directive types

Each ValidationAttribute gets a thin DirectiveType<T> wrapper. The important parts are the name (which the codegen config matches against) and which properties to expose or ignore.

// RequiredDirective.cs
public class RequiredDirective : DirectiveType<RequiredAttribute>
{
    protected override DirectiveTypeDefinition CreateDefinition(ITypeDiscoveryContext context)
    {
        var definition = base.CreateDefinition(context);
        definition.CreateInstance = _ => throw new NotImplementedException();
        return definition;
    }

    protected override void Configure(IDirectiveTypeDescriptor<RequiredAttribute> descriptor)
    {
        descriptor.Name("required");
        descriptor.Location(
            DirectiveLocation.InputFieldDefinition |
            DirectiveLocation.ArgumentDefinition |
            DirectiveLocation.FieldDefinition);
        descriptor.Ignore(dt => dt.RequiresValidationContext);
        descriptor.Ignore(dt => dt.ErrorMessageResourceName);
    }
}
// EmailAddressDirective.cs
public class EmailAddressDirective : DirectiveType<EmailAddressAttribute>
{
    protected override DirectiveTypeDefinition CreateDefinition(ITypeDiscoveryContext context)
    {
        var definition = base.CreateDefinition(context);
        definition.CreateInstance = _ => throw new NotImplementedException();
        return definition;
    }

    protected override void Configure(IDirectiveTypeDescriptor<EmailAddressAttribute> descriptor)
    {
        descriptor.Name("emailAddress");
        descriptor.Location(
            DirectiveLocation.InputFieldDefinition |
            DirectiveLocation.ArgumentDefinition |
            DirectiveLocation.FieldDefinition);
        descriptor.Ignore(dt => dt.RequiresValidationContext);
        descriptor.Ignore(dt => dt.ErrorMessageResourceName);
        descriptor.Ignore(dt => dt.CustomDataType);
    }
}

The CreateInstance override prevents HotChocolate from trying to instantiate the attribute directly - the directive is metadata only.


Server: the TypeInterceptor

ValidationTypeInterceptor fires during schema initialization. It scans every field of every InputObjectTypeDefinition for ValidationAttributes, formats the error message using the field’s display name, and attaches the directive.

public class ValidationTypeInterceptor : TypeInterceptor
{
    public override void OnBeforeRegisterDependencies(
        ITypeDiscoveryContext discoveryContext,
        DefinitionBase definition)
    {
        if (definition is InputObjectTypeDefinition inputObjectTypeDefinition)
        {
            foreach (var field in inputObjectTypeDefinition.Fields)
            {
                if (field.Property!.GetCustomAttributes(typeof(ValidationAttribute), true) is
                    ValidationAttribute[] { Length: > 0 } attributes)
                {
                    foreach (var attribute in attributes)
                    {
                        attribute.ErrorMessage = attribute.FormatErrorMessage(
                            field.Property.GetCustomAttribute<DisplayNameAttribute>(false)?.DisplayName ??
                            field.Property.Name);
                        field.AddDirective(attribute, discoveryContext.TypeInspector);
                    }
                }
            }
        }

        if (definition is ObjectTypeDefinition objectTypeDefinition)
        {
            foreach (var field in objectTypeDefinition.Fields)
            {
                if (field.Member?.GetCustomAttributes(typeof(ValidationAttribute), true) is
                    ValidationAttribute[] { Length: > 0 } attributes)
                {
                    foreach (var attribute in attributes)
                    {
                        attribute.ErrorMessage = attribute.FormatErrorMessage(
                            field.Member.GetCustomAttribute<DisplayNameAttribute>(false)?.DisplayName ??
                            field.Member.Name);
                        field.AddDirective(attribute, discoveryContext.TypeInspector);
                    }
                }

                foreach (var argument in field.Arguments)
                {
                    if (argument.Parameter?.GetCustomAttributes(typeof(ValidationAttribute), true) is
                        ValidationAttribute[] { Length: > 0 } attributes2)
                    {
                        foreach (var attribute in attributes2)
                        {
                            attribute.ErrorMessage = attribute.FormatErrorMessage(
                                argument.Parameter.GetCustomAttribute<DisplayNameAttribute>(false)?.DisplayName ??
                                argument.Parameter.Name ?? "");
                            field.AddDirective(attribute, discoveryContext.TypeInspector);
                        }
                    }
                }
            }
        }
    }
}

FormatErrorMessage substitutes {0} in the error message string with the field name (or display name if [DisplayName] is set). This means the error message that ends up in the directive - and later in the generated Yup schema - is already human-readable.


Server: validation middleware

The middleware runs Validator.TryValidateObject on every argument before the resolver executes. It recurses into nested objects and lists.

The public class ValidateArgumentsMiddleware(FieldDelegate next) syntax is a C# 12 primary constructor (.NET 8+). On older SDKs, replace it with a field and a conventional constructor: private readonly FieldDelegate _next; public ValidateArgumentsMiddleware(FieldDelegate next) { _next = next; } and replace next(context) with _next(context).

public class ValidateArgumentsMiddleware(FieldDelegate next)
{
    public async Task Invoke(IMiddlewareContext context)
    {
        if (context.Selection.SyntaxNode.Arguments.Count == 0)
        {
            await next(context);
            return;
        }

        var errors = context.Selection.SyntaxNode.Arguments
            .Select(a => context.ArgumentValue<object>(a.Name.Value))
            .SelectMany(ValidateObject).ToList();

        if (errors.Any())
        {
            foreach (var error in errors)
            {
                context.ReportError(ErrorBuilder.New()
                    .SetCode("error.validation")
                    .SetMessage(error.ErrorMessage ?? "")
                    .SetExtension("memberName", error.MemberNames.First())
                    .AddLocation(context.Selection.SyntaxNode.Location?.Line ?? 0,
                                 context.Selection.SyntaxNode.Location?.Column ?? 0)
                    .SetPath(context.Path)
                    .Build());
            }
            context.Result = null;
        }
        else
        {
            await next(context);
        }

        static IEnumerable<ValidationResult> ValidateObject(object argument)
        {
            var results = new List<ValidationResult>();
            RecursiveValidator.TryValidateObject(argument, results, validateAllProperties: true, "");
            return results;
        }
    }
}

The RecursiveValidator walks the object graph, validating nested types and collections. Without this, a required field inside a nested input type would pass the top-level check.


Server: wiring it up

services
    .AddGraphQLServer()
    .TryAddTypeInterceptor<ValidationTypeInterceptor>()
    .UseField<ValidateArgumentsMiddleware>()
    .AddType<RequiredDirective>()
    .AddType<MaxLengthDirective>()
    .AddType<EmailAddressDirective>()
    .AddType<StringLenghtDirective>()
    .AddType<DataTypeDirective>()
    .AddType<RegularExpressionDirective>()
    .AddType<RangeDirective>();

The directive types must be explicitly registered - HotChocolate won’t discover them automatically.


Input type

With the above in place, a plain C# input class is all you need:

public class RegisterInput(string username, string password, string email, string lastName, string firstName)
{
    [Required(ErrorMessage = "Username is required")]
    [RegularExpression("^[a-zA-Z0-9]*$", ErrorMessage = "Username must contains only alphanumeric characters")]
    public string Username { get; set; } = username;

    [Required(ErrorMessage = "Password is required")]
    [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)]
    public string Password { get; set; } = password;

    [Required(ErrorMessage = "Email Address is required")]
    [EmailAddress]
    public string Email { get; set; } = email;

    [Required(ErrorMessage = "Last Name is required")]
    public string LastName { get; set; } = lastName;

    [Required(ErrorMessage = "First Name is required")]
    public string FirstName { get; set; } = firstName;
}

The interceptor will pick up every attribute and attach the corresponding directive to the generated schema field.


Client: codegen config

The typescript-validation-schema plugin reads directives out of the introspected schema and emits Yup validators. The directives config maps directive names to Yup method calls:

// codegen.ts
const config: CodegenConfig = {
    schema: "path/to/schema.graphql",
    generates: {
        './src/graphql/validation.ts': {
            plugins: [
                { add: { content: '// @ts-nocheck' } },
                "typescript-validation-schema",
            ],
            config: {
                importFrom: './graphql',
                strictScalars: true,
                schema: 'yup',
                scalars: {
                    LocalDate: "string",
                    Long: "number",
                    Decimal: "number",
                },
                scalarSchemas: {
                    LocalDate: "yup.string()",
                    Long: "yup.number()",
                    Decimal: "yup.number()"
                },
                directives: {
                    required: {
                        errorMessage: "defined"
                    },
                    stringLength: {
                        maximumLength: "max",
                        minimumLength: "min"
                    },
                    regularExpression: {
                        pattern: ["matches", "/$1/"]
                    },
                    emailAddress: {
                        errorMessage: "email"
                    }
                }
            }
        }
    }
};

The required.errorMessagedefined mapping tells the plugin to call .defined(errorMessage) when it sees a @required directive. Similarly emailAddress.errorMessageemail produces .email(errorMessage).


Client: generated output

Running codegen produces validation.ts with a typed schema function for every input type:

export function RegisterInputSchema(): yup.ObjectSchema<RegisterInput> {
    return yup.object({
        username: yup.string().defined().nonNullable()
            .defined("Username is required")
            .matches(/^[a-zA-Z0-9]*$/),
        password: yup.string().defined().nonNullable()
            .defined("Password is required")
            .max(100)
            .min(6),
        email: yup.string().defined().nonNullable()
            .defined("Email Address is required")
            .email("The Email field is not a valid e-mail address."),
        lastName: yup.string().defined().nonNullable()
            .defined("Last Name is required"),
        firstName: yup.string().defined().nonNullable()
            .defined("First Name is required"),
    });
}

The error messages come directly from the C# attributes, formatted by FormatErrorMessage. Change the attribute on the backend, regenerate, and the Yup schema updates automatically.


Client: using the schema

Drop it straight into Formik’s validationSchema. You can extend it with frontend-only rules (like a password-confirm field) without touching the generated file:

<Formik
    initialValues={{ email: '', username: '', password: '', password2: '', ... }}
    validationSchema={RegisterInputSchema().shape({
        policy: Yup.boolean().oneOf([true], 'This field must be checked'),
        password2: Yup.string().test(
            'passwords-match',
            'Passwords must match',
            function (value) { return this.parent.password === value; }
        ),
        recaptchaResponse: undefined
    })}
    onSubmit={async (values) => {
        await signUpMutation({ variables: { registerInput: values } });
    }}
>
    {({ errors, touched, handleBlur, handleChange, values }) => (
        <form>
            <TextField
                error={Boolean(touched.username && errors.username)}
                helperText={touched.username && errors.username}
                label="Username"
                name="username"
                onBlur={handleBlur}
                onChange={handleChange}
                value={values.username}
            />
            {/* ... */}
        </form>
    )}
</Formik>

The .shape() call merges the generated schema with the additional rules - the base schema is not mutated.


What this gets you

  • Validation rules defined once, in C#, using the standard System.ComponentModel.DataAnnotations attributes most .NET teams already use
  • The schema carries the rules as machine-readable metadata - any client with access to introspection can consume them
  • Server-side enforcement via the middleware, so the directives are not just decoration
  • Frontend Yup schemas generated automatically - no manual sync, no drift

The only manual step is re-running codegen after the backend schema changes. That belongs in your CI pipeline as a schema generation step before the TypeScript build.