Why does pipeline parameter cause error when combined with PSDefaultParameterValues?

My powershell function should accept a list of valid paths of mixed files and/or directories either as a named parameter or via pipeline, filter for files that match a pattern, and return the list of files.

$Paths = 'C:\MyFolder\','C:\MyFile'

This works: Get-Files -Paths $Paths This doesn't: $Paths | Get-Files

$PSDefaultParameterValues = @{
    "Get-Files:Paths"   = ( Get-Location ).Path
}

[regex]$DateRegex = '(20\d{2})([0-1]\d)([0-3]\d)'
[regex]$FileNameRegex = '^term2-3\.7_' + $DateRegex + '\.csv$' 

Function Get-Files {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo[]])]
    [OutputType([System.IO.FileInfo])]
    param (
        [Parameter(
            Mandatory = $false, # Should default to $cwd provided by $PSDefaultParameterValues
            ValueFromPipeline,
            HelpMessage = "Enter filesystem paths that point either to files directly or to directories holding them."
        )]
        [String[]]$Paths
    )
    begin {
        [System.IO.FileInfo[]]$FileInfos = @()
        [System.IO.FileInfo[]]$SelectedFileInfos = @()
    }
    process { foreach ($Path in $Paths) {
        Switch ($Path) {
            { Test-Path -Path $Path -PathType leaf } {
                $FileInfos += (Get-Item $Path)
            }
            { Test-Path -Path $Path -PathType container } {
                foreach ($Child in (Get-ChildItem $Path -File)) {
                    $FileInfos += $Child
                }
            }
            Default {
                Write-Warning -Message "Path not found: $Path"
                continue
            }
        }
        $SelectedFileInfos += $FileInfos | Where-Object { $_.Name -match $FileNameRegex }
        $FileInfos.Clear()
    } }
    end {
        Return $SelectedFileInfos | Get-Unique
    }
}

I found that both versions work if I remove the default parameter value. Why?

Why does passing a parameter via the pipeline cause an error when that parameter has a default defined in PSDefaultParameterValues, and is there a way to work around this?


Solution 1:

Mathias R. Jessen provided the crucial pointer in a comment:

  • A parameter that is bound via an entry in the dictionary stored in the $PSDefaultParameterValues preference variable is bound before it is potentially bound via the pipeline, just like passing a parameter value explicitly, as an argument would.

  • Once a given parameter is bound that way, it cannot be bound again via the pipeline, causing an error:

    • The input object cannot be bound to any parameters for the command either because the command does not take pipeline input or the input and its properties do not match any of the parameters that take pipeline input.

    • As you can see, the specific problem at hand - a parameter already being bound - is unfortunately not covered by this message. The unspoken part is that once a given parameter has been bound by argument (possibly via $PSDefaultParameterValues), it is removed from the set of candidate pipeline-binding parameters the input could bind to, and if there are no candidates remaining, the error occurs.

  • The only way to override a $PSDefaultParameterValue preset value is to use an (explicit) argument.

This comment on a related GitHub issue provides details on the order of parameter binding.

A simplified way to reproduce the problem:

& { 

  # Preset the -Path parameter for Get-Item
  # In any later Get-Item calls that do not use -Path explicitly, this
  # is the same as calling Get-Item -Path /
  $PSDefaultParameterValues = @{ 'Get-Item:Path' = '/' }

  # Trying to bind the -Path parameter *via the pipeline* now fails,
  # because it has already been bound via $PSDefaultParameterValues.
  # Even without the $PSDefaultParameterValues entry in the picture,
  # you'd get the same error with: '.' | Get-Item -Path /
  '.' | Get-Item 

  # By contrast, using an *argument* allows you to override the preset.
  Get-Item -Path .
}

Solution 2:

What's happening here?!

This is a timing issue.

PowerShell attempts to bind and process parameter arguments in roughly* the following order:

  1. Explicitly named parameter arguments are bound (eg. -Param $value)
  2. Positional arguments are bound (abc in Write-Host abc)
  3. Default parameter values are applied for any parameter that wasn't processed during the previous two steps - note that applicable $PSDefaultParameterValues always take precedence over defaults defined in the parameter block
  4. Resolve parameter set, validate all mandatory parameters have values (this only fails if there are no upstream command in the pipeline)
  5. Invoke the begin {} blocks on all commands in the pipeline
  6. For any commands downstream in a pipeline: wait for input and then start binding it to the most appropriate parameter that hasn't been handled in previous steps, and invoke process {} blocks on all commands in the pipeline

As you can see, the value you assign to $PSDefaultParameterValues takes effect in step 3 - long before PowerShell even has a chance to start binding the piped string values to -Paths, in step 6.

*) this is a gross over-simplification, but the point remains: default parameter values must have been handled before pipeline binding starts.


How to work around it?

Given the procedure described above, we should be able to work around this behavior by explicitly naming the parameter we want to bind the pipeline input to.

But how do you combine -Paths with pipeline input?

By supplying a delay-bind script block (or a "pipeline-bound parameter expression" as they're sometimes called):

$Paths | Get-Files -Paths { $_ }

This will cause PowerShell to recognize -Paths during step 1 above - at which point the default value assignment is skipped.

Once the command starts receiving input, it transforms the input value and binds the resulting value to -Paths, by executing the { $_ } block - since we just output the item as-is, the effect is the exact same as when the pipeline input is bound implicitly.


Digging deeper

If you want to learn more about what happens "behind the curtain", the best tool available is Trace-Command:

$PSDefaultParameterValues['Get-Files:Paths'] = $PWD

Trace-Command -Expression { $Paths |Get-Files } -Name ParameterBinding -PSHost

I should mention that the ParameterBinding tracer is very verbose - which is great for surmising what's going on - but the output can be a bit overwhelming, in which case you might want to replace the -PSHost parameter with -PSPath .\path\to\output.txt to write the trace output to a file