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
Line-of-Business code constantly guards lookup structures — settings dictionaries, translation tables, header maps — but MustContain and MustNotContain operate on collection items and substrings, so there is no assertion that targets dictionary keys. Versions before the v4 rewrite shipped MustContainKey and MustNotContainKey in the removed DictionaryAssertions class; reintroduce these two guards with the library's modern conventions. The broader legacy family (MustContainValue, MustContainPair, MustBeKeyOf, plural-keys overloads) is deliberately not resurrected: value and pair lookups are O(n) and rarely precondition-shaped, and they can be added later on demand.
The additions must preserve the library's fluent return values, exception-factory overloads, nullable annotations, broad target-framework support, Native AOT compatibility, and the customizable single-file source distribution. Key lookups must use ContainsKey so they never enumerate and honor the dictionary's own key comparer.
Acceptance Criteria
MustContainKey and MustNotContainKey are available for IReadOnlyDictionary<TKey, TValue> receivers on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 with identical semantics, and both type arguments are inferred at the call site without explicit specification.
Additional overloads for Dictionary<TKey, TValue> preserve and return the concrete dictionary shape, while receivers of other dictionary types implementing IReadOnlyDictionary<TKey, TValue> (such as ConcurrentDictionary, SortedDictionary, ReadOnlyDictionary, ImmutableDictionary, and FrozenDictionary on .NET 10) bind to the interface overloads without overload-resolution ambiguity.
The guards check key presence exclusively through ContainsKey, so they never enumerate the dictionary and respect its configured key comparer.
A null dictionary thrown into the default overloads produces the established ArgumentNullException null behavior; the exception-factory overloads pass the dictionary and the key to the factory.
A failed MustContainKey throws the new MissingKeyException and a failed MustNotContainKey throws the new ExistingKeyException; both derive from CollectionException, report the offending key, and follow the existing serialization conventions.
Every new guard returns the successfully validated input, captures the guarded expression for the default exception, accepts an optional custom message, and provides an exception-factory overload consistent with the existing API.
Automated tests cover present and absent keys, null dictionaries, comparer-sensitive lookups (for example an OrdinalIgnoreCase dictionary), default exceptions, custom messages, custom exception factories, caller argument expressions, returned values including the preserved Dictionary<TKey, TValue> shape, and successful binding for several dictionary types.
The source-export whitelist catalog and committed settings contain the two new assertion families with their exceptions and throw helpers, and focused source-export tests cover the new entries.
The committed .NET Standard 2.0 single-file distribution is regenerated and validates with the new portable API surface.
The assertion overview documents the new guards, including the shapes they accept and the IDictionary<TKey, TValue>-only limitation described below.
The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.
Technical Details
One Check.<Assertion>.cs file per family. The MustContain-style shape preservation via a TCollection type parameter is impossible here: C# type inference does not consult generic constraints, so a TDictionary : class, IReadOnlyDictionary<TKey, TValue> constraint leaves TValue non-inferable and would force explicit type arguments at every call site. The receivers are therefore interface-typed, with TKey and TValue inferred from the receiver conversion (exact signatures):
MustNotContainKey mirrors these shapes, and the Dictionary<TKey, TValue> overloads also get factory variants. The concrete overloads exist because the interface overloads erase the shape in fluent chains (_map = map.MustNotBeNull().MustContainKey("endpoint"); must keep compiling when map and _map are Dictionary<TKey, TValue>); they coexist safely with the interface overloads because an identity conversion beats an interface conversion in overload resolution. The notnull constraint matches the BCL's Dictionary<TKey, TValue> annotation and avoids nullability warnings in the implementation.
Why IReadOnlyDictionary and not IDictionary (or both). A guard only reads, and every current BCL dictionary type implements IReadOnlyDictionary<TKey, TValue>. Offering overloads for both interfaces would make every call on a concrete dictionary type other than Dictionary<TKey, TValue> ambiguous (CS0121), because such types convert to both interfaces and neither conversion is better. Accepted limitation: a receiver statically typed as IDictionary<TKey, TValue> cannot use the guards; document dictionary.Keys.MustContain(key) as the workaround (the key collections of the BCL dictionaries implement ICollection<TKey>.Contains via ContainsKey, so this stays O(1)).
The key is passed to ContainsKey unmodified; dictionaries that reject null keys surface their own ArgumentNullException, and the guards add no redundant key-null check. Follow MustContain's null handling for the dictionary itself: [NotNull, ValidatedNotNull], [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")], and the MustNotBeNull path in the default overloads.
Exceptions. Add MissingKeyException and ExistingKeyException deriving from CollectionException, mirroring MissingItemException/ExistingItemException including the #if !NET8_0_OR_GREATER serialization constructor. Add Throw.MissingKey and Throw.ExistingKey helpers following the Throw.MissingItem message conventions — for example "{parameterName ?? "The dictionary"} must contain key {key.ToStringOrNull()}, but it actually does not." — appending the dictionary's keys (not its key-value pairs) via the existing collection-content helper for the missing-key case.
Update AssertionWhitelist, settings.json, and the source-export whitelist tests for the two new families, ensure the new exceptions and throw helpers are reachable in the export, regenerate Light.GuardClauses.SingleFile.cs as the .NET Standard 2.0 output, and verify both portable and .NET 10 generated-source validation. Document the new guards in the collections section of docs/assertion-overview.md. No microbenchmarks are needed: the guards perform a single hash or tree lookup.
Rationale
Line-of-Business code constantly guards lookup structures — settings dictionaries, translation tables, header maps — but
MustContainandMustNotContainoperate on collection items and substrings, so there is no assertion that targets dictionary keys. Versions before the v4 rewrite shippedMustContainKeyandMustNotContainKeyin the removedDictionaryAssertionsclass; reintroduce these two guards with the library's modern conventions. The broader legacy family (MustContainValue,MustContainPair,MustBeKeyOf, plural-keys overloads) is deliberately not resurrected: value and pair lookups are O(n) and rarely precondition-shaped, and they can be added later on demand.The additions must preserve the library's fluent return values, exception-factory overloads, nullable annotations, broad target-framework support, Native AOT compatibility, and the customizable single-file source distribution. Key lookups must use
ContainsKeyso they never enumerate and honor the dictionary's own key comparer.Acceptance Criteria
MustContainKeyandMustNotContainKeyare available forIReadOnlyDictionary<TKey, TValue>receivers on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 with identical semantics, and both type arguments are inferred at the call site without explicit specification.Dictionary<TKey, TValue>preserve and return the concrete dictionary shape, while receivers of other dictionary types implementingIReadOnlyDictionary<TKey, TValue>(such asConcurrentDictionary,SortedDictionary,ReadOnlyDictionary,ImmutableDictionary, andFrozenDictionaryon .NET 10) bind to the interface overloads without overload-resolution ambiguity.ContainsKey, so they never enumerate the dictionary and respect its configured key comparer.ArgumentNullExceptionnull behavior; the exception-factory overloads pass the dictionary and the key to the factory.MustContainKeythrows the newMissingKeyExceptionand a failedMustNotContainKeythrows the newExistingKeyException; both derive fromCollectionException, report the offending key, and follow the existing serialization conventions.OrdinalIgnoreCasedictionary), default exceptions, custom messages, custom exception factories, caller argument expressions, returned values including the preservedDictionary<TKey, TValue>shape, and successful binding for several dictionary types.IDictionary<TKey, TValue>-only limitation described below.Technical Details
One
Check.<Assertion>.csfile per family. TheMustContain-style shape preservation via aTCollectiontype parameter is impossible here: C# type inference does not consult generic constraints, so aTDictionary : class, IReadOnlyDictionary<TKey, TValue>constraint leavesTValuenon-inferable and would force explicit type arguments at every call site. The receivers are therefore interface-typed, withTKeyandTValueinferred from the receiver conversion (exact signatures):MustNotContainKeymirrors these shapes, and theDictionary<TKey, TValue>overloads also get factory variants. The concrete overloads exist because the interface overloads erase the shape in fluent chains (_map = map.MustNotBeNull().MustContainKey("endpoint");must keep compiling whenmapand_mapareDictionary<TKey, TValue>); they coexist safely with the interface overloads because an identity conversion beats an interface conversion in overload resolution. Thenotnullconstraint matches the BCL'sDictionary<TKey, TValue>annotation and avoids nullability warnings in the implementation.Why
IReadOnlyDictionaryand notIDictionary(or both). A guard only reads, and every current BCL dictionary type implementsIReadOnlyDictionary<TKey, TValue>. Offering overloads for both interfaces would make every call on a concrete dictionary type other thanDictionary<TKey, TValue>ambiguous (CS0121), because such types convert to both interfaces and neither conversion is better. Accepted limitation: a receiver statically typed asIDictionary<TKey, TValue>cannot use the guards; documentdictionary.Keys.MustContain(key)as the workaround (the key collections of the BCL dictionaries implementICollection<TKey>.ContainsviaContainsKey, so this stays O(1)).The key is passed to
ContainsKeyunmodified; dictionaries that reject null keys surface their ownArgumentNullException, and the guards add no redundant key-null check. FollowMustContain's null handling for the dictionary itself:[NotNull, ValidatedNotNull],[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")], and theMustNotBeNullpath in the default overloads.Exceptions. Add
MissingKeyExceptionandExistingKeyExceptionderiving fromCollectionException, mirroringMissingItemException/ExistingItemExceptionincluding the#if !NET8_0_OR_GREATERserialization constructor. AddThrow.MissingKeyandThrow.ExistingKeyhelpers following theThrow.MissingItemmessage conventions — for example"{parameterName ?? "The dictionary"} must contain key {key.ToStringOrNull()}, but it actually does not."— appending the dictionary's keys (not its key-value pairs) via the existing collection-content helper for the missing-key case.Update
AssertionWhitelist,settings.json, and the source-export whitelist tests for the two new families, ensure the new exceptions and throw helpers are reachable in the export, regenerateLight.GuardClauses.SingleFile.csas the .NET Standard 2.0 output, and verify both portable and .NET 10 generated-source validation. Document the new guards in the collections section ofdocs/assertion-overview.md. No microbenchmarks are needed: the guards perform a single hash or tree lookup.