Skip to content

Latest commit

 

History

History
214 lines (157 loc) · 7.92 KB

File metadata and controls

214 lines (157 loc) · 7.92 KB

Validation Reference

A complete reference of the built-in validation attributes shipped with the library, plus how validation strategies and custom rules fit together.

Validation runs at construction time. A failed validation throws ArgumentException (not FormatException) — see CLAUDE.md.

For the architecture (attribute → strategy → rule → factory pipeline), see architecture.md. For practical patterns including custom rules, see advanced-usage.md.

At a glance

Category Where Count
Text Semantics.Strings/Validation/Attributes/Text/ 7
Format Semantics.Strings/Validation/Attributes/Format/ 7
Casing Semantics.Strings/Validation/Attributes/Casing/ 9
Path Semantics.Paths/Validation/Attributes/Path/ 10
Strategies Semantics.Strings/Validation/Strategies/ 2

There is no quantity validation in this list — semantic quantities enforce their own invariants at the type level (see strategy-unified-vector-quantities.md).

Text

[IsEmailAddress]

Validates that the value parses as an email address.

[IsEmailAddress]
public sealed record EmailAddress : SemanticString<EmailAddress> { }

EmailAddress.Create("user@example.com");   // ✅
EmailAddress.Create("not-an-email");       // ❌ ArgumentException

[IsBase64]

Validates that the value is well-formed Base64.

[IsBase64]
public sealed record ApiToken : SemanticString<ApiToken> { }

[StartsWith(prefix)], [EndsWith(suffix)], [Contains(substring)]

Self-explanatory substring constraints.

[StartsWith("https://"), Contains(".example.com")]
public sealed record SecureApiUrl : SemanticString<SecureApiUrl> { }

[PrefixAndSuffix(prefix, suffix)]

Convenience for "must start with X and end with Y".

[PrefixAndSuffix("Bearer ", "==")]
public sealed record BearerToken : SemanticString<BearerToken> { }

[RegexMatch(pattern[, options])]

Arbitrary regex constraint.

[RegexMatch(@"^[a-z0-9]+(-[a-z0-9]+)*$")]
public sealed record BlogSlug : SemanticString<BlogSlug> { }

Format

[IsEmptyOrWhitespace] / [HasNonWhitespaceContent]

Mutually exclusive — pick one.

[IsSingleLine] / [IsMultiLine]

Constrain whether the string contains line breaks.

[HasExactLines(n)], [HasMinimumLines(n)], [HasMaximumLines(n)]

Constrain the line count.

[HasMaximumLines(10), HasNonWhitespaceContent]
public sealed record CommitMessageHeader : SemanticString<CommitMessageHeader> { }

Casing

Attribute Style Example
[IsCamelCase] myVariable httpRequest
[IsPascalCase] MyClass HttpRequest
[IsKebabCase] lower-with-dashes http-request
[IsSnakeCase] lower_with_underscores http_request
[IsMacroCase] UPPER_WITH_UNDERSCORES HTTP_REQUEST
[IsLowerCase] all lowercase httprequest
[IsUpperCase] all uppercase HTTPREQUEST
[IsSentenceCase] first letter upper, rest lower Http request
[IsTitleCase] first letter of each word upper Http Request

First-class .NET types

Removed in 3.0. The [IsBoolean], [IsDateTime], [IsDecimal], [IsDouble], [IsGuid], [IsInt32], [IsIpAddress], [IsTimeSpan], [IsUri], and [IsVersion] attributes were deprecated in 2.x and have now been deleted. Wrap the .NET type directly instead — it is more type-safe, faster, and cheaper in memory than validating a string:

public sealed record TransactionId(Guid Value);
public sealed record WebsiteUrl(Uri Value);

If a value genuinely has to stay a string inside a wider validation pipeline, write a custom validation attribute (see Custom validation attributes) wrapping the corresponding TryParse.

Path

These live in Semantics.Paths and require using ktsu.Semantics.Paths;.

Attribute Validates
[IsPath] Legal path characters and length.
[IsValidPath] Stricter: also rejects reserved names.
[IsAbsolutePath] Fully qualified path.
[IsRelativePath] Not absolute.
[IsFilePath] Refers to a file: a fully qualified path must not name an existing directory.
[IsDirectoryPath] Refers to a directory: a fully qualified path must not name an existing file.
[IsFileName] Filename without separators.
[IsValidFileName] Stricter filename validation.
[IsExtension] File extension including the leading dot.
[DoesExist] The path exists at validation time. Use sparingly — couples the type to the file system.
[IsAbsolutePath, DoesExist]
public sealed record ConfigFilePath : SemanticString<ConfigFilePath> { }

[IsFilePath] and [IsDirectoryPath] consult the file system only for fully qualified paths. A path that is not fully qualified names no particular location until a caller supplies a base directory, so probing for it would resolve it against the process's current working directory — making the same string valid in one process and invalid in another depending on what files happen to sit beside them. Those paths are validated by shape alone. To ask the existence question about a relative path, resolve it first with AsAbsolute(baseDirectory) and validate the result.

For most use cases, prefer the dedicated path types (AbsoluteFilePath, RelativeDirectoryPath, etc.) from Semantics.Paths — they bundle these attributes and provide rich path operations.

Strategies

By default, all attributes on a type must pass (ValidateAll semantics). Strategies override that behaviour.

[ValidateAll] (default)

Every attribute must pass. Equivalent to leaving the strategy attribute off.

[ValidateAny]

At least one attribute must pass.

[ValidateAny]
[IsEmailAddress, StartsWith("https://")]
public sealed record ContactMethod : SemanticString<ContactMethod> { }

Need richer logic (e.g. "all critical attributes must pass and at least one secondary attribute must pass")? Implement IValidationStrategy and register it via ValidationStrategyFactory. See advanced-usage.md for the worked example.

Custom validation attributes

Subclass SemanticStringValidationAttribute:

public sealed class IsProductCodeAttribute : SemanticStringValidationAttribute
{
    private static readonly Regex Pattern = new(@"^[A-Z][0-9]{5}$", RegexOptions.Compiled);

    public override bool Validate(ISemanticString semanticString) =>
        Pattern.IsMatch(semanticString.ToString());
}

[IsProductCode]
public sealed record ProductCode : SemanticString<ProductCode> { }

Validation runs through the attribute → strategy → rule pipeline regardless of whether the attribute is built-in or custom.

Practical patterns

Domain-specific types

[IsEmailAddress]
public sealed record UserEmail : SemanticString<UserEmail> { }

[RegexMatch(@"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")]
public sealed record ThemeColor : SemanticString<ThemeColor> { }

[RegexMatch(@"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")]
public sealed record JwtToken : SemanticString<JwtToken> { }

Combined constraints

// Default ValidateAll
[StartsWith("https://"), Contains(".example.com"), HasNonWhitespaceContent]
public sealed record SecureApiUrl : SemanticString<SecureApiUrl> { }

// Either-or
[ValidateAny]
[EndsWith(".com"), EndsWith(".org")]
public sealed record TrustedDomain : SemanticString<TrustedDomain> { }

When to prefer first-class .NET types

For values whose consumer cares about the parsed object (Guid, IPAddress, Uri, …), wrap the .NET type directly instead of validating the string:

public sealed record TransactionId(Guid Value)
{
    public static TransactionId New() => new(Guid.NewGuid());
}

Use the [Is*] attributes when the value belongs in a string-shaped pipeline (logs, configs, serialised payloads) and the parsed object is incidental.