Value Objects Integration
Value Objects Integration
Section titled “Value Objects Integration”A Purview.ValueObjects scalar is a strongly typed wrapper around a single value (AssetId around a Guid, TenantId around a Guid, EmailAddress around a string, and so on). Because a scalar is one value, ZodSharp validates it as a unit — not through its Value property — and reports the failure with an empty path.
This page shows how to wire the [Scalar] generation to the [ZodSchema] generation, the two shapes a custom rule can take, and how the rule’s error code and message are defined. For the general rule contract, see Custom Rules.
Prerequisites and wiring
Section titled “Prerequisites and wiring”Reference both packages:
dotnet add package Purview.ValueObjectsdotnet add package Purview.ZodSharpA scalar is declared with the [Scalar] attribute (from Purview.ValueObjects.Serialization) and given a ZodSharp schema with [ZodSchema]. The type must be partial:
using Purview.ValueObjects.Serialization;using ZodSharp;
namespace ChangeOps;
[Scalar][ZodSchema]public readonly partial record struct AssetId{ public Guid Value { get; init; }}Each attribute brings in a different generator:
| Attribute | Generator | What it emits |
|---|---|---|
[Scalar] | Purview.ValueObjects | the IScalarValueObject<TSelf, TValue> implementation (and, when Purview.ZodSharp is referenced, the ScalarRuleAdapter<TSelf, TValue, TRule> used below) |
[ZodSchema] | Purview.ZodSharp | the static AssetIdSchema validator (Validate/Parse, and so on) |
The underlying member defaults to a public property named Value. Rename it with the attribute argument and the value-objects generator follows:
[Scalar("Id")][ZodSchema]public readonly partial record struct TenantId{ public Guid Id { get; init; }}Scalars implement the two-type-parameter contract:
namespace Purview.ValueObjects;
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>.
Automatic scalar forms
Section titled “Automatic scalar forms”Purview.ValueObjects 1.0.1 (or later) also supports an automatic form, where the value-object generator declares the underlying property for you. Both spellings are equivalent:
[Scalar<Guid>] [Scalar(typeof(Guid))][Scalar<string>(Nullable = true)] [Scalar(typeof(string), Nullable = true)][Scalar<int?>] [Scalar(typeof(int?))][Scalar<Guid>("Id")] [Scalar(typeof(Guid), "Id")]A nullable reference type cannot be a generic attribute argument ([Scalar<string?>] reports CS8970) or a typeof operand (typeof(string?) reports CS8639), so a nullable reference scalar is written with Nullable = true. Nullable value types are written directly ([Scalar<int?>] / [Scalar(typeof(int?))]). A nullable scalar round-trips JSON null, so its schema accepts null.
The property is emitted by the value-object generator, which ZodSharp cannot observe, so the automatic form has no source member for ZodSharp to read. ZodSharp resolves the underlying type from the attribute instead, so the schema is generated exactly as for the manual form:
[Scalar<Guid>][ZodSchema][NonSentinel(Message = "InstallationId must not be empty.")]public readonly partial record struct InstallationId { }Because there is no property to carry DataAnnotations, the automatic form supports type-level rules only:
[RequiredZod],[NullOrNonWhiteSpace],[NonSentinel], and any custom[ZodRule(typeof(...))]-mapped attribute applied to the type are read from the type and adapted to the scalar’s underlying value, reporting an empty path exactly as for the manual form.- A property-level DataAnnotation has no host on the generated property, so it is never observed. Use a Zod-native type-level rule, or switch to the manual
[Scalar]form when property-level DataAnnotations are required. [RequiredZod]always rejectsnull; use[NullOrNonWhiteSpace](or a custom null-tolerant rule) when a nullable scalar must acceptnullwhile rejecting whitespace.
[ZodSchema(GenerateValidateMethod = false)] is incompatible with a scalar form, because the value-object generator’s generated Create always calls {Type}Schema.Validate(instance); ZodSharp reports ZODSGEN044 rather than leaving a dangling reference, and it also reports it when the declared underlying type cannot be represented.
Two ways to attach a rule to a scalar
Section titled “Two ways to attach a rule to a scalar”There are two shapes, and the generator picks the right one from the rule’s own constraints:
- A rule written against the value object (
where TSelf : IScalarValueObject<TSelf, TValue>) closes over the scalar itself, so it can read state through the value-object contract. - A rule written against the underlying value (
IValidationRule<TValue>) is adapted automatically: the generator reads the scalar’s property, closes the rule with the underlying value, and wraps it inScalarRuleAdapterso the rule still runs against the value object as a unit. One rule then serves every scalar backed by the same primitive.
In both cases the rule is attached at the type level — put the attribute on the scalar type, not on its Value property — and the reported error has an empty path:
Code = "invalid_asset_id"Message = "AssetId must not be empty."Origin = "value_object"Path = []Type-level attributes need AttributeTargets.Class | AttributeTargets.Struct (and usually Property/Field too, so the same attribute can validate a primitive member). A generated attribute already carries all five targets:
[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; }}Error code and message definitions
Section titled “Error code and message definitions”Every rule should expose its error identity as public constants so consumers and tests can assert against the rule rather than re-typing literals:
public const string ErrorCode = "invalid_value";public const string MessageFormat = "Value must not be empty.";ErrorCodeis the rule’s canonical code — the value a test compares against (Assert.That(error.Code).IsEqualTo(NotEmptyRule<AssetId>.ErrorCode)).MessageFormatis astring.Formattemplate.{0}(and{1}, …) are the offending value and any rule-specific arguments; use it withstring.Format(CultureInfo.CurrentCulture, MessageFormat, …).- The constants may be inherited from a base rule class, and abstract bases are exempt, so a shared base can host them for its concrete derivations.
A rule that omits either constant is reported as ZODSGEN042 (a warning).
Runtime identity: IZodRule
Section titled “Runtime identity: IZodRule”The constants are the static default. When a single attribute must produce a different code per annotated scalar, the rule implements ZodSharp.Core.IZodRule and supplies its own code/origin at runtime:
public interface IZodRule{ string? Code { get; } // null falls back to the attribute-mapped code string? Origin { get; } // null falls back to the attribute-mapped origin}The generated code reads the identity through an IZodRule cast, preferring the rule over the mapped attribute values. A rule that accepts a code/origin constructor parameter without implementing IZodRule never reaches the reported error identity and is reported as ZODSGEN039.
The full precedence for the reported code and origin (first match wins):
- Rule-owned — the rule implements
IZodRule, so itsCode/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[NotEmpty(Code = "invalid_asset_id")]). - Attribute-type mapping —
[ZodRule(typeof(X), Code = "…", Origin = "…")]. - Default —
validation_failed, with no origin.
Message overrides
Section titled “Message overrides”The reported message is resolved in this order:
- A
messageargument on the rule (typically aMessageconstructor parameter), when set. - The attribute’s
ErrorMessage/ErrorMessageResourceName/ErrorMessageResourceType, mapped to a constructor parameter namedmessage. GetErrorMessage(value), which formatsMessageFormatwith the offending value.
MessageFormat is therefore the fallback, not the only message.
Rule against the value object
Section titled “Rule against the value object”Define one generic rule per underlying primitive, close it over the scalar, and implement IZodRule so each scalar keeps its own code:
// MyRules/NotEmptyRule.cs — a rules library that references Purview.ValueObjectsusing System.Globalization;using ZodSharp.Core;
namespace MyRules;
public readonly record struct NotEmptyRule<TSelf>(string? Code = null, string? Message = null) : IValidationRule<TSelf>, IZodRule where TSelf : IScalarValueObject<TSelf, Guid>{ public const string ErrorCode = "invalid_value"; public const string MessageFormat = "Value must not be empty.";
public bool IsValid(in TSelf value) => value.Value != Guid.Empty;
public string GetErrorMessage(in TSelf value) => Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat);
string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object";}using MyRules;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; }}Because the rule closes over 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). This rule is not adapted: a rule whose constraint asks for the scalar keeps closing over the value object.
Rule against the underlying value (adapted)
Section titled “Rule against the underlying value (adapted)”A rule written against the value (IValidationRule<Guid>) is adapted automatically when applied to a [Scalar] type, so one rule serves every scalar backed by that primitive:
// MyRules/NonSentinelRule.cs — a rule written against the value, not the value objectusing System;using System.Globalization;using ZodSharp.Core;
namespace MyRules;
public readonly record struct NonSentinelRule<T>(string? Message = null) : IValidationRule<T>, IZodRule where T : IEquatable<T>{ public const string ErrorCode = "invalid_value"; public const string MessageFormat = "Value is a sentinel value, but got {0}";
public bool IsValid(in T value) => !value.Equals(default(T)!);
public string GetErrorMessage(in T value) => Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value);
string? IZodRule.Code => ErrorCode; string? IZodRule.Origin => "value_object";}[Scalar][ZodSchema][NonSentinel(Message = "AssetId must not be empty.")]public readonly partial record struct AssetId{ public Guid Value { get; init; }}The generator emits the rule against Guid and wraps it in the adapter the value-objects generator emits:
var assetIdCustomRuleInner0 = new global::MyRules.NonSentinelRule<global::System.Guid>("AssetId must not be empty.");var assetIdCustomRule0 = new global::Purview.ValueObjects.ScalarRuleAdapter<global::ChangeOps.AssetId, global::System.Guid, global::MyRules.NonSentinelRule<global::System.Guid>>(assetIdCustomRuleInner0);if (!assetIdCustomRule0.IsValid(value)){ (errors ??= new List<ValidationError>()).Add( ValidationError.Create( ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Code ?? "validation_failed", assetIdCustomRule0.GetErrorMessage(value), EmptyPath, origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Origin));}The reported error keeps the empty path, and the error identity (Code/Origin) is read from the wrapped rule, not the adapter.
Testing
Section titled “Testing”Assert against the rule’s constants instead of duplicating literals, and remember that an adapted scalar rule reads its identity from the wrapped rule:
// Direct scalar rule — the runtime identity comes from IZodRule or the attribute mapping.var directError = AssetIdSchema.Validate(default).Errors[0];
await Assert.That(directError.Code).IsEqualTo("invalid_asset_id");await Assert.That(directError.Message).IsEqualTo("AssetId must not be empty.");await Assert.That(directError.Origin).IsEqualTo("value_object");await Assert.That(directError.Path.Length).IsEqualTo(0);
// Adapted underlying-value rule — the canonical identity is the wrapped rule's constant.var adaptedError = TenantIdSchema.Validate(default).Errors[0];
await Assert.That(adaptedError.Code).IsEqualTo(NonSentinelRule<Guid>.ErrorCode); // "invalid_value"await Assert.That(adaptedError.Message).IsEqualTo("TenantId must not be empty."); // attribute override
// With no Message override, the rule's MessageFormat is the message:await Assert.That( string.Format(CultureInfo.CurrentCulture, NonSentinelRule<Guid>.MessageFormat, Guid.Empty)) .IsEqualTo("Value is a sentinel value, but got 00000000-0000-0000-0000-000000000000");Diagnostics and troubleshooting
Section titled “Diagnostics and troubleshooting”| ID | Severity | Meaning |
|---|---|---|
| ZODSGEN030 | Error | The mapped rule does not implement IValidationRule<T> for the target type, or an unbound generic rule could not be closed with it. |
| ZODSGEN033 | Warning | A rule-mapped attribute is applied to a type that gets no generated schema (no [ZodSchema] and not reachable as a complex property), so the rule never runs. |
| ZODSGEN039 | Warning | A rule accepts a code/origin constructor parameter but does not implement IZodRule, so the value never reaches the reported error identity. |
| ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. |
| ZODSGEN042 | Warning | A rule does not expose public const string ErrorCode / MessageFormat constants. |
| ZODSGEN044 | Error | A [ZodSchema] type uses an automatic [Scalar] form but cannot produce a usable schema: the generated Validate method is suppressed, or the declared underlying type cannot be represented. |
Common causes:
- The rule never runs. The scalar is missing
[ZodSchema](ZODSGEN033), or[ZodSchema(GenerateValidateMethod = false)]omitted theValidatemethod that hosts type-level rules. - The validator does not compile.
DisableValueObjectsSourceGeneratordisabled the adapter; re-enable it, or express the rule against the value object so no adapter is needed. - The wrong rule shape resolved. A rule constrained to
IScalarValueObject<TSelf, TValue>is never adapted; a rule written against the underlying value is always adapted. If a family member cannot validate the target type, ZODSGEN030 is reported and nothing is emitted.
Related
Section titled “Related”- Custom Rules — the rule contract, the public
AddRule/RuleAPI, rule families, and attribute generation. - Source Generator — the
[ZodSchema]generator, generated types, and the refinement hook. - Source Generator Diagnostics — the full
ZODSGEN*catalog.