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:
- Directive types that wrap each
ValidationAttribute - A
TypeInterceptorthat reads attributes off C# input types and attaches the corresponding directives at schema build time - A field middleware that runs
System.ComponentModel.DataAnnotations.Validatorat 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 replacenext(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.errorMessage → defined mapping tells the plugin to call .defined(errorMessage) when it sees a @required directive. Similarly emailAddress.errorMessage → email 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.DataAnnotationsattributes 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.