Search code examples
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

  • Is the above approach the closest I could come in PowerShell to creating a custom dictionary type that validates its own keys?
  • Can I fix the setting via index issue in PowerShell?
  • Is this overall even a valid approach or are there are other serious drawbacks that I've missed and/or better ways to do this in PowerShell?

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:

    • While your class would work as expected from PowerShell once the bug is fixed, thanks to the latter's implicit surfacing of explicit interface implementations, using it from other .NET languages, notably from C#, would require casting each instance to System.Collections.IDictionary in order to access the dictionary functionality.

    Workarounds:

    • Either: Call the parameterized .Item property underlying the indexer explicitly, using method syntax, instead of using index notation:

      • Note: This workaround is likely unacceptable, because it requires all callers to employ it; also, it is more obscure and cumbersome than using property notation. However, I'm mentioning it for the sake of completeness.

      • E.g., calling $dict.Item('a') = 'y' (instead of $dict['a'] = 'y') works.

    • Or: Define your MyDict class using embedded C# code via Add-Type, which allows you to surface a type-native indexer (parameterized Item property) that internally defers to the explicitly interface implementation, as shown below; this will make setting entry values work with index notation too.

      • The reason that C# code must be used is that PowerShell's own class construct doesn't support implementing parameterized properties, as discussed in this conceptually related answer.

      • Note that C# has syntactic sugar for defining indexers; when you examine an instance of the resulting type via Get-Member, you'll see a ParameterizedProperty entry with following definition:

         System.Object Item(System.Object key) {get;set;}
        
    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.