I traced the differences between $items | forEach-Object { Write-host "hello"} and $null | ForEach-Object { Write-Host "hello"} via Trace-Command.
PS C:>Trace-Command -Name parameterbinding -Expression { $items | ForEach-Object { write-host "hello" } } -PSHost
DEBUG: ParameterBinding Information: 0 : BIND NAMED cmd line args [Out-Null]
DEBUG: ParameterBinding Information: 0 : BIND POSITIONAL cmd line args [Out-Null]
DEBUG: ParameterBinding Information: 0 : MANDATORY PARAMETER CHECK on cmdlet [Out-Null]
....
PS C:> Trace-Command -Name parameterbinding -Expression { $null | ForEach-Object { write-host "hello" } } -PSHost
DEBUG: ParameterBinding Information: 0 : BIND NAMED cmd line args [ForEach-Object]
DEBUG: ParameterBinding Information: 0 : BIND POSITIONAL cmd line args [ForEach-Object]
DEBUG: ParameterBinding Information: 0 : BIND arg [ write-host "hello" ] to parameter [Process]
DEBUG: ParameterBinding Information: 0 : Binding collection parameter Process: argument type [ScriptBlock], parameter type [System.Management.Automation.ScriptBlock[]], collection type Array, element type [System.Management.Automation.ScriptBlock], no
It seems that $items points to the Out-Null cmdlet, which is shown via:
DEBUG: ParameterBinding Information: 0 : BIND NAMED cmd line args [Out-Null]
So it seems that Get-ChildItem returns a reference to Out-Null in case of an error. If you compare this to $null | ForEach-Object ... you'll that the ForEach-Object will be invoked directly:
DEBUG: ParameterBinding Information: 0 : BIND NAMED cmd line args [ForEach-Object]
What's also interesting, if you use ForEach-Object with the -InputObject parameter the code works as requested:
PS C:> Trace-Command -Name parameterbinding -Expression { ForEach-Object -InputObject $items -Process { write-host "hello" } } -PSHost
DEBUG: ParameterBinding Information: 0 : BIND NAMED cmd line args [ForEach-Object]
DEBUG: ParameterBinding Information: 0 : BIND arg [] to parameter [InputObject]
So my "guess" is the following. In case of an error (of Get-ChildItem) you won't that below code prints an output:
PS C:\> Get-ChildItem -Path "notExisting" | ForEach-Object { Write-Host "found" }
That makes perfectly sense, Get-ChildItem "calls" Out-Null which will clear the pipeline, which will break the pipeline chain (= if nothing is found, nothing shall be printed).
Base on that, this call statement $items = Get-ChildItem -Path "someNotExistingPath" is invoked, but Get-ChildItem returns a null type that is not equal to $null. When performing this code if($null -EQ $items) PowerShell will more or less perform a implicit cast of the Get-ChildItem-null-type to $null. When it comes to this call $items | ForEach-Object, nothing else should be send to the pipeline since $items contains the reulst of Out-Null.
UPDATE:
In the meanwhile @iRon also added a duplicate link, which explains the details. I'll keep the answer since this link doesn't show the usage of Trace-Command. Hope thats ok for the community.
Hope that helps.