Skip to main content
Version: 0.2.x (0.2.1)

Authoring reference

Supported underlying types​

string, Guid, bool, char, every built-in integer (including Int128 and UInt128, which travel as JSON strings), decimal, double, float, DateOnly, TimeOnly, DateTime, DateTimeOffset, TimeSpan.

Declarative options on [ValueObject<T>]​

OptionTypeDefaultEffect
Patternstring?noneRegular expression the normalized value must match. Also the OpenAPI pattern. Invalid → VO0014.
MinLength, MaxLengthint-1, unconstrainedstring only (VO0008 otherwise). Validation, OpenAPI minLength / maxLength, and the EF Core column size.
Minimum, Maximumstring?noneInclusive bounds in invariant-culture text, so decimal, DateOnly and TimeSpan keep full precision. Parsed at compile time; unparsable → VO0004. Also OpenAPI minimum / maximum.
ComparisonStringComparisonOrdinalstring only. Drives equality, ordering and hashing together.
ValueSetValueSetKindOpenClosed accepts only the declared [KnownValue]s, through a frozen lookup, and becomes the schema enum. Members of a closed set over a reference type are boxed once and shared, so the boxed paths allocate nothing.
ArithmeticboolfalseNumeric types only (VO0007 otherwise). Operators and generic math; every result is validated again.
ImplicitConversionToValueboolfalsestring s = iban;
ExplicitConversionFromValueboolfalse(Iban)text, validating like Create.
AllowEmptyboolfalsestring only. Accepts ""; null is still rejected, since absence is T?.
AllowDefaultboolfalseSilences VO0010, for a type whose zero state is meaningful.
SchemaFormatstring?the natural format of the typeOpenAPI format: uuid, date, int64, or your own such as iban or email.
Examplestring?noneOpenAPI example.
Descriptionstring?the type's XML <summary>OpenAPI description.

Declared rules run before any hook, so a validator only ever sees values that already satisfy them. Validation and normalization gives the exact order.

Hooks​

A value object declares a rule by implementing an interface, so the compiler checks the signature: a mis-typed rule fails the build instead of being silently ignored. All are optional, and VO0011 reports a rule written without its interface — the one mistake the compiler cannot catch.

InterfaceMember
IValueObjectNormalizer<TValue>static TValue NormalizeValue(TValue value)
IValueObjectSpanNormalizerstatic string NormalizeValue(ReadOnlySpan<char> value) — string value objects only
IValueObjectValidator<TValue>static ValidationResult ValidateValue(in TValue value)
IValueObjectFormatter<TValue>static bool TryFormatValue(in TValue value, Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
IValueObjectStringFormatter<TValue>static string FormatValue(in TValue value, ReadOnlySpan<char> format, IFormatProvider? provider)

NormalizeValue must be idempotent and must not reject: an unnormalizable value is rejected by ValidateValue. TryFormatValue, when present, takes over formatting entirely, including the default format.

Adding IValueObjectSpanNormalizer alongside IValueObjectNormalizer<string> lets parsing and JSON reading normalize straight from the text, so ingesting a value allocates the normalized string and nothing else. It halves what TryParse allocates, and makes deserializing a payload of value objects allocate exactly what deserializing the same payload of primitives does. Write the value-typed overload as a one-line delegation:

public readonly partial struct Iban : IValueObjectNormalizer<string>, IValueObjectSpanNormalizer
{
public static string NormalizeValue(string value) => NormalizeValue(value.AsSpan());

public static string NormalizeValue(ReadOnlySpan<char> value)
{
Span<char> buffer = value.Length <= 64 ? stackalloc char[64] : new char[value.Length];
// ... write the normalized characters into buffer ...
return new string(buffer[..length]);
}
}

The rules are public because a static interface member cannot be anything else. Normalize remains the member callers use: it guards against a null underlying value and then defers to NormalizeValue.

Diagnostics​

The generator and the analyzers report VO0001 to VO0018. Diagnostics lists each one with its fix.

Next: Generated members, for what the generator writes from all of this.