- Use PowerShell splatting and PSBoundParameters to pass parameters - Wed, Nov 9 2022
- Using PowerShell with $PSStyle - Mon, Jan 24 2022
- Clean up user profiles with PowerShell - Mon, Jun 9 2014
If you are like me, you often write PowerShell functions based on an underlying native cmdlet. These wrapper functions are quite common and help abstract complex but repetitive tasks. Here's an example of a function built around Get-ChildItem.
Function Get-FolderSizeA { [cmdletbinding()] Param( [parameter(Position = 0, Mandatory)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path $_ })] [string]$Path, [switch]$Recurse, [ValidateNotNullOrEmpty()] [string]$Filter = "*.*" ) Write-Verbose "Starting $($myinvocation.MyCommand)" Write-Verbose "Getting stats for files ($filter) in $path" if ($recurse) { Write-Verbose "Recursing" $stats = Get-ChildItem -Path $Path -Filter $filter -File -Recurse | Measure-Object -Property Length -Sum -Average } else { $stats = Get-ChildItem -Path $Path -Filter $filter -File | Measure-Object -Property Length -Sum -Average } Write-Verbose "Found $($stats.count) files" #write a custom object to the pipeline [pscustomobject]@{ Path = $Path Filter = $filter Files = $stats.count TotalKB = [math]::round($stats.sum / 1KB, 2) AverageKB = [math]::round($stats.average / 1KB, 2) Date = (Get-Date).ToShortDateString() } Write-Verbose "Ending $($myinvocation.MyCommand)" }
My function needs logic to decide how to run Get-ChildItem based on the parameters. But there is an easier way and something that can simplify my code. I have always found that the simpler the code, the less likely the chance of errors, and the easier it is to debug.
Using splatting
I am assuming you are familiar with splatting. This technique can shorten your command syntax and make it easier to read your code. You might have a command like this:
Get-ChildItem c:\work -Filter *.txt -Recurse -File
As an alternative, you can define a hash table of command parameters.
$paramHash = @{ Path = "c:\work" Filter = "*.txt" Recurse = $True File = $True }
You can pass this hash table using the @ symbol.
Get-ChildItem @paramHash
Note that there is no $ before the variable. Using splatting makes your code more "vertical," which I find easier to read than scrolling horizontally. Hash tables are also handy because you can create and modify them on the fly.
Using PSBoundParameters
In my code, the function parameters are identical to what I need for Get-ChildItem. I could simplify my code by splatting. I can do this by leveraging an intrinsic variable, $PSBoundParameters. This is a special hash table that is automatically created in every function.
The typename is a bit different than a standard hash table, but for all practical purposes, you can treat it as a hash table. Here's a revised version of the function using PSBoundParameters.
Function Get-FolderSizeB { [cmdletbinding()] Param( [parameter(Position = 0, Mandatory)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path $_ })] [string]$Path, [switch]$Recurse, [ValidateNotNullOrEmpty()] [string]$Filter ) Write-Verbose "Starting $($myinvocation.MyCommand)" Write-Verbose "Using these PSBoundParameters" $PSBoundParameters | Out-String | Write-Verbose Write-Verbose "Getting stats for files in $path" #splat PSBoundParameters to Get-ChildItem $stats = Get-ChildItem @PSBoundParameters -File | Measure-Object -Property Length -Sum -Average Write-Verbose "Found $($stats.count) files" #write a custom object to the pipeline [pscustomobject]@{ Path = $Path Filter = $filter Files = $stats.count TotalKB = [math]::round($stats.sum / 1KB, 2) AverageKB = [math]::round($stats.average / 1KB, 2) Date = (Get-Date).ToShortDateString() } Write-Verbose "Ending $($myinvocation.MyCommand)" }
My Verbose output includes PSBoundParameters. Notice that with splatting, you can still include other parameters.
Get-ChildItem @PSBoundParameters -File
It would be cleaner not to have to do that. Also notice that because I didn't specify a Filter parameter, it isn't detected as a bound parameter. My output also lacks filter information. Get-ChildItem essentially uses a filter of *.*, but I'm not capturing it. Let's fix this.
Modifying PSBoundParameters
Function Get-FolderSizeC { [cmdletbinding()] Param( [parameter(Position = 0)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path $_ })] [string]$Path = ".", [switch]$Recurse, [ValidateNotNullOrEmpty()] [string]$Filter = "*.*" ) Write-Verbose "Starting $($myinvocation.MyCommand)" Write-Verbose "Using these PSBoundParameters" $PSBoundParameters | Out-String | Write-Verbose Write-Verbose "Getting stats for files in $path" $PSBoundParameters.Add("File",$True) if (-Not ($PSBoundParameters.ContainsKey("Filter"))) { #add the parameter default $PSBoundParameters.Add("Filter",$Filter) } $stats = Get-ChildItem @psboundparameters | Measure-Object -Property Length -Sum -Average Write-Verbose "Found $($stats.count) files" #write a custom object to the pipeline [pscustomobject]@{ Path = $Path Filter = $filter Files = $stats.count TotalKB = [math]::round($stats.sum / 1KB, 2) AverageKB = [math]::round($stats.average / 1KB, 2) Date = (Get-Date).ToShortDateString() } Write-Verbose "Ending $($myinvocation.MyCommand)" }
In this version, I'm going to add -File to PSBoundParameters.
$PSBoundParameters.Add("File",$True)
I'm also adding my Filter default if it isn't detected as a bound parameter.
if (-Not ($PSBoundParameters.ContainsKey("Filter"))) { #add the parameter default $PSBoundParameters.Add("Filter",$Filter) }
This allows me to specify a parameter default value, yet still use it as a bound parameter.
Or I can specify a filter.
But you know, maybe I want the Average value to be optional. And since I'm already splatting to Get-ChildItem, why not also splat to Measure-Object?
Function Get-FolderSizeD { [cmdletbinding()] Param( [parameter(Position = 0)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path $_ })] [string]$Path = ".", [switch]$Recurse, [ValidateNotNullOrEmpty()] [string]$Filter = "*.*", #Include the average size [switch]$Average ) Write-Verbose "Starting $($myinvocation.MyCommand)" Write-Verbose "Using these detected PSBoundParameters" $PSBoundParameters | Out-String | Write-Verbose #Convert path and add to PSBoundParameters #this is another way of defining a hash table value, even if it #doesn't already exist $PSBoundParameters["Path"] = Convert-Path $path Write-Verbose "Getting stats for files in $($PSBoundParameters.Path)" $PSBoundParameters.Add("File", $True) if (-Not ($PSBoundParameters.ContainsKey("Filter"))) { #add the parameter default $PSBoundParameters.Add("Filter", $Filter) } #remove Average if bound since it isn't a valid parameter for Get-ChildItem if ($PSBoundParameters.ContainsKey("Average")) { #the remove method writes a Boolean result to the pipeline which I'll suppress [void]$PSBoundParameters.Remove("Average") } #parameters to splat to Measure-Object $mo = @{ Property = "Length" Sum = $True Average = $Average } Write-Verbose "Invoking Get-ChildItem with these parameters" $PSBoundParameters | Out-String | Write-Verbose Write-Verbose "Invoking Measure-Command with these parameters" $mo | Out-String | Write-Verbose $stats = Get-ChildItem @PSBoundParameters | Measure-Object @mo Write-Verbose "Found $($stats.count) files" #write a custom object to the pipeline [pscustomobject]@{ Path = (Convert-Path $Path) Filter = $filter Files = $stats.count TotalKB = [math]::round($stats.sum / 1KB, 2) AverageKB = [math]::round($stats.average / 1KB, 2) Date = (Get-Date).ToShortDateString() } Write-Verbose "Ending $($myinvocation.MyCommand)" }
In this version, I also added Path to PSBoundParameters. In all of my previous examples, I have specified a value, but the function has a parameter default of the current location. However, I don't want to see a period as the path; I want to see the full file system path so I'll convert the parameter value.
You can also see where I'm defining a hash table of parameter values for Measure-Object. Since -Average is a switch, it has an implicit Boolean value, which I can use in the hash table.
PSBoundParameters and the pipeline
There is one thing you need to watch. My examples thus far have used a simple function, so everything is bound at the beginning. However, if your command is taking in pipeline input, this can change.
Function Get-FolderSizeE { [cmdletbinding()] Param( [parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path $_ })] [alias("fullname")] [string]$Path = ".", [switch]$Recurse, [ValidateNotNullOrEmpty()] [string]$Filter = "*.*", [Parameter(HelpMessage = "Include the average file size")] [switch]$Average ) Begin { Write-Verbose "Starting $($myinvocation.MyCommand)" Write-Verbose "Starting with these PSBoundParameters" $PSBoundParameters | Out-String | Write-Verbose #update PSBoundParameters $PSBoundParameters.Add("File", $True) #remove Average if bound since it isn't a valid parameter for Get-ChildItem if (-Not ($PSBoundParameters.ContainsKey("Filter"))) { #add the parameter default $PSBoundParameters.Add("Filter", $Filter) } if ($PSBoundParameters.ContainsKey("Average")) { #the remove method writes a Boolean result to the pipeline which I'll suppress [void]$PSBoundParameters.Remove("Average") } #parameters to splat to Measure-Object $mo = @{ Property = "Length" Sum = $True Average = $Average } } #begin Process { #Convert path and add to PSBoundParameters $PSBoundParameters["Path"] = Convert-Path $path Write-Verbose "Using these PSBoundParameters in the Process block" $PSBoundParameters | Out-String | Write-Verbose Write-Verbose "Getting stats for files in $($PSBoundParameters.Path)" $stats = Get-ChildItem @PSBoundParameters | Measure-Object @mo Write-Verbose "Found $($stats.count) files" #write a custom object to the pipeline [pscustomobject]@{ Path = (Convert-Path $Path) Filter = $filter Files = $stats.count TotalKB = [math]::round($stats.sum / 1KB, 2) AverageKB = [math]::round($stats.average / 1KB, 2) Date = (Get-Date).ToShortDateString() } } #process End { Write-Verbose "Ending $($myinvocation.MyCommand)" } #end }
In this version, the function will accept a path as pipeline input. This means that the Path parameter doesn't get bound until the Process script block, so I move my parameter handling code there.
The final function
Let's pull this all together into a final function.
Function Get-FolderSize { [cmdletbinding(DefaultParameterSetName = 'default')] [alias("gfs")] [OutputType("jhFolderSize")] Param( [parameter( Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = "default" )] [Parameter( Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = "recurse" )] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path $_ })] [alias("fullname")] [string]$Path = ".", [Parameter(ParameterSetName = "recurse")] [switch]$Recurse, [ValidateNotNullOrEmpty()] [string]$Filter = "*.*", [Parameter(ParameterSetName = "recurse")] [string]$Exclude, [Parameter(HelpMessage = "Include the average file size")] [switch]$Average ) Begin { Write-Verbose "Starting $($myinvocation.MyCommand)" Write-Debug "Starting with these PSBoundParameters" $PSBoundParameters | Out-String | Write-Debug #update PSBoundParameters $PSBoundParameters.Add("File", $True) #remove Average if bound since it isn't a valid parameter for Get-ChildItem if (-Not ($PSBoundParameters.ContainsKey("Filter"))) { #add the parameter default $PSBoundParameters.Add("Filter", $Filter) } if ($PSBoundParameters.ContainsKey("Average")) { #the remove method writes a Boolean result to the pipeline which I'll suppress [void]$PSBoundParameters.Remove("Average") } #parameters to splat to Measure-Object $mo = @{ Property = "Length" Sum = $True Average = $Average } } #begin Process { #Convert path and add to PSBoundParameters $PSBoundParameters["Path"] = Convert-Path $path Write-Debug "Using these PSBoundParameters" $PSBoundParameters | Out-String | Write-Debug Write-Debug "Invoking Get-ChildItem with these parameters" $PSBoundParameters | Out-String | Write-Debug Write-Verbose "Getting stats for files in $($PSBoundParameters.Path)" $stats = Get-ChildItem @PSBoundParameters | Measure-Object @mo Write-Verbose "Found $($stats.count) files" #write a custom object to the pipeline #leave sizes as raw byte values Use a custom format file to display #formatted output in KB or MB. [pscustomobject]@{ PSTypename = "jhFolderSize" Path = (Convert-Path $Path) Filter = $filter Files = $stats.count Total = $stats.sum Average = $stats.Average Date = (Get-Date).ToShortDateString() } } #process End { Write-Verbose "Ending $($myinvocation.MyCommand)" } #end }
This version adds the Exclude parameter, which only works when using -Recurse, so I created a separate parameter set. I also moved the verbose statements showing PSBoundParameters to the debug stream, since I don't need to see them in my Verbose output.
I also removed the size formatting and am displaying values in bytes. I can use a custom formatting file if I want to display values in KB.
Summary
If you are new to using PSBoundParameters, I encourage you to try out my function variations. Even though I argued that using PSBoundParameters can simplify your code, I'll admit that the final version of my function is more complicated than where I began. However, I tried to demonstrate a variety of techniques. Depending on how you structure your parameters, you may not need to adjust PSBoundParameters much. Or you might only need to check PSBoundParameters as a final parameter validation test.
Subscribe to 4sysops newsletter!
You can read more in the about_Automatic_Variables and about_Splatting help topics.
Excellent article Jeff thanks
While I realize this was written just to demonstrate PSBoundParameters, somehow
Get-ChildItem c:\work -Filter *.txt -Recurse -File | Measure-Object -Property Length -Sum -Average
seems a hell of a lot simpler than
Sorry, didn’t see the “Please enclose code in pre tags” until too late.
No problem. I edited your comment.
The final code is definitely more complicated than where I began. But the code itself isn’t the point of the article. I was demonstrating different ways you can use PSBoundparameters. You might use one of the concepts in your code to simplify something.