Skip to content

Expand the built-in assertion set of the validation library #74

Description

@feO2x

Expand the Built-In Assertion Set

Rationale

IsUuidV7 (#72) landed as the first Guid-shaped format assertion and noted that "more assertions are planned for the library". This issue collects the candidates found by auditing the current Checks.* surface against Light.GuardClauses, FluentValidation, Zod, and the JSON Schema format vocabulary.

The library is already at effective parity with FluentValidation's built-in validator set — CreditCard is the only one missing. The candidates below therefore come from GuardClauses, from formats that matter specifically at an HTTP/gRPC/messaging boundary, and from asymmetries inside the existing families.

Each candidate is sized by its OpenAPI cost. A metadata-free rule registers as ErrorMetadataContract.NoMetadata and costs exactly the six registration points enumerated in #72; a rule carrying metadata additionally needs a schema in BuiltInValidationErrorContracts and metadata keys in ValidationErrorMetadataKeys.

This is a tracking issue. Each accepted candidate gets its own sub-issue and plan; nothing here is committed to until then.

Current Surface

Family Assertions
Comparable IsGreaterThan(OrEqualTo), IsLessThan(OrEqualTo), IsInRange, IsNotInRange, IsInExclusiveRange
Count HasCount / HasMinCount / HasMaxCount × (string?, TCollection, ImmutableArray<T>)
Strings IsNotNullOrWhiteSpace, HasMinLength, HasMaxLength, HasLengthIn, Matches, IsEmail, ContainsOnlyDigits, ContainsOnlyLettersAndDigits
Empty / Null IsEmpty / IsNotEmpty (string, Guid, collections), IsNull / IsNotNull
Enums IsInEnum, IsEnumName
Decimals HasPrecisionAndScale
Guids IsUuidV7
Predicate Must, Custom

Tier 1 — Highest Value, Metadata-Free

  • Check<DateTime>.IsUtc() / IsLocal() / IsUnspecified() — GuardClauses MustBeUtc / MustBeLocal / MustBeUnspecified. A DTO deserialized from JSON carries a DateTimeKind that reflects how the client encoded the timestamp — Unspecified for a timestamp with no zone, Local for one with an offset — and there is no way to state a requirement about it today. Storing an Unspecified value as if it were UTC is the classic boundary bug in exactly the scenario this library targets. TemporalMetadataOpenApiConformanceTests already proves temporal values round-trip canonically through the metadata pipeline. Tracked in Add DateTimeKind assertions (IsUtc, IsLocal, IsUnspecified) to the validation library #75. The DateTimeOffset counterpart was split out of that issue during review — see Tier 4.
  • Numeric sign family: IsPositive, IsNegative, IsNotNegative, IsNotPositive, IsNotZero — GuardClauses has all five. IsGreaterThan(0) works today but yields a generic message plus a comparativeValue: 0 metadata payload carrying no information. Dedicated codes give better messages and free contracts. Open question: netstandard2.0 has no generic math, so this needs either per-type overloads or an IComparable<T> + default(T) formulation.
  • IsAbsoluteUri / IsHttpsUrl / IsHttpOrHttpsUrl / HasScheme for Check<string> and Check<Uri> — GuardClauses MustBeAbsoluteUri, MustBeHttpUrl, MustBeHttpsUrl, MustBeHttpOrHttpsUrl, MustHaveScheme, MustHaveOneSchemeOf. Webhook, callback, and redirect URLs are everywhere in request DTOs, and accepting a relative or javascript: URI is a security issue rather than a data-quality one. Note that MetadataValue.FromUri is already wired through BuiltInValidationErrorDefinitions.Shared.cs and ValidationErrorMessageFormatting.cs, yet no assertion in the library operates on Uri at all — the metadata kind is supported and unreachable. HasScheme carries one string key; the rest are metadata-free.

Tier 2 — Closes Internal Asymmetries

  • HasCountIn(min, max) for collections — GuardClauses MustHaveCountIn. Strings have HasLengthIn; collections have HasCount / HasMinCount / HasMaxCount but no range form. The metadata shape is identical to the existing LengthInRange contract, so this is the cheapest real gap in the library.
  • IsOneOf / IsNotOneOf — GuardClauses MustBeOneOf / MustNotBeOneOf; .NET 8's [AllowedValues] / [DeniedValues]. Allowed-value sets for string fields that are not enums: currency codes, region slugs, sort keys. Would be the first built-in rule with array metadata (MetadataKind.Array exists but is unused by built-in rules), which is the main design cost. Good OpenAPI payoff — consumers can render the permitted set from the error contract.
  • HasUniqueItems() — not in GuardClauses, but a first-class JSON Schema keyword (uniqueItems) and a common API need for tag and ID lists. Metadata-free. Needs a decision on the comparer and on hash-set allocation versus an O(n²) scan for small collections.

Tier 3 — String Formats, Mostly Liftable from GuardClauses

GuardClauses already ships portable implementations with the netstandard2.0 fallback written — Check.IsBase64.cs uses Base64.IsValid on net10 and IsBase64Portable elsewhere; Check.IsAscii.cs uses Ascii.IsValid on net8+ and a manual loop otherwise. That is exactly the dual-target problem #72 spent most of its length on, already solved.

  • IsBase64 / IsBase64Url — tokens, continuation cursors, encoded blobs. Zod has both.
  • IsAscii — fields bound for legacy systems or HTTP headers.
  • IsHexadecimal — hashes, signatures, colour codes.
  • StartsWith / EndsWith / Contains with a StringComparison — prefix-coded identifiers. Metadata: the substring plus the comparison type.
  • IsLowerCase / IsUpperCase, IsFileExtension — narrow, but nearly free once the family exists.
  • IsCreditCard (Luhn) — the one FluentValidation validator not covered here.
  • IsE164PhoneNumber — Zod's e164. Phone numbers are ubiquitous in DTOs and everyone hand-rolls the regex.

Deliberately excluded: IsTrimmed / IsTrimmedAtStart / IsTrimmedAtEnd, despite GuardClauses having them. Check() trims by default via TrimStringNormalizer, so the assertion would almost always be vacuous.

Tier 4 — High Demand, Real Design Cost

  • Check<DateTimeOffset>.HasZeroOffset() — split out of Add DateTimeKind assertions (IsUtc, IsLocal, IsUnspecified) to the validation library #75 during review, because it is not a DateTimeKind assertion and cannot share the Utc code. Measured on .NET 10: 2026-08-02T10:00:00+00:00 deserializes to DateTimeKind.Local as a DateTime but to a zero offset as a DateTimeOffset, so a shared code would give one wire value opposite verdicts, and OpenAPI cannot expose the difference — PortableOpenApiSchemaTypeMapper maps both types to string/date-time. Worse, an unzoned wire value deserializes to a DateTimeOffset carrying the server's offset, so the assertion would accept it on a UTC-configured host and reject it elsewhere. Needs its own ZeroOffset code and message plus a decision on that host dependence. Note that a DateTimeOffset with any valid offset already identifies an unambiguous instant, so requiring zero is a canonicalization policy rather than an ambiguity check.
  • IsInThePast / IsInTheFuture / IsWithin(skew) — the most-requested temporal validation there is. Add an IsUuidV7 assertion to the validation library #72 deferred the UUIDv7 timestamp-plausibility variant for the same reason: it needs a clock abstraction. Open questions are whether TimeProvider hangs off ValidationContextOptions or is passed per call, and whether TimeProvider is reachable on netstandard2.0 via Polyfill or needs a Microsoft.Bcl.TimeProvider reference on that target asset.
  • IsMultipleOf — JSON Schema's multipleOf: step sizes, quantities in packs, money in cents. Same generic-math constraint as the sign family.
  • IsFinite for double / float — GuardClauses MustBeFinite. Narrow, since System.Text.Json rejects NaN by default, but relevant for gRPC and messaging where the value arrives intact.
  • IsValidFlagsCombination<TEnum>Checks.Enums.cs documents that flags-combined values fail IsInEnum. That is a deliberate choice, but it leaves [Flags] enums with no correct assertion. GuardClauses solves this with its EnumInfo type behind MustBeValidEnumValue.

Unrelated Observations from the Audit

Neither is an assertion, but both surfaced while comparing families and are worth deciding on separately.

  • The string Count and Length families overlap. Checks.Count.cs provides HasCount / HasMinCount / HasMaxCount on Check<string?> measuring .Length, while Checks.Strings.cs provides HasMinLength / HasMaxLength / HasLengthIn. A string's minimum length therefore has two spellings with different error codes, and the Count spelling emits "{displayName} must contain at least {minCount} item(s)" for a string. Either the string Count overloads should go, or the Length family should gain an exact-length HasLength — right now the Length family is the one with the hole.
  • Uri metadata is plumbed but unreachable, as noted under Tier 1.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions