You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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.
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 currentChecks.*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 —
CreditCardis 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.NoMetadataand costs exactly the six registration points enumerated in #72; a rule carrying metadata additionally needs a schema inBuiltInValidationErrorContractsand metadata keys inValidationErrorMetadataKeys.This is a tracking issue. Each accepted candidate gets its own sub-issue and plan; nothing here is committed to until then.
Current Surface
IsGreaterThan(OrEqualTo),IsLessThan(OrEqualTo),IsInRange,IsNotInRange,IsInExclusiveRangeHasCount/HasMinCount/HasMaxCount× (string?,TCollection,ImmutableArray<T>)IsNotNullOrWhiteSpace,HasMinLength,HasMaxLength,HasLengthIn,Matches,IsEmail,ContainsOnlyDigits,ContainsOnlyLettersAndDigitsIsEmpty/IsNotEmpty(string,Guid, collections),IsNull/IsNotNullIsInEnum,IsEnumNameHasPrecisionAndScaleIsUuidV7Must,CustomTier 1 — Highest Value, Metadata-Free
Check<DateTime>.IsUtc()/IsLocal()/IsUnspecified()— GuardClausesMustBeUtc/MustBeLocal/MustBeUnspecified. A DTO deserialized from JSON carries aDateTimeKindthat reflects how the client encoded the timestamp —Unspecifiedfor a timestamp with no zone,Localfor one with an offset — and there is no way to state a requirement about it today. Storing anUnspecifiedvalue as if it were UTC is the classic boundary bug in exactly the scenario this library targets.TemporalMetadataOpenApiConformanceTestsalready proves temporal values round-trip canonically through the metadata pipeline. Tracked in Add DateTimeKind assertions (IsUtc, IsLocal, IsUnspecified) to the validation library #75. TheDateTimeOffsetcounterpart was split out of that issue during review — see Tier 4.IsPositive,IsNegative,IsNotNegative,IsNotPositive,IsNotZero— GuardClauses has all five.IsGreaterThan(0)works today but yields a generic message plus acomparativeValue: 0metadata payload carrying no information. Dedicated codes give better messages and free contracts. Open question:netstandard2.0has no generic math, so this needs either per-type overloads or anIComparable<T>+default(T)formulation.IsAbsoluteUri/IsHttpsUrl/IsHttpOrHttpsUrl/HasSchemeforCheck<string>andCheck<Uri>— GuardClausesMustBeAbsoluteUri,MustBeHttpUrl,MustBeHttpsUrl,MustBeHttpOrHttpsUrl,MustHaveScheme,MustHaveOneSchemeOf. Webhook, callback, and redirect URLs are everywhere in request DTOs, and accepting a relative orjavascript:URI is a security issue rather than a data-quality one. Note thatMetadataValue.FromUriis already wired throughBuiltInValidationErrorDefinitions.Shared.csandValidationErrorMessageFormatting.cs, yet no assertion in the library operates onUriat all — the metadata kind is supported and unreachable.HasSchemecarries one string key; the rest are metadata-free.Tier 2 — Closes Internal Asymmetries
HasCountIn(min, max)for collections — GuardClausesMustHaveCountIn. Strings haveHasLengthIn; collections haveHasCount/HasMinCount/HasMaxCountbut no range form. The metadata shape is identical to the existingLengthInRangecontract, so this is the cheapest real gap in the library.IsOneOf/IsNotOneOf— GuardClausesMustBeOneOf/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.Arrayexists 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.0fallback written —Check.IsBase64.csusesBase64.IsValidon net10 andIsBase64Portableelsewhere;Check.IsAscii.csusesAscii.IsValidon 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/Containswith aStringComparison— 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'se164. 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 viaTrimStringNormalizer, 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 aDateTimeKindassertion and cannot share theUtccode. Measured on .NET 10:2026-08-02T10:00:00+00:00deserializes toDateTimeKind.Localas aDateTimebut to a zero offset as aDateTimeOffset, so a shared code would give one wire value opposite verdicts, and OpenAPI cannot expose the difference —PortableOpenApiSchemaTypeMappermaps both types tostring/date-time. Worse, an unzoned wire value deserializes to aDateTimeOffsetcarrying the server's offset, so the assertion would accept it on a UTC-configured host and reject it elsewhere. Needs its ownZeroOffsetcode and message plus a decision on that host dependence. Note that aDateTimeOffsetwith 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 whetherTimeProviderhangs offValidationContextOptionsor is passed per call, and whetherTimeProvideris reachable onnetstandard2.0viaPolyfillor needs aMicrosoft.Bcl.TimeProviderreference on that target asset.IsMultipleOf— JSON Schema'smultipleOf: step sizes, quantities in packs, money in cents. Same generic-math constraint as the sign family.IsFinitefordouble/float— GuardClausesMustBeFinite. Narrow, sinceSystem.Text.JsonrejectsNaNby default, but relevant for gRPC and messaging where the value arrives intact.IsValidFlagsCombination<TEnum>—Checks.Enums.csdocuments that flags-combined values failIsInEnum. That is a deliberate choice, but it leaves[Flags]enums with no correct assertion. GuardClauses solves this with itsEnumInfotype behindMustBeValidEnumValue.Unrelated Observations from the Audit
Neither is an assertion, but both surfaced while comparing families and are worth deciding on separately.
Checks.Count.csprovidesHasCount/HasMinCount/HasMaxCountonCheck<string?>measuring.Length, whileChecks.Strings.csprovidesHasMinLength/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-lengthHasLength— right now the Length family is the one with the hole.Urimetadata is plumbed but unreachable, as noted under Tier 1.