Forward Parameters Safely in PowerShell with $PSBoundParameters for Thin, Predictable Wrappers
When you write PowerShell wrappers around existing cmdlets, it’s easy for parameters to drift: wrappers grow new switches, defaults diverge, and callers can’t predict what actually gets forwarded. A simple pattern fixes this: clone $PSBoundParameters, prune wrapper-only keys, splat the rest to the target cmdlet, and log the final call. You’ll get clearer intent, less maintenance, and more predictable behavior.
The Core Pattern: Clone, Prune, Splat
$PSBoundParameters contains exactly what the caller supplied (including values bound from pipeline input), not your parameter defaults. That makes it ideal for forwarding. The baseline steps are:
- Clone
$PSBoundParametersso you don’t mutate the live dictionary. - Remove wrapper-only keys.
- Optionally whitelist allowed parameters (to prevent accidental forwarding).
- Splat to the inner cmdlet for predictable behavior.
- Log what you’re calling so code reviews see the intent.
Baseline example
The following wrapper narrows results by age while forwarding only what Get-ChildItem supports:
function Find-OldFiles {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,
[int]$Days = 30,
[switch]$Recurse,
[string]$Filter
)
$limit = (Get-Date).AddDays(-$Days)
# 1) Clone and 2) prune wrapper-only keys
$forward = $PSBoundParameters.Clone()
$null = $forward.Remove('Days')
# 3) Whitelist keys you intend to forward
$target = @{}
foreach ($k in @('Path','Recurse','Filter')) {
if ($forward.ContainsKey($k)) { $target[$k] = $forward[$k] }
}
# 4) Splat to the inner cmdlet
Get-ChildItem @target -File -ErrorAction Stop |
Where-Object { $_.LastWriteTime -lt $limit }
}
# Usage
Find-OldFiles -Path 'C:\Logs' -Days 14 -Recurse -Filter '*.log' |
Select-Object -First 3 FullName, LastWriteTimeBecause you splat a curated hashtable, you avoid accidental overrides and keep the wrapper’s behavior aligned with the source cmdlet.
Make It Safer and More Maintainable
You can harden the pattern for real-world use: whitelist dynamically, handle switches carefully, map parameter names, and respect common parameters.
1) Dynamically whitelist by reflecting the target cmdlet
Instead of a hardcoded allowlist, use the target’s metadata. This prevents forwarding unsupported options (especially useful when wrapping third-party modules or external binaries):
$gciParams = (Get-Command -Name Get-ChildItem -CommandType Cmdlet).Parameters.Keys
$forward = $PSBoundParameters.Clone()
$null = $forward.Remove('Days')
$target = @{}
foreach ($k in $forward.Keys) {
if ($gciParams -contains $k) {
# Drop false-valued switches to avoid forcing defaults
$v = $forward[$k]
$isFalseSwitch = (
($v -is [System.Management.Automation.SwitchParameter] -and -not $v.IsPresent) -or
($v -is [bool] -and -not $v)
)
if (-not $isFalseSwitch) { $target[$k] = $v }
}
}Note: Common parameters (like -Verbose, -ErrorAction) are part of advanced function metadata, so they’ll be forwarded if supported; if you’re wrapping an external process, reflection will naturally filter them out.
2) Map wrapper parameter names to the target names
Sometimes you want wrapper-friendly names that differ from the inner cmdlet. Build a small translation map and apply it before splatting:
# Example mapping: wrapper -Pattern => Get-ChildItem -Filter
$map = @{ Pattern = 'Filter' }
$forward = $PSBoundParameters.Clone()
$null = $forward.Remove('Days')
# Normalize keys
foreach ($key in @($forward.Keys)) {
if ($map.ContainsKey($key)) {
$forward[$map[$key]] = $forward[$key]
$null = $forward.Remove($key)
}
}Keep the map small and explicit. Avoid silently changing semantics.
3) Respect defaults by using presence, not value
Because $PSBoundParameters only contains parameters the caller actually supplied, presence checks tell you whether to forward. Don’t force your wrapper’s defaults into the inner cmdlet unless you truly intend to change its behavior.
# Good: forward -Filter only if caller provided it
if ($PSBoundParameters.ContainsKey('Filter')) { $target.Filter = $PSBoundParameters.Filter }
# Avoid: forcing a default value even when the caller didn't supply it
# $target.Filter = '*.log' # changes semantics unexpectedly4) Combine with ShouldProcess for safe operations
When your wrapper performs changes (delete, move, write), expose -WhatIf and -Confirm with [CmdletBinding(SupportsShouldProcess=$true)] and use $PSCmdlet.ShouldProcess(). Still forward only the allowed set:
function Remove-OldFiles {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string]$Path,
[int]$Days = 30,
[switch]$Recurse,
[string]$Filter
)
$limit = (Get-Date).AddDays(-$Days)
$forward = $PSBoundParameters.Clone(); $null = $forward.Remove('Days')
$allowed = (Get-Command Get-ChildItem).Parameters.Keys
$gci = @{}
foreach ($k in $forward.Keys) { if ($allowed -contains $k) { $gci[$k] = $forward[$k] } }
$toDelete = Get-ChildItem @gci -File -ErrorAction Stop |
Where-Object { $_.LastWriteTime -lt $limit }
foreach ($file in $toDelete) {
if ($PSCmdlet.ShouldProcess($file.FullName, 'Remove-Item')) {
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop
}
}
}This keeps destructive actions reviewable and opt-in while still passing through the caller’s intent.
5) Keep credentials and secrets out of logs
When logging (see next section), scrub sensitive keys like -Credential, -Headers, -Token, -Body, or any custom secret. Err on the side of redaction.
Log the Final Call for Predictability and Reviews
Logging exactly what you’re about to call reduces ambiguity during code reviews and incident analysis. Use Write-Verbose for opt-in traceability, or Write-Information if you want it visible by default (tunable via -InformationAction).
A minimal, safe formatter for splats
function Format-Splat {
param(
[Parameter(Mandatory)][hashtable]$Hash,
[string]$CommandName,
[string[]]$RedactKeys = @('Credential','Headers','Authorization','Token','Body','Password')
)
$parts = foreach ($k in $Hash.Keys) {
if ($RedactKeys -contains $k) { "-$k <REDACTED>"; continue }
$v = $Hash[$k]
if ($v -is [System.Management.Automation.SwitchParameter]) {
if ($v.IsPresent) { "-$k" }
} elseif ($v -is [bool]) {
if ($v) { "-$k:":$true } # skip false
} elseif ($v -is [string]) {
"-$k '" + ($v -replace "'","''") + "'"
} else {
"-$k $v"
}
}
(($CommandName + ' ' + ($parts -join ' ')).Trim())
}
# Example usage
$target = @{ Path = 'C:\Logs'; Recurse = $true; Filter = '*.log' }
Write-Verbose (Format-Splat -Hash $target -CommandName 'Get-ChildItem')With this, your wrapper logs a friendly, reviewable command line while protecting secrets. Pair it with -Verbose in CI runs for full traceability.
Putting it together: hardened Find-OldFiles
function Find-OldFiles {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$Path,
[int]$Days = 30,
[switch]$Recurse,
[string]$Filter
)
$limit = (Get-Date).AddDays(-$Days)
$forward = $PSBoundParameters.Clone()
$null = $forward.Remove('Days')
# Dynamic allowlist
$allowed = (Get-Command Get-ChildItem).Parameters.Keys
$target = @{}
foreach ($k in $forward.Keys) {
if ($allowed -contains $k) {
$v = $forward[$k]
$isFalseSwitch = (
($v -is [System.Management.Automation.SwitchParameter] -and -not $v.IsPresent) -or
($v -is [bool] -and -not $v)
)
if (-not $isFalseSwitch) { $target[$k] = $v }
}
}
Write-Verbose (Format-Splat -Hash $target -CommandName 'Get-ChildItem')
Get-ChildItem @target -File -ErrorAction Stop |
Where-Object { $_.LastWriteTime -lt $limit }
}
# Example:
# Find-OldFiles -Path 'C:\Logs' -Recurse -Filter '*.log' -Days 14 -VerboseThis version automatically stays in sync with the target cmdlet’s parameters, drops false-valued switches, and logs the final call. It still keeps the wrapper thin and intentional.
Real-world use cases
- Web/DevOps automation: Wrap
Invoke-RestMethodto standardize headers and retries while forwarding any caller-supplied query parameters. - Build tooling: Wrap
Start-Processto enforce timeouts and logging, forwarding only safe flags to the underlying process. - CI/CD: Wrap artifact discovery (
Get-ChildItem,Get-Content) with org-wide filtering standards, forwarding path and selection options from pipelines. - Database tasks: Wrap
Invoke-Sqlcmdwith standard connection policy; redact credentials in logs; forward only query-related switches.
Common pitfalls (and fixes)
- Accidentally forwarding wrapper defaults: Only forward when
$PSBoundParameters.ContainsKey('Name')is true. - Logging secrets: Always redact sensitive keys and consider
Write-Verboseinstead ofWrite-Host. - Overriding target defaults with
:$false: Drop false-valued switches before splatting. - Wrapping external tools: Reflection won’t help on
.exeargument shapes; maintain a simple allowlist or explicit mapping.
Adopting this pattern reduces wrapper drift, makes behavior predictable, and improves reviewability. Clone, prune, splat—and log. Your future self (and your teammates) will thank you.
Strengthen your parameter patterns in PowerShell. Explore more patterns like this in advanced scripting resources and cookbooks.