In the second installment of the Practical PowerShell series, I discussed how to leverage functions in scripts and emphasized the creation of advanced functions. Advanced functions add value to your code, which becomes more flexible through parameterization, robust through validation and error handling, and better documented.
In this article, I discuss dynamic parameters in functions or scripts, which are parameters only available under specific conditions, for example mutually exclusive parameters. You can also restrict usage based on a particular context, such as running PowerShell core. Another example is restricting the allowed values through ValidateSet using actual information, such as domain controller names. They also make your function or script more friendly, mainly when used interactively. As a PowerShell user, you will notice this when using tab-completion, as parameters will or will not be offered, depending on the parameters you specified earlier in the command line.
Parameter Sets
To use a practical example, we’ll examine a cmdlet that you may or may not already use to connect to Microsoft Graph, Connect-MgGraph. There are several ways to use Connect-MgGraph. This becomes visible when you ask Get-Help to provide usage instructions, e.g., Get-Help Connect-MgGraph. The syntax section shows different ways to use the command, each with unique parameters. These unique combinations are called parameter sets. Below are two of them from the syntax section; I removed some common ones for readability:
Connect-MgGraph [-ClientId] <String> [[-CertificateSubjectName] <String>] [[-CertificateThumbprint] <String>] [-Certificate <X509Certificate2>] [-ClientTimeout <Double>] [-ContextScope {Process | CurrentUser}] [-Environment <String>] [-NoWelcome] [-SendCertificateChain <Boolean>] [-TenantId <String>] Connect-MgGraph [[-ClientId] <String>] [[-Identity]] [-ClientTimeout <Double>] [-ContextScope {Process | CurrentUser}] [-
The first syntax shows the parameters to connect using certificate-based authentication, and the second to connect using a managed identity. Both examples have a unique set of parameters specific to each use case: CertificateSubjectName, CertificateThumbprint or Certificate for certificate-based authentication, and Identity for managed identity. The syntax output shows that you cannot specify certificate-related parameters when using managed identity or vice versa. In fact, if you specify conflicting parameters, PowerShell will complain that it cannot determine which set to use. Other parameters such as ClientId can be used for both sets.
Inspecting Parameter Sets
If defined, you can explore parameter sets for any command by inspecting its ParameterSets property. The property reveals the name of the parameter sets and its parameters. Looking at the Connect-MgGraph cmdlet, we see:
(Get-Command Connect-MgGraph).ParameterSets | Select-Object Name, Parameters Name Parameters ---- ---------- UserParameterSet {ClientId, TenantId, Scopes,…} AppCertificateParameterSet {ClientId, CertificateSubjectName, CertificateThumbprint,…} IdentityParameterSet {ClientId, Identity,…} AppSecretCredentialParameterSet {ClientSecretCredential, TenantId,…} AccessTokenParameterSet {AccessToken, Environment, ClientTimeout, NoWelcome…} EnvironmentVariableParameterSet {ContextScope, Environment, ClientTimeout, EnvironmentVariable…}
Creating Parameter Sets for a Script or Function
How do you define parameter sets for your advanced function or script? Assume we have an advanced function that we want to use, and we will authenticate against Microsoft Graph using certificate-based authentication. For authentication to work, we need a Tenant ID, an Application ID, and a certificate. The certificate can be in the personal certificate store. In this instance, we can pass the certificate thumbprint as a parameter. Another syntax we want to define is using the filename of a file-based certificate (.pfx) as a parameter, including a password parameter for decoding the certificate file. Another option is passing the certificate as an object, but we will skip that option in this example for simplicity. The parameter combinations are better visible when we put them in a matrix, as shown below. The TenantId and AppId parameters must be specified for both scenarios.
Parameter | AuthCertThumb | AuthCertFile |
CertificateThumbprint | ✅ | |
CertificateFile | ✅ | |
CertificatePassword | ✅ | |
TenantId | ✅ | ✅ |
AppId | ✅ | ✅ |
Looking at the table, the function will have two parameter sets:
- TenantId, AppId, and CertificateThumbprint. All three parameters are mandatory for this syntax.
- TenantId, AppId, CertificateFile, and CertificatePassword. All four parameters are mandatory for this syntax.
We now need to specify a parameter attribute containing the mandatory requirement and the related ParameterSetName for each parameter. Because TenantId and AppId are used in both sets, there will be two parameter attribute definitions:
Function Invoke-ParameterSetDemo { [cmdletbinding(DefaultParameterSetName = 'AuthCertThumb')] param( [parameter(Mandatory=$true,ParameterSetName='AuthCertThumb')] [parameter(Mandatory=$true,ParameterSetName='AuthCertFile')] [string]$TenantId, [parameter(Mandatory=$true,ParameterSetName='AuthCertThumb')] [parameter(Mandatory=$true,ParameterSetName='AuthCertFile')] [string]$AppId, [parameter(Mandatory=$true,ParameterSetName='AuthCertThumb')] [string]$CertificateThumbprint, [parameter(Mandatory=$true,ParameterSetName='AuthCertFile')] [ValidateScript({ Test-Path -Path $_ -PathType Leaf})] [string]$CertificateFile, [parameter(Mandatory=$true,ParameterSetName='AuthCertFile')] [SecureString]$CertificatePassword ) Process { # Rest of code } }
Note: The certificatepassword parameter in the example is of type SecureString. This is a type accelerator for [System.Security.SecureString]. This type is also used when you use ConvertTo-SecureString to create secure strings or store your password using Get-Credential. Should you need it in your script, use ConvertFrom-SecureString -AsPlainText to get the original value.
The code shown in the example also includes a ValidateScript validation, which defines a script block that will check if the value for the certificate file is accessible using Test-Path; $_ in the script block gets replaced with the actual value of the CertificateFile parameter. The DefaultParameterSetName argument in the CmdletBinding attribute instructs PowerShell which set to use when it cannot determine which set to use based on the parameters used.
Testing a Parameter Set
When you try to call the function shown in the example, you can start experiencing the benefits of parameter sets. First, when you specify CertificateThumbprint, the tab-completion will detect that you are using the AuthCertThumb parameter set and do not offer CertificateFile and CertificatePassword when tabbing through the parameters. Second, when you ask Get-Help about your function or script, you will see the two variants, like what we saw earlier with Connect-MgGraph. Suppose our function is called Invoke-ParameterSetDemo, Get-Help will show you the following:
> Get-Help Invoke-ParameterSetDemo NAME Invoke-ParameterSetDemo SYNTAX Invoke-ParameterSetDemo -TenantId <string> -AppId <string> -CertificateThumbprint <string> [<CommonParameters>] Invoke-ParameterSetDemo -TenantId <string> -AppId <string> -CertificateFile <string> -CertificatePassword <securestring> [<CommonParameters>]
This should give people using your function or script an indication of how your code can be used, how to call it, and the relation between parameters. Small tip: If you want to know which parameter set is effectively used, the automatic variable $PSCmdlet.ParameterSetName should provide its name.
The downside to parameter sets is that the work to define the sets can become significant and complex. You can imagine the amount of work expands for every parameter you want to add to the mix, managing all the parameter sets. New parameters may introduce a new parameter set, which you must propagate to existing definitions. Drawing a table like I showed above might help visualize the situation, and you can then figure out what you need to.
Github Copilot can also be of value when writing the code for parameter sets. Ask it to generate the parameter definition in PowerShell using a table such as the one shown above. An example outcome is shown in the screenshot here; the table I pasted is flattened in the conversation (Figure 1).
Using a DynamicParam Block
The alternative to parameter sets is a DynamicParam block. This will allow you to dynamically define parameters at runtime, using a more programmatic and less rigid way to set the required conditions than when using parameter sets. For example, you can make parameters available only for PowerShell Core (looking at $PSEdition) or when running in Azure Automation ($PSPrivateMetadata).JobId is set, which works for PS 5.1 and 7.2 runbooks.
In its simplest form, using DynamicParam consists of adding a DynamicParam script block following and complementary to the param() section. You can use both, as shown in this outline:
Function Invoke-DynamicParamDemo { param() DynamicParam {} }
If we look at our requirements, we see that TenantId and AppId are always required, so we can use a regular param section for those. In that param section, we still declare our dynamic parameters. We add them but make them optional. For each mode, we assign a different ParameterSetName:
[cmdletbinding()] Param( [Parameter(Mandatory = $true)] [string]$TenantId, [Parameter(Mandatory = $true)] [string]$AppId, [Parameter(Mandatory = $false, ParameterSetName = 'Thumb')] [string]$CertificateThumbprint, [Parameter(Mandatory = $false, ParameterSetName = 'File')] [ValidateScript({ Test-Path -Path $_ -PathType Leaf })] [string]$CertificateFile, [Parameter(Mandatory = $false, ParameterSetName = 'File')] [System.Security.SecureString]$CertificatePassword )
You also see that we can still use our variable typing, and the validation can stay as well, as they depend on the environment (file exists) rather than the presence or content of any other parameters. Here is where the DynamicParam block comes into play, where we put our conditions and logic:
Function Invoke-DynamicParamDemo { [cmdletbinding()] Param( [Parameter(Mandatory = $true)] [string]$TenantId, [Parameter(Mandatory = $true)] [string]$AppId ) DynamicParam { $paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary $thumbAttr = New-Object System.Management.Automation.ParameterAttribute $thumbAttr.ParameterSetName = 'Thumb' $thumbAttr.Mandatory = $true $thumbAttrCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute] $thumbAttrCollection.Add($thumbAttr) $thumbParam = New-Object System.Management.Automation.RuntimeDefinedParameter('CertificateThumbprint', [string], $thumbAttrCollection) $paramDictionary.Add('CertificateThumbprint', $thumbParam) If( $PSBoundParameters.ContainsKey('CertificateFile')) { If(-not( Test-Path -Path $_ -PathType Leaf )) { Throw 'Certificate file not found' } } $fileAttr = New-Object System.Management.Automation.ParameterAttribute $fileAttr.ParameterSetName = 'File' $fileAttr.Mandatory = $true $fileAttrCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute] $fileAttrCollection.Add($fileAttr) $validateScriptAttr = New-Object System.Management.Automation.ValidateScriptAttribute({ Test-Path -Path $_ -PathType Leaf }) $fileAttrCollection.Add($validateScriptAttr) $fileParam = New-Object System.Management.Automation.RuntimeDefinedParameter('CertificateFile', [string], $fileAttrCollection) $paramDictionary.Add('CertificateFile', $fileParam) $passAttr = New-Object System.Management.Automation.ParameterAttribute $passAttr.ParameterSetName = 'File' $passAttr.Mandatory = $true $passAttrCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute] $passAttrCollection.Add($passAttr) $passParam = New-Object System.Management.Automation.RuntimeDefinedParameter('CertificatePassword', [System.Security.SecureString], $passAttrCollection) $paramDictionary.Add('CertificatePassword', $passParam) return $paramDictionary } Begin { if ($PSBoundParameters.ContainsKey('CertificateThumbprint')) { $CertificateThumbprint = $PSBoundParameters['CertificateThumbprint'] } if ($PSBoundParameters.ContainsKey('CertificateFile')) { $CertificateFile = $PSBoundParameters['CertificateFile'] } if ($PSBoundParameters.ContainsKey('CertificatePassword')) { $CertificatePassword = $PSBoundParameters['CertificatePassword'] } } Process { $PSCmdlet.ParameterSetName } }
This might look overwhelming, mainly because of all the additional code needed for parameter definitions. However, it is the same pattern: dynamic parameters are defined using a dictionary, specifically, a RuntimeDefinedParameterDictionary. This dictionary is returned by the DynamicParam block. For each dynamic parameter, you:
- Configure a ParameterAttribute object and set its properties, such as mandatory or the intended parameter set.
- Create an attribute collection for storing attributes and validations. The type of this collection is System.Attribute. Because it is code, you can also create a validation based on external data. For example, you can add code restricting values to Active Directory sites.
- Using the attribute collection with attributes and validations, we can create our RuntimeDefinedParameter and add it to our RuntimeDefinedParameterDictionary.
You might also have noticed that I added a Begin and Process block. The process block is mandatory when using DynamicParam. The dynamic parameters defined using DynamicParam are only accessible using PSBoundParameters, as they are created at runtime. So, I set the variables of the same name using their PSBoundParameters counterpart in the Begin block for simplicity and later usage.
If you get this structure, you can make things as flexible (or complex) as possible through code instead of declarations with parameter sets. DynamicParam is more straightforward to construct and maintain when working with many parameters. You can mix the two methods, using parameter sets for regular parameters and adding DynamicParm for attributes with more complex conditional logic.
Summary
In conclusion, the flexibility and power offered by the DynamicParam block can enhance the robustness and adaptability of your functions and scripts. By leveraging this approach, you can programmatically control parameter behavior, allowing for dynamic validation and conditional logic that the more static parameter sets cannot achieve. This advanced technique not only simplifies management when using a substantial number of parameters but also provides a way to address complex usage scenarios tailored to the context, execution environment, and requirements.