powershelldictionaryvalidationindexer

How to implement a custom dictionary type that self validates its keys


Say I wanted a dictionary type that self-validated its keys, so that only certain string values were allowed.

I tried something like this:

class MyDict : Collections.DictionaryBase, ICloneable {
    [void] OnValidate($key, $value) {
        $validKeyValues = 'a','b','c'
        if ( $key -notin $validKeyValues ) {
            throw "Key $key failed validation, must be one of $validKeyValues."
        }
    }

    [object] Clone() {
        $clonedDict = [MyDict]::new()
        $this.GetEnumerator() | ForEach-Object { $clonedDict.Add($_.Key, $_.Value) }
        return $clonedDict
    }
}

function StackOverflow {
    param(
        [MyDict]$Dictionary
    )
    $Dictionary
}

$dict = [MyDict]::new()
$dict.a = 'x'

StackOverflow $dict

This seems to work for member access setting and getting, e.g., $dict.a = 'x' works. However, I can no longer index into the dictionary when setting a new value, so $dict['key'] = 'value' doesn't work, but getting via $dict['a'] does work and would return x using the above example.

Not being able to set via indexing seems like it's not a robust implementation, and so I am worried I might be missing out on more details.

Questions

note I did try inheriting from the generic Dictionary<TKey,TValue> class instead of DictionaryBase, but this generic class doesn't seem to use OnValidate($key, $value), so I couldn't implement the self-validation. I've also left out ISerializable and IDeserializationOnCallback to simplify the question's scope.

PowerShell 7.4.5


Solution

  • The behavior you're seeing is arguably a bug, present up to at least PowerShell (Core) 7 v7.4.x:

    If using index notation works for getting an entry's value (e.g. $dict['a']) it should equally work for setting it (e.g., $dict['a'] = 'y'), especially given that both operations work with property notation.

    The problem is that the parameterized .Item property that is required for index-notation support is an part of an explicit interface implementation, IDictionary.Item, in the base class you derive from, System.Collections.DictionaryBase.

    While that isn't a problem for getting an entry's value (except in Windows PowerShell, the legacy, ships-with-Windows, Windows-only edition of PowerShell whose latest and last version is 5.1), it unexpectedly fails on setting.[1]

    Given that PowerShell in general surfaces explicit interface implementations as if they were type-native members, the same should apply here.

    See GitHub issue #24537 for a discussion.

    Caveat:


    Workarounds:

    Add-Type @'
    using System.Collections;
    
    public class MyDict : DictionaryBase {
    
      // Key-validation method.
      protected override void OnValidate(object key, object value) {
        var validKeyValues = new string[] { "a", "b", "c" };
        if (!((IList)validKeyValues).Contains(key)) {
            throw new System.ArgumentException(string.Format("Key {0} failed validation, must be one of {1}.", key, string.Join(' ', validKeyValues)));
        }
      }
    
      // Expose a type-native indexer that defers to the explicit 
      // interface implementation; at the .NET level this turns
      // into a parameterized property named "Item".
      public object this[object key] {
        get => ((IDictionary)this)[key];
        set => ((IDictionary)this)[key] = value;
      }
    
    }
    '@
    
    $d = [MyDict]::new()
    # This works now, due to a type-native indexer being present.
    $d['a'] = 'works'
    

    [1] That is, Windows PowerShell fundamentally doesn't support index notation via parameterized .Item properties that are part of explicit interface implementations only.