Powershell: Dynamic Parameters

This is an extension to my notes on parameters for scripts or (advanced) functions in Powershell:

Dynamic (or conditional) parameters are an interesting and cool feature, but also a bit tricky and cumbersome to set up. Often times, much simpler things like parameter sets and/or parameter validations may be the better and easier solution – but on the other hand: Sometimes, those are not the right tools and one really should use dynamic parameters…

Supplement: After playing around with it for some time, I think it’s really a finicky feature (see “Gotchas!”); thus my recommendation now:
Avoid if possible.
(Sometimes it may be the best solution – but think long and hard about it before going down that road!)

Introduction

Disclaimer: I won’t give here a full introduction or complete tutorial on dynamic parameters; this is primarily supposed to be just a dense reminder about this topic for my future self.

To learn more about dynamic parameters, I recommend these pages:

The Challenge

As mentioned before, due to the more complicated nature of setting up and using dynamic parameters, one could (and should) use most of the times other, simpler techniques, like parameter sets.

It also took quite a while until had a real need for these conditional parameters:
I wanted to enable/disable some parameters depending on the value(!) of another parameter, so that not too many (or the wrong) parameters will be required by the caller of the function.

The function in question is used to create user accounts; and depending on the naming convention of the username (the user ID), only very specific additional parameters are required (others get default values or are irrelevant).

In this case, parameter sets can’t help, because the value of a parameter (i.e. the username) needs to be evalutated, not just whether the parameter itself is mentioned or not (since the parameter -UserID is mandatory anyways).

(Another inspiration from the articles for using dynamic parameters, but which I haven’t used yet:
To generate the values of a ValidateSet dynamically, instead of hardwiring them in the source code.)

The Set-Up

This is just an overview; details are in the sample code below:

A. To create a dynamic parameter, you will need to use the DynamicParam keyword.

  1. Unlike the Param keyword, the statements in the DynamicParam block are enclosed in curly brackets (i.e. DynamicParam { ... }).
  2. DynamicParam must be declared after Param when used in a cmdlet.
    But note that the DynamicParam block runs before the Param block (see Gotchas)!

B. Normal code must be in a Begin{}, Process{} or End{} block (see Gotchas)!

C. Steps to do before one can use the dynamic parameters (compare with the comments in the sample code):

  1. Define the parameter attributes with the ParameterAttribute object.
  2. Create a RuntimeDefinedParameterDictionary object.
  3. Create an Attribute collection object.
  4. Add your ParameterAttribute to the Attribute collection.
  5. Create the RuntimeDefinedParameter specifying:
    • The name of the parameter.
    • The type of the parameter.
    • The Attribute collection object you created in step 2.
  6. Add the RuntimeDefinedParameter to the RuntimeDefinedParameterDictionary.
  7. Return the RuntimeDefinedParameterDictionary object.

Gotchas!

Gotcha 1: Unexpected token “…” in expression or statement

If you get an error/exception like the one above, it’s because normal code (after DynamicParam {}) must be in a Begin{}, Process{} or End{} block — even if you don’t plan to make the function pipeline-ready. (I prefer to put it in the End{} block under such circumstances.)

That rarely gets mentioned (explicitly) in the offical documentation or in some tutorials; but with a little digging, one can find it explained elsewhere, e.g. here: https://stackoverflow.com/questions/39041328/any-code-after-dynamicparam-block-on-the-script-level-is-parsed-as-syntax-error

Gotcha 2: DynamicParam{} runs before Param()

Another peculiarity that is not mentioned often and led to an error when I tested dynamic features first with the parameter specified directly on the command line:

In other words:
This was OK: Script.ps1 -UserID svcOne
This was not: Script.ps1

After some searching, I came accross this explaination:

Your code only works if you pass an argument to your […] parameter directly on the command line.
If you let PowerShell prompt you for a [the] argument, based on the parameter’s mandatory property, your code cannot work, because the DynamicParam block runs before such automatic prompts.
Source

Because then the mandatory UserID will be prompted too late and thus will not yet be available in the DynamicParam block (because the DynamicParam block runs before the Param block!).

Don’t make it mandatory in the Param block (to prevent a second inquiry, because that will already happen in the DynamicParam block).

Assign the variable in the Param block to itself, so it won’t be empty: If available, it will get the value from the DynamicParam block. (Without assigning itself, the parameter variable may become empty again, because it’s optional and Param runs after DynamicParam!)

Test in the DynamicParam block whether the parameter is already provided as a CLI argument; otherwise ask for the user it via Read-Host:

Param ([Parameter(Mandatory = $false)] $UserID = $UserID)

DynamicParam { if ($PSBoundParameters['UserID']) { $UserID = $PSBoundParameters['UserID'] }
               else                              { $UserID = read-host "UserID"           }
               # ...    
             }

Workaround B

Putting a Read-Host in the middle of a DynamicParam block (Workaround A) leads to a lot of weird side effects (with regards to autocompletion or code extraction for help and other stuff from a function).

Instead, just assume that the parameter argument required for the DynamicParam block is actually provided directly on the command line; if not: Abort. (Only bad thing: Other mandatory parameters will also first be queried before the function finally aborts.)

Param
(
    [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateLength(1, 20)]
    [string] $UserID,
    
    [Parameter(Mandatory=$true)] $Domain
)

DynamicParam
{
    $ParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary

    if ((-not $PSBoundParameters['UserID']) -or ([string]::IsNullOrEmpty($UserID)))
    {
        # One can't abort straight from within DynamicParam, so just mark it and leave.
        $IsDynamicParamOK = $false
        return $ParameterDictionary
    }
    
    # Else: Continue with the normal code path, because everything was OK.
    # ...

    $IsDynamicParamOK = $true
    return $ParameterDictionary
}

End
{
   if ($IsDynamicParamOK -eq $false)
   {
       Write-Warning "Please provide the UserID directly on the command line: $($MyInvocation.MyCommand.Name) -UserID ..."
       return
   }

   # Else: Continue with the normal code path, because everything was OK.
   # ...
}

Sample Code

It is a lot of boilerplate code… 😩 (on the other hand, it stems from a real-life function and is thus still a bit verbose):

[CmdletBinding()]
Param
(
    [Parameter(Mandatory = $true)]  $Domain,
    [Parameter(Mandatory = $false)] $UserID = $UserID, # See 'Gotcha 2'.
                           [switch] $Optional
)

DynamicParam
{
    if ($PSBoundParameters['UserID']) { $UserID = $PSBoundParameters['UserID'] }
    else                              { $UserID = read-host "UserID" }

    if ([string]::IsNullOrEmpty($UserID)) { throw "`r`nThe UserID may not be empty!" }
    if ($UserID.Length -gt 20)            { throw "`r`nThe UserID may not exceed 20 characters!" }
    
    $IsService  = $false
    $IsExternal = $false   

    # 1. Define the parameter attributes with the ParameterAttribute object.
    $GeneralParameterAttributes = [System.Management.Automation.ParameterAttribute] @{
            Mandatory         = $true
            ParameterSetName  = '__AllParameterSets'
            HelpMessage       = "Help Message"       # For interactive help: '(Type !? for Help.)'
    }
        # Alternative for setting the above up:
        #   $GeneralParameterAttributes = New-Object System.Management.Automation.ParameterAttribute
        #   $GeneralParameterAttributes.ParameterSetName = '__AllParameterSets'
        #   $GeneralParameterAttributes.Mandatory        = $true
        #   [...]
    
    # 2. Create a RuntimeDefinedParameterDictionary object.
    #    (Note: During this sample code, these kind of dictionaries will be created/combined multiple times...)
    $Dictionary_Common = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
        
    $DP_Description = New-Object System.Management.Automation.RuntimeDefinedParameter('Description', [string], $Collection)
    $Dictionary_Common.Add('Description', $DP_Description)

    # 3. Create an Attribute collection object.
    $Collection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]

    # 4. Add your ParameterAttribute to the Attribute collection.
    $Collection.Add($GeneralParameterAttributes)

    if ($UserID -like 'svc*')
    {
        $IsService  = $true
        $EmployeeID = "ServiceAccount"

        # (same as step 2...)
        $Dictionary_ServiceAccount = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
       
        # 5. Create the RuntimeDefinedParameter specifying:
        #    - The name of the parameter.
        #    - The type of the parameter.
        #    - The Attribute collection object you created in step 2.
        #   (This will be repeated and extended multiple times in the code below...)
        $DP_Name = New-Object System.Management.Automation.RuntimeDefinedParameter('Name', [string], $Collection)
        
        # 6. Add the RuntimeDefinedParameter to the RuntimeDefinedParameterDictionary.
        #   (This will be repeated multiple times in the code below...)
        $Dictionary_ServiceAccount.Add('Name', $DP_Name)
        
        $Dictionary = $Dictionary_ServiceAccount
    }
    else
    {
        # (The lines below are just specializations and extensions of the steps that came before...)
        
        # Copy a dictionary and extend it:
        $Dictionary_RegularAccount = $Dictionary_Common

        $DP_GivenName = New-Object System.Management.Automation.RuntimeDefinedParameter('GivenName', [string], $Collection)
        $Dictionary_RegularAccount.Add('GivenName', $DP_GivenName)
        
        $DP_Surname = New-Object System.Management.Automation.RuntimeDefinedParameter('Surname', [string], $Collection)
        $Dictionary_RegularAccount.Add('Surname', $DP_Surname)
        
        $DP_EmployeeID = New-Object System.Management.Automation.RuntimeDefinedParameter('EmployeeID', [string], $Collection)
        $Dictionary_RegularAccount.Add('EmployeeID', $DP_EmployeeID)

        if ($UserID -like 'uext*')
        {
            $IsExternal = $true

            $Dictionary_ExternalEmployee = $Dictionary_RegularAccount
                      
            $Manager_Attributes = New-Object System.Management.Automation.ParameterAttribute
            $Manager_Attributes.ParameterSetName = '__AllParameterSets'
            $Manager_Attributes.Mandatory        = $true
            
            $Manager_Collection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
            $Manager_Collection.Add($Manager_Attributes)
            
            # In this case, the parameter gets an alias:
            $Manager_Alias = New-Object -TypeName "System.Management.Automation.AliasAttribute" -ArgumentList 'Xyz'
            $Manager_Collection.Add($Manager_Alias)
            
            # ... and can be empty or null:
            $Manager_AllowEmptyString = New-Object -TypeName "System.Management.Automation.AllowEmptyStringAttribute"
            $Manager_Collection.Add($Manager_AllowEmptyString)
            
            $Manager_AllowNull = New-Object -TypeName "System.Management.Automation.AllowNullAttribute"
            $Manager_Collection.Add($Manager_AllowNull)
            
            $DP_Manager = New-Object System.Management.Automation.RuntimeDefinedParameter('Manager', [string], $Manager_Collection)
            $Dictionary_ExternalEmployee.Add('Manager', $DP_Manager)
            
            $Dictionary = $Dictionary_ExternalEmployee
        }
        else
        {
            $Dictionary_Default = $Dictionary_RegularAccount
            $Dictionary = $Dictionary_Default
        }
    }
    
    # 7. Return the RuntimeDefinedParameterDictionary object.
    return $Dictionary
}

End # See 'Gotcha 1'.
{
    write-host "------------------------------------------------------------"
    write-host "UserID: $UserID"

    if ($IsService)  { write-host "-> A Service Account" }
    if ($IsExternal) { write-host "-> An External Employee" }

    if ($PSBoundParameters['GivenName']) { write-host "GivenName: $($PSBoundParameters['GivenName'])" }
    if ($PSBoundParameters['Surname'])   { write-host "Surname: $($PSBoundParameters['Surname'])" }
    if ($PSBoundParameters['Optional'])  { write-host "Switch '-Optional' is active." }
}