Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions Light.GuardClauses.SingleFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2083,6 +2083,63 @@ public static ReadOnlyMemory<byte> MustBeAscii(this ReadOnlyMemory<byte> paramet
return parameter;
}

/// <summary>
/// Ensures that values of <paramref name = "parameter"/> can be assigned to variables of
/// <paramref name = "requiredType"/>, or otherwise throws an <see cref = "ArgumentException"/>.
/// </summary>
/// <param name = "parameter">The candidate type to be checked.</param>
/// <param name = "requiredType">The type to which values of the candidate type must be assignable.</param>
/// <param name = "parameterName">The name of the parameter (optional).</param>
/// <param name = "message">The message that will be passed to the resulting exception (optional).</param>
/// <returns>The original candidate type.</returns>
/// <exception cref = "ArgumentException">
/// Thrown when values of <paramref name = "parameter"/> cannot be assigned to variables of
/// <paramref name = "requiredType"/>.
/// </exception>
/// <exception cref = "ArgumentNullException">
/// Thrown when <paramref name = "parameter"/> or <paramref name = "requiredType"/> is null.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt")]
public static Type MustBeAssignableTo([NotNull][ValidatedNotNull] this Type? parameter, [NotNull][ValidatedNotNull] Type? requiredType, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
{
parameter.MustNotBeNull(parameterName, message);
requiredType.MustNotBeNull(nameof(requiredType), message);
if (!requiredType.IsAssignableFrom(parameter))
{
Throw.MustBeAssignableTo(parameter, requiredType, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that values of <paramref name = "parameter"/> can be assigned to variables of
/// <paramref name = "requiredType"/>, or otherwise throws your custom exception.
/// </summary>
/// <param name = "parameter">The candidate type to be checked.</param>
/// <param name = "requiredType">The type to which values of the candidate type must be assignable.</param>
/// <param name = "exceptionFactory">
/// The delegate that creates your custom exception. The original candidate and required types are passed to this
/// delegate.
/// </param>
/// <returns>The original candidate type.</returns>
/// <exception cref = "Exception">
/// Your custom exception thrown when either type is null or the candidate type is not assignable to the required
/// type.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt; exceptionFactory:null => halt")]
public static Type MustBeAssignableTo([NotNull][ValidatedNotNull] this Type? parameter, [NotNull][ValidatedNotNull] Type? requiredType, Func<Type?, Type?, Exception> exceptionFactory)
{
if (parameter is null || requiredType is null || !requiredType.IsAssignableFrom(parameter))
{
Throw.CustomException(exceptionFactory, parameter, requiredType);
}

return parameter;
}

/// <summary>
/// Ensures that the string is standard Base64 with valid padding. Space, tab, carriage return, and line feed are ignored.
/// Empty and whitespace-only strings are valid.
Expand Down Expand Up @@ -11800,6 +11857,13 @@ public static void InvalidMinimumImmutableArrayLength<T>(ImmutableArray<T> param
[DoesNotReturn]
public static void MustBeApproximately<T>(T parameter, T other, T tolerance, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new ArgumentOutOfRangeException(parameterName, message ?? $"{parameterName ?? "The value"} must be approximately equal to {other} with a tolerance of {tolerance}, but it actually is {parameter}.");
/// <summary>
/// Throws the default <see cref = "ArgumentException"/> indicating that values of the candidate type cannot be
/// assigned to variables of the required type, using the optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBeAssignableTo(Type parameter, Type requiredType, string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"Values of type \"{parameter}\" must be assignable to variables of type \"{requiredType}\", but they are not.", parameterName);
/// <summary>
/// Throws the default <see cref = "ArgumentOutOfRangeException"/> indicating that a comparable value must be greater
/// than the given boundary value, using the optional parameter name and message.
/// </summary>
Expand Down
44 changes: 44 additions & 0 deletions ai-plans/0167-type-assignability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Type-Assignability Assertion

## Rationale

Parent issue #162 (point 3) identifies six BrilliantMessaging guards that reject a `Type` unless it can be assigned to a required base type or interface. The existing type-relation predicates do not provide a direct throwing assertion, so callers must translate the relationship into a boolean condition and choose the correct comparison direction themselves.

Add `MustBeAssignableTo` with the exact semantics of `Type.IsAssignableFrom`, expressed in the fluent direction `candidateType.MustBeAssignableTo(requiredType)`, and keep it available on `netstandard2.0`. Do not add `MustImplement`: the assignability guard already covers interfaces, while a second API would duplicate behavior and risk suggesting the open-generic equivalence semantics of the existing `Implements` predicate.

## Acceptance Criteria

- [x] `candidateType.MustBeAssignableTo(requiredType, parameterName, message)` returns the original candidate `Type` when `requiredType.IsAssignableFrom(candidateType)` is true and throws `ArgumentException` when it is false, identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; the exception exposes the candidate parameter name and honors an optional custom message.
- [x] The default overload throws `ArgumentNullException` when either the candidate or required type is null, attributing a null candidate to the caller-captured parameter name and a null required type to `requiredType`; no new public exception type is introduced.
- [x] A custom-exception-factory overload accepts `Func<Type?, Type?, Exception>`, passes the original candidate and required types to the factory, invokes it only when either input is null or the assignability check fails, and a null factory on a failing check throws `ArgumentNullException` via the existing `Throw.CustomException` convention.
- [x] Automated tests cover identity, direct and indirect base-class relationships, interface implementation, value types, variant generics, and representative open-generic BCL semantics; reversed and unrelated failure cases; both null inputs; return-value identity; parameter-name and custom-message propagation; the factory arguments and concrete exception; no factory invocation on success; null-factory behavior; and nullable-flow analysis.
- [x] No `MustImplement` convenience assertion is added; interface assignability is documented and tested through `MustBeAssignableTo`.
- [x] The source-export whitelist catalog and committed settings contain `MustBeAssignableTo`, and focused source-export tests cover retention of the guard, its throw helper, and the two-argument custom-exception helper as well as trimming of the exception-factory overload when configured.
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the assertion and validates for both supported source-export targets.
- [x] The type-relation assertion documentation lists `MustBeAssignableTo`, and the package release notes mention the new guard.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Add `Check.MustBeAssignableTo.cs` using the conventions of the other value-returning guards (aggressive inlining, JetBrains contract annotations, nullable flow annotations, caller-argument-expression capture, and XML documentation). The exact public shape is:

```csharp
public static Type MustBeAssignableTo(
[NotNull] [ValidatedNotNull] this Type? parameter,
[NotNull] [ValidatedNotNull] Type? requiredType,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
);

public static Type MustBeAssignableTo(
[NotNull] [ValidatedNotNull] this Type? parameter,
[NotNull] [ValidatedNotNull] Type? requiredType,
Func<Type?, Type?, Exception> exceptionFactory
);
```

The check must evaluate `requiredType.IsAssignableFrom(parameter)`. This makes the direction explicit: after success, a value whose runtime type is `parameter` can be stored in a variable declared as `requiredType`. Use the BCL behavior verbatim, including equality, inheritance, interface implementation, array compatibility, and generic variance; do not route through `InheritsFrom`, `IsOrInheritsFrom`, or `IsEquivalentTypeTo`, whose constructed/open-generic handling is intentionally different. No generic `MustBeAssignableTo<T>` overload is included because the motivating scenario supplies the required type at runtime.

Route default relation failures through a non-returning `Throw.MustBeAssignableTo` helper that constructs `ArgumentException`. Its default message should state both types and the assignment direction without implying that the `Type` object itself failed a CLR cast. The default overload validates both type arguments before evaluating assignability; the factory overload treats either null as a validation failure and passes both original values to the factory. No trimming annotations or microbenchmarks are required because `Type.IsAssignableFrom` does not enumerate reflected members and this guard is a thin wrapper around the BCL operation.

Register `MustBeAssignableTo` in `AssertionWhitelist` and `settings.json`, add focused `SourceFileMergerWhitelistTests`, add `MustBeAssignableToTests` under the type assertions, extend the value-returning assertion coverage in `Issue72NotNullAttributeTests`, update the type-relation table in `docs/assertion-overview.md`, and regenerate `Light.GuardClauses.SingleFile.cs` through the source-export tool's committed settings.
5 changes: 4 additions & 1 deletion docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,16 @@ while preserving its concrete stream type, and all three guards support custom m

| Assertion | Behavior |
| --- | --- |
| `MustBeAssignableTo` | Requires CLR assignability from a candidate type to a required base type or interface |
| `IsEquivalentTypeTo` | Treats equal types and constructed-generic/definition pairs as equivalent |
| `Implements`, `IsOrImplements` | Test interface implementation, optionally allowing equality |
| `DerivesFrom`, `IsOrDerivesFrom` | Test base-class derivation, optionally allowing equality |
| `InheritsFrom`, `IsOrInheritsFrom` | Test derivation or interface implementation, optionally allowing equality |
| `IsOpenConstructedGenericType` | Tests for a constructed generic type that still has open parameters |

The relation methods provide comparer overloads where applicable.
`MustBeAssignableTo` uses `requiredType.IsAssignableFrom(candidateType)` directly, so it covers interface
assignability, generic variance, and the BCL's open-generic behavior. The other relation methods provide comparer
overloads where applicable.

## URI assertions

Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Light.GuardClauses 15.1.0
--------------------------------

- new assertions: MustBeUri, ObjectDisposed
- new assertions: MustBeAssignableTo, MustBeUri, ObjectDisposed
</PackageReleaseNotes>
</PropertyGroup>

Expand Down
79 changes: 79 additions & 0 deletions src/Light.GuardClauses/Check.MustBeAssignableTo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using Light.GuardClauses.ExceptionFactory;
using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute;

namespace Light.GuardClauses;

public static partial class Check
{
/// <summary>
/// Ensures that values of <paramref name="parameter" /> can be assigned to variables of
/// <paramref name="requiredType" />, or otherwise throws an <see cref="ArgumentException" />.
/// </summary>
/// <param name="parameter">The candidate type to be checked.</param>
/// <param name="requiredType">The type to which values of the candidate type must be assignable.</param>
/// <param name="parameterName">The name of the parameter (optional).</param>
/// <param name="message">The message that will be passed to the resulting exception (optional).</param>
/// <returns>The original candidate type.</returns>
/// <exception cref="ArgumentException">
/// Thrown when values of <paramref name="parameter" /> cannot be assigned to variables of
/// <paramref name="requiredType" />.
/// </exception>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="parameter" /> or <paramref name="requiredType" /> is null.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt")]
public static Type MustBeAssignableTo(
[NotNull] [ValidatedNotNull] this Type? parameter,
[NotNull] [ValidatedNotNull] Type? requiredType,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
)
{
parameter.MustNotBeNull(parameterName, message);
requiredType.MustNotBeNull(nameof(requiredType), message);

if (!requiredType.IsAssignableFrom(parameter))
{
Throw.MustBeAssignableTo(parameter, requiredType, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that values of <paramref name="parameter" /> can be assigned to variables of
/// <paramref name="requiredType" />, or otherwise throws your custom exception.
/// </summary>
/// <param name="parameter">The candidate type to be checked.</param>
/// <param name="requiredType">The type to which values of the candidate type must be assignable.</param>
/// <param name="exceptionFactory">
/// The delegate that creates your custom exception. The original candidate and required types are passed to this
/// delegate.
/// </param>
/// <returns>The original candidate type.</returns>
/// <exception cref="Exception">
/// Your custom exception thrown when either type is null or the candidate type is not assignable to the required
/// type.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation(
"parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt; exceptionFactory:null => halt"
)]
public static Type MustBeAssignableTo(
[NotNull] [ValidatedNotNull] this Type? parameter,
[NotNull] [ValidatedNotNull] Type? requiredType,
Func<Type?, Type?, Exception> exceptionFactory
)
{
if (parameter is null || requiredType is null || !requiredType.IsAssignableFrom(parameter))
{
Throw.CustomException(exceptionFactory, parameter, requiredType);
}

return parameter;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;

namespace Light.GuardClauses.ExceptionFactory;

public static partial class Throw
{
/// <summary>
/// Throws the default <see cref="ArgumentException" /> indicating that values of the candidate type cannot be
/// assigned to variables of the required type, using the optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBeAssignableTo(
Type parameter,
Type requiredType,
string? parameterName = null,
string? message = null
) =>
throw new ArgumentException(
message ??
$"Values of type \"{parameter}\" must be assignable to variables of type \"{requiredType}\", but they are not.",
parameterName
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,51 @@ public static void ObjectDisposedWhitelistExportsGuardThrowHelperAndExceptionFac
sourceCode.Should().NotContain("public static void InvalidOperation(");
}

[Fact]
public static void MustBeAssignableToWhitelistExportsGuardThrowHelperAndFactoryOnBothTargets()
{
using var temporaryDirectory = new TemporaryDirectory();
var portableFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToPortable.cs");
var modernFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToModern.cs");
var whitelist = CreateWhitelist(
includedAssertions: [new ("MustBeAssignableTo", true)]
);

SourceFileMerger.CreateSingleSourceFile(CreateOptions(portableFile, whitelist));
SourceFileMerger.CreateSingleSourceFile(
CreateOptions(modernFile, whitelist, SourceTargetFramework.Net10_0)
);

foreach (var sourceCode in new[] { File.ReadAllText(portableFile), File.ReadAllText(modernFile) })
{
sourceCode.Should().Contain("public static Type MustBeAssignableTo(");
sourceCode.Should().Contain("public static void MustBeAssignableTo(");
sourceCode.Should().Contain("Func<Type?, Type?, Exception> exceptionFactory");
sourceCode.Should().Contain("public static void CustomException<T1, T2>(");
sourceCode.Should().NotContain("public static bool IsEquivalentTypeTo(");
}
}

[Fact]
public static void MustBeAssignableToWhitelistTrimsExceptionFactoryOverload()
{
using var temporaryDirectory = new TemporaryDirectory();
var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToWithoutFactory.cs");

SourceFileMerger.CreateSingleSourceFile(
CreateOptions(
targetFile,
CreateWhitelist(includedAssertions: [new ("MustBeAssignableTo", false)])
)
);
var sourceCode = File.ReadAllText(targetFile);

sourceCode.Should().Contain("public static Type MustBeAssignableTo(");
sourceCode.Should().Contain("public static void MustBeAssignableTo(");
sourceCode.Should().NotContain("Func<Type?, Type?, Exception> exceptionFactory");
sourceCode.Should().NotContain("public static void CustomException<T1, T2>(");
}

[Fact]
public static void MustBeUriWhitelistExportsGuardThrowHelperExceptionAndFactories()
{
Expand Down
Loading
Loading