Custom Rules
Custom Rules
Section titled “Custom Rules”A rule is a readonly record struct (or class) implementing ZodSharp.Core.IValidationRule<T>. Rules are evaluated after a schema’s type/structural check succeeds; each failing rule adds a ValidationError to the result.
Custom rules are first class:
- attach them to a schema with the public
AddRule/RuleAPI, or - surface them as a
System.ComponentModel.DataAnnotations-style attribute (for example[NoWhitespace]) that the[ZodSchema]source generator honours exactly like[EmailAddress]or[Range].
The rule contract
Section titled “The rule contract”namespace ZodSharp.Core;
public interface IValidationRule<T>{ bool IsValid(in T value); string GetErrorMessage(in T value);}Implementations should be structs so validation does not allocate. IsValid is called only when the surrounding schema succeeded and (for nullable properties) the value is not null.
For a string rule, also implement ZodSharp.Core.IStringValidationRule (bool IsValid(ReadOnlySpan<char> value) / string GetErrorMessage(ReadOnlySpan<char> value)) so the rule participates in ZodString.ValidateSpan/IsValidSpan without materialising the input. Rules that only implement IValidationRule<T> are still fully supported; they simply fall back to the string pipeline for span validation.
Defining a custom rule
Section titled “Defining a custom rule”using ZodSharp.Core;
namespace MyRules;
/// <summary>Rejects strings that contain whitespace.</summary>public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule<string>{ public bool IsValid(in string value) { if (value is null) return false;
foreach (var character in value) { if (char.IsWhiteSpace(character)) return false; }
return true; }
public string GetErrorMessage(in string value) => Message ?? $"Whitespace is not allowed in '{value}'.";}The rule can be used standalone:
var rule = new NoWhitespaceRule();if (!rule.IsValid("John Doe")) Console.WriteLine(rule.GetErrorMessage("John Doe"));Attaching a rule to a schema
Section titled “Attaching a rule to a schema”ZodType<TOutput, TInput>.AddRule(IValidationRule<TOutput>) and the generic Rule<TRule>(TRule) helper are public, so a custom rule can be composed directly:
using ZodSharp;using MyRules;
var schema = Z.String().Rule(new NoWhitespaceRule("No spaces allowed."));
var result = schema.Validate("John Doe");// result.IsSuccess == false// result.Errors[0].Code == "validation_failed"// result.Errors[0].Message == "No spaces allowed."// result.Errors[0].Path is emptyBoth methods mutate the receiver and return it for chaining; see Guarantees and Limitations.
Exposing a rule as a DataAnnotations attribute
Section titled “Exposing a rule as a DataAnnotations attribute”Built-in rules map to System.ComponentModel.DataAnnotations attributes (EmailRule ↔ [EmailAddress]). A custom rule gets the same treatment in two steps:
- Author a
ValidationAttributewhose properties mirror the rule’s constructor parameters. - Map it to the rule with
[ZodRule(typeof(...))].
using System;using System.ComponentModel.DataAnnotations;using ZodSharp.Core;using MyRules;
[ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")][AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]public sealed class NoWhitespaceAttribute : ValidationAttribute{ /// <summary>Overrides the rule's default error message.</summary> public string? Message { get; set; }}Apply it to a [ZodSchema] model like any other annotation:
using System.ComponentModel.DataAnnotations;using ZodSharp;
[ZodSchema]public class User{ [Required] [NoWhitespace(Message = "No spaces allowed.")] public string Name { get; set; } = string.Empty;}The generator emits rule-based validation, so UserSchema.Validate(user) fails for "John Doe" with:
Code = "invalid_string"Message = "No spaces allowed."Origin = "string"Path = ["Name"]Because the attribute derives from ValidationAttribute, the property participates in the same “carries a data annotation” discovery as the built-in attributes. The default error code is validation_failed when Code is not set.
Error identity: code and origin precedence
Section titled “Error identity: code and origin precedence”One attribute type can serve many members that each need a different error code. The generator resolves Code/Origin in this order (first match wins):
- Rule-owned — the rule implements
ZodSharp.Core.IZodRule, soIZodRule.Code/IZodRule.Originare used at runtime (the mapped values are only a fallback when the rule returnsnull). - Attribute-declared — a
Code/Originnamed argument on the applied attribute (for example[NoWhitespace(Code = "invalid_asset_id")]). - Attribute-type mapping —
[ZodRule(typeof(X), Code = "…", Origin = "…")]. - Default —
validation_failedwith no origin.
using ZodSharp.Core;
public readonly record struct NotEmptyRule<T>(string? Code = null, string? Message = null) : IValidationRule<T>, IZodRule where T : struct, IEquatable<T>{ public bool IsValid(in T value) => !value.Equals(default(T));
public string GetErrorMessage(in T value) => Message ?? "Value must not be empty.";
// The rule owns its identity, so callers can pass a per-member error code. string? IZodRule.Code => Code;
string? IZodRule.Origin => "value_object";}Generic rules
Section titled “Generic rules”Map an unbound generic rule type and the generator closes it with the property type, so one rule serves every underlying primitive:
[ZodRule(typeof(NotEmptyRule<>))][AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]public sealed class NotEmptyAttribute : ValidationAttribute{ public string? Code { get; set; }
public string? Message { get; set; }}[NotEmpty]on aGuidproperty instantiatesNotEmptyRule<Guid>; on anintproperty it instantiatesNotEmptyRule<int>.- The rule must expose exactly one type parameter. A type argument that cannot satisfy the rule’s constraints (for example
NotEmptyRule<T> where T : structapplied to astring) is reported asZODSGEN030and no rule is emitted, so the generated code always compiles.
Generating the attribute from the rule
Section titled “Generating the attribute from the rule”If you do not want to hand-write the attribute, mark the rule itself with the parameterless [ZodRule] and the generator emits a matching attribute:
using ZodSharp.Core;
namespace MyRules;
[ZodRule(Code = "invalid_string", Origin = "string")]public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? Message = null) : IValidationRule<string>{ public bool IsValid(in string value) => AllowEmpty || value.IndexOf(' ') < 0;
public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed.";}This produces a NoWhitespaceAttribute in the rule’s namespace, shaped like:
/// <summary>Validation attribute that applies NoWhitespaceRule.</summary>[global::System.AttributeUsage( global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | global::System.AttributeTargets.Parameter, Inherited = true, AllowMultiple = false)][global::ZodSharp.Core.ZodRule(typeof(global::MyRules.NoWhitespaceRule), Code = "invalid_string", Origin = "string")]public sealed class NoWhitespaceAttribute : global::System.ComponentModel.DataAnnotations.ValidationAttribute{ public bool AllowEmpty { get; set; } = true;}Mapping rules:
- The attribute name is the rule name with a trailing
Rulereplaced byAttribute(NoWhitespaceRule→NoWhitespaceAttribute). Override it with[ZodRule(AttributeName = "…")]. - Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter’s default value preserved. A parameter named
messageis omitted — use the inheritedValidationAttribute.ErrorMessageinstead. - The rule must be non-generic, non-nested, and non-abstract, and every parameter type must be a legal attribute-argument type (primitive,
string,enum,System.Type).
Type-level rules
Section titled “Type-level rules”Rules can also be attached to the [ZodSchema] type itself instead of a property. They validate the whole value (the value object as a unit) and report an empty path, which is what you want for a scalar whose single Value is the value:
[ZodRule(typeof(NotEmptyRule<>))][AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)]public sealed class NotEmptyAttribute : ValidationAttribute{ public string? Code { get; set; } public string? Message { get; set; }}
[NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")][ZodSchema]public partial record struct AssetId{ public Guid Value { get; init; }}The generator emits the rule against the value itself — no property local, and EmptyPath rather than a property path:
var assetIdCustomRule0 = new global::MyRules.NotEmptyRule<global::ChangeOps.AssetId>("invalid_asset_id", "AssetId must not be empty.");if (!assetIdCustomRule0.IsValid(value)){ (errors ??= new List<ValidationError>()).Add( ValidationError.Create( ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Code ?? "invalid_asset_id", assetIdCustomRule0.GetErrorMessage(value), EmptyPath, origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin ?? null));}- Generic closure: a type-level attribute closes an unbound generic rule with the target type (
NotEmptyRule<AssetId>), so the rule sees the value object and can read its state through its own constraints. - Ordering in the generated
Validate: property rules → type-level rules → the synchronousValidate()refinement. - Type-level attributes need
AttributeTargets.Class/Structon the attribute declaration; the property-level attributes above only needProperty/Field.
When a type-level rule does not run
Section titled “When a type-level rule does not run”- The type gets no schema. The generator is driven by
[ZodSchema]and then walks nested complex property types. A rule attribute on a type that gets no schema is ignored, and the analyzer reports the warningZODSGEN033so the mistake is visible. A type without[ZodSchema]that is referenced as a complex property of a schema does get a (secondary) schema, so its type-level rules run and no warning is raised. [ZodSchema(GenerateValidateMethod = false)]. Type-level rules live insideValidate, so they are omitted along with it.DisableZodSharpSourceGenerator. The generator — and therefore every rule — is skipped.
Validating scalar value objects
Section titled “Validating scalar value objects”A Purview.ValueObjects scalar is a single value, so validate it as a unit rather than through its Value property. Scalars implement the two-type-parameter contract:
public interface IScalarValueObject<TSelf, TValue> : IValueObject, IComparable<TSelf>, IComparable where TSelf : IScalarValueObject<TSelf, TValue>{ TValue Value { get; } static abstract TSelf Create(TValue value); static abstract TSelf Hydrate(TValue value); int CompareTo(TValue other);}so AssetId is IScalarValueObject<AssetId, Guid>. Today the check is normally repeated on every scalar:
// repeated on every Guid scalarpartial void OnZodValidate(RefineCtx<AssetId> context){ if (context.Value.Value == Guid.Empty) context.AddIssue("invalid_asset_id", "AssetId must not be empty.", [nameof(Value)]);}Type one rule on the value object and put the attribute on the scalar type:
// MyRules/NotEmptyRule.cs — a rules library that references Purview.ValueObjectspublic readonly record struct NotEmptyRule<TSelf>(string? Code = null, string? Message = null) : IValidationRule<TSelf>, IZodRule where TSelf : IScalarValueObject<TSelf, Guid>{ public bool IsValid(in TSelf value) => value.Value != Guid.Empty;
public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty.";
string? IZodRule.Code => Code;
string? IZodRule.Origin => "value_object";}
[ZodRule(typeof(NotEmptyRule<>))][AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)]public sealed class NotEmptyAttribute : ValidationAttribute{ public string? Code { get; set; } public string? Message { get; set; }}using Purview.ValueObjects.Serialization;using ZodSharp;
[Scalar][ZodSchema][NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")]public readonly partial record struct AssetId{ public Guid Value { get; init; }}
[Scalar][ZodSchema][NotEmpty(Code = "invalid_external_identity_id", Message = "ExternalIdentityId must not be empty.")]public readonly partial record struct ExternalIdentityId{ public Guid Value { get; init; }}The per-scalar Validate() refinements disappear, each scalar keeps its own Code/Message, and the reported error has an empty path because the rule applies to the value object itself:
Code = "invalid_asset_id"Message = "AssetId must not be empty."Origin = "value_object"Path = []Because the rule is closed with TSelf (NotEmptyRule<AssetId>), it sees the value object and reads Value through the IScalarValueObject<TSelf, Guid> constraint. A generic rule must have exactly one type parameter, so the underlying value type is pinned by the constraint — define one rule per primitive (NotEmptyRule<TSelf> where TSelf : IScalarValueObject<TSelf, Guid>, a long variant, and so on).
Diagnostics
Section titled “Diagnostics”| ID | Severity | Meaning |
|---|---|---|
| ZODSGEN030 | Error | The mapped rule does not implement IValidationRule<T> for the property type (or the rule target type), or an unbound generic rule could not be closed with it. |
| ZODSGEN031 | Error | A rule constructor parameter could not be mapped from the attribute. |
| ZODSGEN032 | Error | A validation attribute could not be generated for the rule. |
| ZODSGEN033 | Warning | A rule-mapped attribute is applied to a type that gets no generated schema (no [ZodSchema] and not referenced as a complex property), so the rule never runs. |
See Source Generator Diagnostics for the full list.
Related
Section titled “Related”- Fluent Schema API —
AddRule/Rulelive onZodType. - Source Generator DataAnnotations — built-in attribute coverage.
- Guarantees and Limitations — allocation and mutation semantics.