GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — GitHub - 0siris/ValidSphere: Lightweight runtime guard and invariant library for .NET

BranchesTags
Open more actions menu

Latest commit

 

History

61 Commits
61 Commits

Folders and files

NameName
Last commit message
Last commit date
.github/workflows
.github/workflows
 
 
benchmarks/ValidSphere.Benchmarks
benchmarks/ValidSphere.Benchmarks
 
 
docs
docs
 
 
src
src
 
 
tests/ValidSphere.Tests
tests/ValidSphere.Tests
 
 
.gitignore
.gitignore
 
 
Directory.Build.props
Directory.Build.props
 
 
LICENSE
LICENSE
 
 
Readme.md
Readme.md
 
 
ValidSphere.slnx
ValidSphere.slnx
 
 
global.json
global.json
 
 

Repository files navigation

ValidSphere

NuGet CI Deterministic SourceLink

A lightweight, extensible assertion and guard library for modern .NET.

ValidSphere constrains program execution to valid states by rejecting invalid inputs and runtime conditions where their constraints are known. It is runtime-first and intended for production code as well as tests, but it is not primarily a test assertion framework.

The same assertion extensions can be used with different failure policies:

value.Is().Eq(expected);
value.Guard().Eq(expected);

The assertion logic stays the same. Only the failure behavior changes:

  • Is() represents a runtime assertion and throws AssertException.
  • Guard() represents argument or precondition validation and throws standard .NET argument exceptions.

This keeps validation rules reusable while preserving the correct failure semantics for each call site.

Contents

  • Positioning
  • Goals
  • Non-Goals
  • Getting Started
    • Installation
    • Basic Usage
    • Guards
    • Is() vs. Guard()
    • Chaining Assertions
    • Using the Asserted Value
  • Assertion Reference
    • Common Assertions
    • Nullability
    • Numeric Comparisons
    • Floating-Point Assertions
    • Strings
    • GUIDs
    • Collections
    • Paths, URIs, Mail Addresses, and GUIDs
    • Enums
    • Runtime Type Assertions
    • Optional Failure Messages
  • Diagnostics and Testing
    • Caller Information and Debugging
    • Test Framework Integration
  • Architecture and Extending
    • Architecture
    • Assertion<T, TPolicy>
    • AssertionContext
    • Assertion Policies
    • Implementing a Custom Policy
    • Creating an Entry Point for a Custom Policy
    • Writing Custom Assertion Extensions
    • Preserving Assertion Context in Extensions
    • Example: Domain-Specific Extension
    • Example: Refining the Assertion Type
    • Extension Design Guidelines
    • Custom Exceptions
  • Appendix
    • Performance Model
    • Choosing Between Specialized Assertions and Satisfy()
    • Recommended Usage
    • API Overview
    • Summary

Positioning

ValidSphere sits between classic guard libraries and test assertion libraries:

Guard libraries ValidSphere Test assertions
Guard clauses Guards and invariants Test-focused assertions
Caller contracts Runtime-first Test diagnostics
Precondition checks Reusable rules Expected-result checks

The name refers to the valid runtime state space that remains after guards, assertions, and invariants eliminate invalid states.


Goals

The library is designed around a few principles:

  • Small API surface
  • Fluent but lightweight syntax
  • No heap allocation introduced by the core assertion wrapper on the successful path
  • No reflection
  • No stack walking for caller information
  • No virtual dispatch for assertion policies
  • Reusable assertion extensions
  • Separate failure semantics through policies
  • Good nullable-flow integration
  • Good compiler and debugger integration
  • Caller expression, member, file, and line captured automatically
  • Assertions can be embedded directly into expressions
  • Easy to extend with project-specific assertions and policies

The core assertion object is a readonly struct, while policies use static abstract interface members.

value
  │
  ├── Is()
  │     └── IsPolicy
  │            └── AssertException
  │
  └── Guard()
        └── GuardPolicy
               ├── ArgumentException
               ├── ArgumentNullException
               └── ArgumentOutOfRangeException

Non-Goals

ValidSphere is not a DTO or object validation framework, a replacement for FluentValidation, a full test assertion or object-graph comparison library, or a reflection-based diagnostics and contract system.


Getting Started

Installation

dotnet add package ValidSphere --version 1.0.0

The package targets net10.0, net8.0 (identical API) and netstandard2.1 (without generic-math comparisons, numeric, floating-point, and date-only checks).

Then import the namespace:

using ValidSphere;

Basic Usage

Runtime assertions with Is()

Use Is() when a failed condition represents an invalid program state, violated invariant, unexpected result, or failed test assertion.

var count = 5;

count.Is().Eq(5);
count.Is().Greater(0);

A failed Is() assertion throws AssertException.

var count = 3;

count.Is().Eq(5);

A failure contains the assertion message together with compiler-captured source information, for example:

Expected '5', but found '3'.
Expression: count
Member: Process
Source: C:\Projects\Example\Service.cs(42)

The original source expression is captured with CallerArgumentExpression.

This also works for expressions:

items.Count.Is().Greater(0);

The assertion context can therefore contain:

Expression: items.Count

without reflection, stack-trace parsing, or source-code analysis.


Guards

Use Guard() for method arguments and preconditions.

public void SetRetryCount(int retryCount)
{
    retryCount.Guard().Range(0, 10);

    // ...
}

The important difference from Is() is the exception type.

For example:

name.Guard().NotNull();

throws ArgumentNullException, while:

count.Guard().Greater(0);

throws ArgumentOutOfRangeException.

Other guard failures use ArgumentException.

The captured subject expression is used as the parameter name where possible, so:

retryCount.Guard().Greater(0);

can produce an exception whose ParamName is:

retryCount

This allows public APIs to use the fluent assertion syntax while still following normal .NET argument-exception conventions.

If a nullable variable should also be recognized as non-null by C# nullable flow analysis after the call, use AsGuardNotNull() as described below.


Is() vs. Guard()

Both entry points use the same assertion extensions.

value.Is().Greater(0);
value.Guard().Greater(0);

The difference is the selected policy.

Entry point Intended use Failure
Is() Runtime assertions, invariants, test assertions AssertException
Guard() Arguments and preconditions Standard .NET argument exceptions

For example:

public Mesh Process(Mesh? mesh, float tolerance)
{
    mesh.AsGuardNotNull();
    tolerance.Guard().Greater(0);

    var result = ProcessInternal(mesh, tolerance);

    result.AsNotNull();

    return result;
}

Guard() and AsGuardNotNull() validate the caller contract.

Is() and AsNotNull() validate assumptions and invariants made by the implementation.


Chaining Assertions

Assertion methods return the assertion object again, so checks can be chained directly.

value.Is()
     .Greater(0)
     .Less(100)
     .NotEq(42);

For strings:

name.Is()
    .NotNullOrWhiteSpace()
    .NotEmpty();

The selected policy and the original AssertionContext are retained for the complete chain.

Conceptually:

value.Is()
    │
    └── Assertion<int, IsPolicy>
             │
             ├── Greater(0)
             ├── Less(100)
             └── NotEq(42)

No And property is required because every assertion directly returns an assertion that can be continued.

Chain entries vs. terminal extractors

Methods named X() return the chainable assertion; methods named AsX() validate and extract the raw value terminally:

trackingId.Is().Guid().NotEmpty();   // Assertion<Guid, IsPolicy> — chainable
Guid g = trackingId.Is().AsGuid();   // Guid — terminal
string v = name.AsNotNullOrWhiteSpace();  // string — terminal
Color c = "Red".Is().AsEnum<Color>();     // Color — terminal

Terminal forms still fail through the selected policy (or a configured OnFailure factory); they just do not return an assertion.


Using the Asserted Value

Assertion<T, TPolicy> exposes the asserted value through Value and can also be converted implicitly back to T.

This allows assertions to be embedded directly into expressions:

var count = input.Guard().Greater(0).Value;

or:

Process(input.Guard().Greater(0));

Type-refining assertions can also be consumed inline:

string? name = GetName();

Process(name.Guard().NotNull());

When the original nullable variable is used again afterwards, prefer the flow-analysis-aware direct entry point:

string? name = GetName();

name.AsGuardNotNull();

Process(name); // no nullable warning

The two forms therefore solve slightly different problems:

Guard().NotNull()
    → refines the returned Assertion<T, TPolicy>

AsGuardNotNull()
    → returns the validated non-null value directly
    → also informs nullable flow analysis about the original variable

The same principle applies to AsNotNull().


Assertion Reference

Common Assertions

Equality

Use Eq() and NotEq() for value equality:

value.Is().Eq(expected);
value.Is().NotEq(unexpected);

Equality uses:

EqualityComparer<T>.Default

The same extensions work as guards:

mode.Guard().NotEq(Mode.Invalid);

Boolean values

Boolean assertions use the shortened True() and False() members:

isValid.Is().True();
hasErrors.Is().False();

They also work with Guard():

isSupported.Guard().True("The requested operation is not supported.");

Arbitrary conditions

Satisfy() has two overloads.

Already evaluated condition

Use the Boolean overload for an arbitrary condition that is already available at the call site:

facet.Is()
      .Satisfy( facet.loops.Count == 0 ||
                facet.loops[^1] <= facet.outputVertexNos.Count);

The condition expression is captured by the compiler through CallerArgumentExpression, so a failure can report the original expression.

Condition 'facet.loops.Count == 0 || facet.loops[^1] <= facet.outputVertexNos.Count' was not satisfied.

This overload does not require a delegate.

Predicate over the current subject

Use the predicate overload when the condition conceptually belongs to the current subject or should participate naturally in a fluent chain:

facet.Is().Satisfy(
    static f => f.loops.Count == 0 ||
                f.loops[^1] <= f.outputVertexNos.Count);

This becomes especially useful after type or null refinement:

string? text = GetText();

text.Is()
    .NotNull()
    .Satisfy(static value => value.Length >= 4);

After NotNull(), the predicate receives a non-null string.

For predicates that do not need external state, prefer a static lambda:

value.Is().Satisfy(static x => x > 0);

A capturing lambda can allocate a closure:

value.Is().Satisfy(x => x > minimum);

If the condition can be written directly without losing clarity, the Boolean overload avoids that delegate/closure concern:

value.Is().Satisfy(value > minimum);

For frequently reused rules, prefer a dedicated assertion extension over Satisfy().


Nullability

Fluent null assertions

Reference types can be checked with NotNull() and Null():

string? value = GetValue();

value.Is().NotNull();
value.Is().Null();

NotNull() refines:

Assertion<string?, TPolicy>

to:

Assertion<string, TPolicy>

so subsequent extensions operate on the non-null type:

value.Is()
     .NotNull()
     .NotEmpty();

Nullable value types work the same way:

int? value = GetValue();

value.Is()
     .NotNull()
     .Greater(0);

After NotNull(), the assertion contains an int rather than int?.

Flow-analysis-aware null checks

In addition to the fluent null assertion, the library provides two direct null-check entry points:

value.AsNotNull();
value.AsGuardNotNull();

Both exist for nullable reference types and nullable value types.

These direct methods additionally annotate the input with [NotNull]. This allows C# nullable flow analysis to understand that the original variable is non-null after a successful call.

For a reference type:

Customer? customer = GetCustomer();

customer.AsGuardNotNull();

Handle(customer); // customer is known to be non-null

For a nullable value type:

int? count = GetCount();

count.AsGuardNotNull();

var value = count.Value;

The direct methods differ only in failure semantics:

Entry point Intended use Failure
AsNotNull() Runtime assertion / invariant AssertException
AsGuardNotNull() Argument / precondition ArgumentNullException

Both return the validated non-null value directly. To start a fluent chain instead, refine through Is()/Guard() first:

string? name = GetName();

name.Guard().NotNullOrEmpty();

They can also be used directly inside expressions:

Process(name.AsGuardNotNull());

Terminal forms return the validated value directly:

string v = name.AsNotNullOrWhiteSpace();
string w = name.AsNotEmpty();

Use the direct variants when the nullable state of the original variable matters after the call.

Use the fluent Is().NotNull() or Guard().NotNull() form when the refined assertion value is consumed directly or when the null check is naturally part of a chain.


Numeric Comparisons

Comparison assertions use generic math operator interfaces.

value.Is().Greater(10);
value.Is().GreaterEq(10);
value.Is().Less(100);
value.Is().LessEq(100);

Ranges are inclusive:

value.Is().Range(0, 100);

Equivalent condition:

0 <= value <= 100

The same assertions can be used as guards:

percentage.Guard().Range(0, 100);

A failed comparison or range guard results in an ArgumentOutOfRangeException.


Floating-Point Assertions

Floating-point values should often be compared using a tolerance rather than exact equality.

Use Approx():

double result = Calculate();

result.Is().Approx(
    expected: 10.0,
    tolerance: 0.0001);

The check is based on the absolute difference:

|actual - expected| <= tolerance

Example:

0.30000000000000004
    .Is()
    .Approx(0.3, 1e-12);

Negative or NaN tolerances are invalid and result in an ArgumentOutOfRangeException.


Strings

Check for null or empty:

string? name = GetName();

name.Is().NotNullOrEmpty();

Check for null, empty, or whitespace:

name.Is().NotNullOrWhiteSpace();

After either check, the returned assertion contains a non-null string.

This allows further chaining:

name.Is()
    .NotNullOrWhiteSpace()
    .NotEmpty();

For argument validation:

public User(string? name)
{
    Name = name.Guard()
               .NotNullOrWhiteSpace();
}

If the original name variable must also be considered non-null afterwards, use AsGuardNotNull() first:

name.Guard().NotNullOrEmpty();

GUIDs

The current GUID-specific API uses NotEmpty():

id.Guard().NotEmpty();

For nullable GUIDs:

Guid? id = GetId();

id.Is().NotNullOrEmpty();

The nullable form refines the assertion to:

Assertion<Guid, TPolicy>

Collections

Require at least one element:

items.Is().NotEmpty();

Require an exact count:

items.Is().Count(3);

Arrays additionally support exact length checks:

buffer.Is().Length(1024);

The built-in collection assertions operate on ICollection or arrays and use Count/Length directly.


Paths, URIs, Mail Addresses, and GUIDs

Strings parse into a domain value before checks run, so file semantics never leak onto a plain string. Is().X() entries return the chainable assertion; Is().AsX() extracts the raw value terminally (Guid g = sample.Is().AsGuid()):

trackingId.Is().Guid().NotEmpty();
path.Is().File().Exists().HaveExtension(".json");
url.Guard().Uri().Absolute().HaveScheme("https");
mail.Is().MailAddress().HaveHost("contoso.com");

File()/Directory() refine to FilePath/DirectoryPath, which also forward a curated set of members to System.IO.File/Directory, so asserting and working share one chain (file.ReadAllText(), dir.GetFiles()).

Enums

color.Is().Defined();
perms.Is().Flagged(Perm.Read);
Color c = "Red".Is().AsEnum<Color>();
"Red".Is().Enum<Color>().Defined();
Color d = 2.Is().AsEnum<Color>();

Defined() checks named/integer definedness, Flagged() a plain bit test (no [Flags] requirement). Enum<T>()/AsEnum<T>() exist for string (with ignoreCase, shape check only) and int; the int pair always requires a defined value. Guard side: GuardEnum<T>()/AsGuardEnum<T>() (a TPolicy-generic .Enum<Color>() cannot compile — partial type arguments are illegal).

Runtime Type Assertions

Runtime type checks can refine the assertion type through OfType<T>().

object value = GetValue();

value.Is().OfType<MyType>();

After the check, the returned assertion contains MyType rather than object.

This allows type-specific assertions without an explicit cast:

value.Is()
     .OfType<MyType>()
     .Satisfy(static typed => typed.IsValid);

The original AssertionContext is preserved during the refinement.


Optional Failure Messages

Assertions provide meaningful default failure messages, but each assertion also accepts an optional custom message.

Typical default messages include:

Expected '10', but found '5'.
Value must be greater than '0'.
String must not be null.
Expected count '3', but found '2'.

A custom message can provide domain-specific context:

count.Is().Greater(
    0,
    "A mesh must contain at least one vertex.");

or for a guard:

retryCount.Guard().Range(
    0,
    10,
    "Retry count is outside the supported range.");

Prefer messages that explain why the invariant exists, rather than merely restating the assertion.

Good:

A mesh must contain at least one vertex.

Less useful:

Count must be greater than zero.

The latter information is already represented by the assertion itself.

Performance note for custom messages

A string literal does not require building a new message on every call:

value.Is().Greater(0, "Value must be positive.");

However, a caller-side interpolated message is evaluated before the assertion method is entered:

value.Is().Greater(
    0,
    $"Value {value} for item {itemId} must be positive.");

That message may therefore allocate even when the assertion succeeds.

The built-in default diagnostic strings are created only in the failure branch.


Diagnostics and Testing

Caller Information and Debugging

Is(), Guard(), AsNotNull(), and AsGuardNotNull() capture source information automatically.

The captured AssertionContext contains:

Expression
MemberName
FilePath
LineNumber

A failure can therefore include diagnostics such as:

Expression: mesh.Vertices.Count
Member: ProcessMesh
Source: C:\Projects\App\MeshProcessor.cs(142)

Caller information is provided by compiler attributes:

CallerArgumentExpression
CallerMemberName
CallerFilePath
CallerLineNumber

This does not require reflection, stack walking, or source-code parsing.

The assertion implementation also uses debugger/runtime attributes such as:

[DebuggerStepThrough]
[StackTraceHidden]
[DoesNotReturn]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[MethodImpl(MethodImplOptions.NoInlining)]

The intended split is:

successful / hot path
    → small assertion methods
    → aggressive inlining
    → debugger steps through infrastructure

failure / cold path
    → policy failure methods
    → no inlining
    → internal frames hidden from formatted stack traces

Test Framework Integration

AssertException implements the marker interface:

IAssertionException

The interface intentionally has no members.

This allows compatible test frameworks such as xUnit.net v3 to recognize AssertException as an assertion failure without requiring the core library to reference xUnit.

A test can therefore use the normal Is() entry point:

[Fact]
public void Result_is_valid()
{
    var result = Calculate();
    result.Is().Eq(42);
    
    //or
    result = Calculate().Is().Eq(42)
                        .Value;
    
}

A failed Is() check produces AssertException, while Guard() intentionally continues to produce normal .NET argument exceptions.

No xUnit-specific policy is required for the built-in Is() behavior.

A custom policy can still be created when another test framework or environment requires different failure semantics.


Architecture and Extending

Architecture

The core architecture consists of four small pieces:

             AssertionContext
                    │
                    ▼
          Assertion<T, TPolicy>
                    │
          ┌─────────┴─────────┐
          │                   │
   Assertion extensions     TPolicy
          │                   │
 Eq / Greater / ...      failure behavior
                              │
                    ┌─────────┴──────────┐
                    │                    │
                 IsPolicy           GuardPolicy

The assertion extension contains the condition.

AssertionContext contains compiler-provided diagnostic information.

The policy contains the failure behavior.

For example, Greater(0) does not need to know whether it is being used as:

value.Is().Greater(0);

or:

value.Guard().Greater(0);

The extension only evaluates the rule and delegates failures to TPolicy.


Assertion<T, TPolicy>

The assertion wrapper is a readonly struct.

It contains:

subject
AssertionContext
exceptionFactory (private OnFailure override)

The public 2-arg ctor leaves the factory empty; refinements must use assertion.Refine(value).

and exposes:

Value
Context
Expression

It also supports implicit conversion back to T:

Process(value.Guard().Greater(0));

Its public constructor forms the minimal extension surface for type-refining custom assertions. Prefer assertion.Refine(refinedValue), which additionally preserves a configured OnFailure factory:

return assertion.Refine(refinedValue);

No builder object or policy instance is required.


AssertionContext

AssertionContext is a readonly struct containing the call-site information captured at the assertion entry point:

public readonly struct AssertionContext
{
    public AssertionContext(
        string? expression,
        string? memberName,
        string? filePath,
        int lineNumber);
    public string? Expression { get; }
    public string? MemberName { get; }
    public string? FilePath { get; }
    public int LineNumber { get; }
}

Custom assertion extensions should preserve assertion.Context when returning the original assertion or refining it to another type.

Do not replace the context with a new empty context unless losing the original call-site information is intentional.


Assertion Policies

Policies implement:

IAssertionPolicy

The policy interface distinguishes three failure categories:

using System.Diagnostics.CodeAnalysis;

public interface IAssertionPolicy
{
    [DoesNotReturn]
    static abstract void Fail(
        AssertionContext context,
        string message);

    [DoesNotReturn]
    static abstract void FailNull(
        AssertionContext context,
        string message);

    [DoesNotReturn]
    static abstract void FailOutOfRange<T>(
        AssertionContext context,
        T actualValue,
        string message);
}

The built-in policies are:

IsPolicy
GuardPolicy

IsPolicy maps every assertion failure to:

AssertException

GuardPolicy maps failures onto the standard .NET argument exception hierarchy:

Fail(...)
    → ArgumentException

FailNull(...)
    → ArgumentNullException

FailOutOfRange(...)
    → ArgumentOutOfRangeException

Because policy methods are static abstract members and policies are value types, no policy object needs to be allocated or virtually dispatched.


Implementing a Custom Policy

Create a custom policy when the failure semantics need to change.

Examples include domain-specific exceptions, protocol validation, parser failures, or integration with another test framework.

The snippets below assume using System.Diagnostics;, using System.Runtime.CompilerServices;, and a project-defined exception type.

public readonly struct DomainPolicy : IAssertionPolicy
{
    [DoesNotReturn]
    [DebuggerStepThrough]
    [StackTraceHidden]
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static void Fail(
        AssertionContext context,
        string message)
    {
        throw new DomainValidationException(
            message,
            context);
    }

    [DoesNotReturn]
    [DebuggerStepThrough]
    [StackTraceHidden]
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static void FailNull(
        AssertionContext context,
        string message)
    {
        throw new DomainValidationException(
            message,
            context);
    }

    [DoesNotReturn]
    [DebuggerStepThrough]
    [StackTraceHidden]
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static void FailOutOfRange<T>(
        AssertionContext context,
        T actualValue,
        string message)
    {
        throw new DomainValidationException(
            $"{message} Actual value: {actualValue}",
            context);
    }
}

The policy should normally remain stateless.


Creating an Entry Point for a Custom Policy

A custom policy normally gets its own small extension method.

Capture the same call-site information as Is() and Guard():

public static class DomainAssertionEntryExtensions
{
    [DebuggerStepThrough]
    [StackTraceHidden]
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static Assertion<T, DomainPolicy> DomainAssert<T>(
        this T subject,
        [CallerArgumentExpression("subject")] string? expression = null,
        [CallerMemberName] string? memberName = null,
        [CallerFilePath] string? filePath = null,
        [CallerLineNumber] int lineNumber = 0)
    {
        return new Assertion<T, DomainPolicy>(
            subject,
            new AssertionContext(
                expression,
                memberName,
                filePath,
                lineNumber));
    }
}

Existing assertions can immediately be reused:

order.Total.DomainAssert()
           .Greater(0);

name.DomainAssert()
    .NotNullOrWhiteSpace();

quantity.DomainAssert()
        .Range(1, 100);

Only the failure policy changes.


Writing Custom Assertion Extensions

Most application-specific behavior should be implemented as an assertion extension, not as a policy.

Use a policy when you want to change:

What happens when a condition fails?

Use an assertion extension when you want to change:

What condition is being checked?

Suppose an application defines:

public readonly record struct Percentage(int Value);

and every percentage must be between 0 and 100.

A custom assertion can be implemented once:

public static class PercentageAssertions
{
    [DebuggerStepThrough]
    [StackTraceHidden]
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static Assertion<Percentage, TPolicy> Valid<TPolicy>(
        this Assertion<Percentage, TPolicy> assertion,
        string? message = null)
        where TPolicy : struct, IAssertionPolicy
    {
        var value = assertion.Value;

        if (value.Value is < 0 or > 100)
        {
            assertion.FailOutOfRange(
                value,
                message ?? "Percentage must be in range [0, 100].");
        }

        return assertion;
    }
}

Usage as an invariant:

percentage.Is().Valid();

and without another implementation as a guard:

percentage.Guard().Valid();

The rule exists only once.


Preserving Assertion Context in Extensions

A custom assertion should preserve the current assertion and its context whenever possible.

The normal pattern is:

public static Assertion<T, TPolicy> MyAssertion<T, TPolicy>(
    this Assertion<T, TPolicy> assertion,
    string? message = null)
    where TPolicy : struct, IAssertionPolicy
{
    if (/* invalid */)
    {
        assertion.Fail( message ?? "Failure message.");
    }

    return assertion;
}

This preserves:

  • the subject
  • the selected policy
  • the original expression
  • member, file, and line information
  • fluent chaining

Do not start a new Is() or Guard() chain inside an assertion extension.

Avoid:

public static Assertion<MyType, TPolicy> Valid<TPolicy>(
    this Assertion<MyType, TPolicy> assertion)
    where TPolicy : struct, IAssertionPolicy
{
    assertion.Value.Is().Satisfy(static v => v.IsValid);

    return assertion;
}

That would create a new IsPolicy assertion and discard the original policy/context semantics.

Instead, evaluate the rule directly and invoke assertion.Fail*(...), which honors OnFailure.


Example: Domain-Specific Extension

Suppose a mesh is considered valid only if it contains vertices.

public static class MeshAssertions
{
    [DebuggerStepThrough]
    [StackTraceHidden]
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static Assertion<Mesh, TPolicy> HasVertices<TPolicy>(
        this Assertion<Mesh, TPolicy> assertion,
        string? message = null)
        where TPolicy : struct, IAssertionPolicy
    {
        var mesh = assertion.Value;

        if (mesh.Vertices.Count == 0)
        {
            assertion.Fail( message ?? "Mesh must contain at least one vertex.");
        }

        return assertion;
    }
}

It can now be used as a runtime assertion:

mesh.Is().HasVertices();

or as a guard:

public void Process(Mesh mesh)
{
    mesh.Guard().HasVertices();

    // ...
}

The selected policy determines the exception type.


Example: Refining the Assertion Type

Extensions can refine the assertion type.

Imagine:

Animal animal = GetAnimal();

A specialized assertion can validate the runtime type and return:

Assertion<Dog, TPolicy>

while preserving the original context:

public static Assertion<Dog, TPolicy> Dog<TPolicy>(
    this Assertion<Animal, TPolicy> assertion,
    string? message = null)
    where TPolicy : struct, IAssertionPolicy
{
    if (assertion.Value is Dog dog)
        return assertion.Refine(dog);

    assertion.Fail( message ?? "Animal must be a dog.");

    return default;
}

Refine() preserves the assertion context, the policy, and a configured OnFailure factory.

This enables:

animal.Is()
      .Dog()
      .Satisfy(static dog => dog.HasValidChip);

without casts in application code.

The built-in OfType<TExpected>() follows the same refinement principle.


Extension Design Guidelines

Keep policies generic

Prefer:

public static Assertion<MyType, TPolicy> Valid<TPolicy>(
    this Assertion<MyType, TPolicy> assertion)
    where TPolicy : struct, IAssertionPolicy

instead of coupling the rule to one policy:

public static Assertion<MyType, IsPolicy> Valid(
    this Assertion<MyType, IsPolicy> assertion)

unless the assertion intentionally only makes sense for one failure model.

Return the assertion

Return the current assertion when the asserted type does not change:

return assertion;

This preserves chaining:

value.Is()
     .Valid()
     .Satisfy(...);

Refine types when validation proves something

If a check proves that:

T?

is actually:

T

return:

Assertion<T, TPolicy>

Likewise for runtime type checks.

When creating the refined wrapper, preserve:

assertion.Context

Use the correct failure category

Use:

assertion.Fail(...)

for general failures.

Use:

assertion.FailNull(...)

when the failure specifically means that a value must not be null.

Use:

assertion.FailOutOfRange(...)

for comparison or range violations.

The distinction matters especially for GuardPolicy, because it selects the matching standard .NET exception type.

Accept an optional custom message

Built-in assertions follow this pattern:

string? message = null

Custom assertions should generally do the same:

public static Assertion<MyType, TPolicy> Valid<TPolicy>(
    this Assertion<MyType, TPolicy> assertion,
    string? message = null)
    where TPolicy : struct, IAssertionPolicy

Create the default message only in the failure branch.

Keep the successful path cheap

Assertions are expected to be used frequently.

Prefer direct checks and early returns.

Avoid unnecessary:

LINQ
closures
reflection
stack walking
temporary collections
exception creation
diagnostic formatting

on the successful path.


Custom Exceptions

For one-off failure behavior, a custom exception factory can be attached to an assertion chain:

value.Is()
     .OnFailure(static failure => new DomainException(failure.Message))
     .Greater(0);

The factory receives an AssertionFailure containing:

  • failure kind (General, Null, OutOfRange)
  • message
  • assertion context
  • actual value (populated only on the OutOfRange path)

Without OnFailure(), the normal assertion policy remains responsible for the exception type.

For reusable failure semantics across many call sites, prefer a custom IAssertionPolicy.

Prefer non-capturing static lambdas when no external state is required. Capturing lambdas may allocate a closure.


Appendix

Performance Model

The library deliberately optimizes the successful path.

Assertion<T, TPolicy> and AssertionContext are readonly struct values.

Policies are stateless structs implementing static abstract interface members.

The normal successful path is conceptually:

value
  ↓
Is() / Guard()
  ↓
construct AssertionContext
  ↓
construct small Assertion<T, TPolicy>
  ↓
evaluate condition
  ↓
return assertion

The core design does not require:

  • policy instances
  • virtual dispatch
  • reflection
  • stack walking
  • exception allocation
  • heap-allocated fluent builder objects

Failure paths are intentionally allowed to perform more work because they already result in an exception.

Default failure-message formatting and caller-context formatting occur on the failure path.

The priority is:

successful assertion
    → minimal overhead

failed assertion
    → maximum diagnostic quality

Predicate assertions

The predicate overload of Satisfy() takes a Func<T, bool>.

A non-capturing static lambda is the preferred form when possible:

value.Is().Satisfy(static value => value > 0);

A capturing lambda can require a closure allocation:

value.Is().Satisfy(value => value > minimum);

When external state is simple and the expression remains readable, the Boolean overload can be the cheaper choice:

value.Is().Satisfy(value > minimum);

Choosing Between Specialized Assertions and Satisfy()

For one-off conditions, Satisfy() is useful:

mesh.Is().Satisfy(mesh.Vertices.Count > 0);

or:

mesh.Is().Satisfy(static mesh => mesh.Vertices.Count > 0);

For reusable domain rules, prefer an extension:

mesh.Is().HaveVertices();

A dedicated extension:

HaveVertices()
    ├── communicates intent
    ├── centralizes the rule
    ├── provides a focused failure message
    ├── works with Is()
    ├── works with Guard()
    └── works with custom policies

Recommended Usage

Use Guard() at API boundaries:

public Mesh Transform(
    Mesh? mesh,
    Matrix4x4 transform,
    float tolerance)
{
    mesh.AsGuardNotNull();
    tolerance.Guard().Greater(0);

    // mesh is known to be non-null here
    // ...
}

Use Is() for internal assumptions and invariants:

var result = ComputeResult();

result.AsNotNull();
result.Vertices.Is().NotEmpty();

Use fluent null refinement when the validated value stays inside the chain:

GetOptionalName()
    .Is()
    .NotNull()
    .NotEmpty();

Create domain-specific extensions for repeated rules:

mesh.Guard()
    .HaveVertices()
    .Processable();

Create a custom policy only when the failure semantics need to change.


API Overview

Category Assertions
Entry points Is(), Guard()
Flow-aware null entry points AsNotNull(), AsGuardNotNull(), AsNotNullOrEmpty(), AsNotNullOrWhiteSpace(), AsNotEmpty(), AsGuardNotNullOrEmpty(), AsGuardNotNullOrWhiteSpace(), AsGuardNotEmpty()
Equality Eq(), NotEq()
Boolean True(), False()
Arbitrary condition Satisfy(bool), Satisfy(Func<T, bool>)
Nullability NotNull(), Null()
Comparison Greater(), GreaterEq(), Less(), LessEq(), Range()
Numbers Positive(), NonNegative(), Negative(), Zero(), NonZero(), Even(), Odd(), DivisibleBy()
Floating point Approx(), NotNaN(), Finite(), Infinite(), PositiveInfinity(), NegativeInfinity()
Strings NotNullOrEmpty(), NotNullOrWhiteSpace(), NotEmpty(), Length(), MinLength(), MaxLength(), LengthInRange(), Contains(), StartsWith(), EndsWith(), Matches()
Collections NotEmpty(), Count(), Length(), MinCount(), MaxCount(), CountInRange(), MinLength(), MaxLength(), LengthInRange(), Contains()
Dictionaries ContainsKey(), NotContainsKey()
Date and time Kind(), Utc(), After(), Before(), NotBefore(), NotAfter() (DateTime); Positive(), NonNegative(), Zero(), WithinTimeout() (TimeSpan); Today(), Weekday() (DateOnly); Utc(), HaveOffset() (DateTimeOffset)
GUID Guid(), AsGuid(), NotEmpty(), NotNullOrEmpty()
Characters IsLetter(), IsDigit(), IsWhiteSpace(), IsUpper(), IsLower()
Paths AsFile(), AsDirectory(), File(), Directory(), Exists(), NotExists(), HaveExtension(), NoExtension(), HaveFileName(), HaveName(), Absolute(), HaveFullPath(), InDirectory(), Empty(), NotEmpty(), Length(), MinLength(), MaxLength(), LengthInRange(), ContainsFile(), ContainsDirectory()
URIs AsUri(), Uri(), Absolute(), HaveScheme(), HaveHost(), HavePort(), Loopback()
Network Loopback(), IPv4(), IPv6() (IPAddress); HavePort(), Loopback() (IPEndPoint); AsMailAddress(), MailAddress(), HaveHost(), HaveUser() (MailAddress)
Enums Defined(), Flagged(), Enum(), AsEnum(), GuardEnum(), AsGuardEnum()
Runtime type OfType<T>()

All assertion methods support the selected TPolicy, so the same rule can normally be used through Is(), Guard(), or a custom policy entry point.


Summary

The core idea of ValidSphere is simple:

value.Is().Valid();
value.Guard().Valid();

The validation rule is implemented once.

The policy decides how failure is represented.

Assertion extension
        │
        │ describes
        ▼
   What is valid?
        │
        ▼
      TPolicy
        │
        │ describes
        ▼
 What happens on failure?

AssertionContext carries compiler-provided call-site information through the chain.

The result is an assertion API that is:

  • composable
  • reusable
  • cheap on the successful path
  • easy to debug
  • nullable-flow-aware where needed
  • independent from a particular exception model
  • extensible for application-specific rules
  • usable both for runtime invariants and API guards
  • usable directly in xUnit.net v3 through AssertException

About

Lightweight runtime guard and invariant library for .NET

Resources

Readme
MIT license
Activity

Stars

1 star

Watchers

0 watching

Forks

0 forks
Report repository

Releases

Packages

Used by

Contributors

Languages

You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.