Skip to content

Objects are situationally invisibly [psobject]-wrapped, sometimes causing unexpected behavior. #5579

@mklement0

Description

@mklement0

There are (at least?) 5 basic scenarios in which objects / properties end up invisibly [psobject]-wrapped, which can lead to subtle differences in behavior:

Note: By [psobject]-wrapped I mean an object for which -is [psobject] returns $true.

  • Objects received via the pipeline when processed via $_ / $PSItem or an [object] or untyped parameter - but not if passed as an argument:
###  Via the *pipeline*:
# -> $true: use of $_
'foo' | ForEach-Object { $_ -is [psobject] }
# -> $true: use of [object]-typed parameter (or untyped)
'foo' | & { param([parameter(valuefrompipeline)] [object] $foo) process { $foo -is [psobject] } }

# -> $false: use of a specifically typed parameter
'foo' | & { param([parameter(valuefrompipeline)] [string] $foo) process { $foo -is [psobject] } }

### As an *argument*:
# -> $false: untyped parameter (or [object]-typed), or any type other than [psobject]
& { param($p) $p -is [psobject] } 'foo'
& { param([string] $p) $p -is [psobject] } 'foo'
# -> $true: only with explicit [psobject]-typing:
& { param([psobject] $p) $p -is [psobject] } 'foo'
  • Objects output by a - binary - cmdlet - but not objects output by a PowerShell function or script or objects returned from an expression. (Note that the arrays that PowerShell implicitly constructs for collecting multiple output objects on assignment to a variable or when a command call participates in an expression are themselves not [psobject]-wrapped.)
# -> $true: output from binary *cmdlet*
(Write-Output 'foo') -is [psobject]

# -> $false: output from *PowerShell code*
(& { 'foo' }) -is [psobject]

# -> $false: output from an expression
'foo' -is [psobject]
  • The value of any calculated property whose value is determined via a script block - but not if the value is determined by a property name (string):
# -> $true: property value is determined by *script block*
(Get-Item / | Select-Object @{ l='foo'; e={ $_.Name } }).foo -is [psobject]

# -> $false: property value is determined by *name* (string)
(Get-Item / | Select-Object @{ l='foo'; e='Name' }).foo -is [psobject]
  • The elements of the collections returned by the intrinsic .ForEach() and .Where() methods are always [psobject]-wrapped (implied by the output collection type, System.Collections.ObjectModel.Collection<PSObject>):
# -> $true, $true
(1..2).ForEach({ $_ }) | ForEach-Object { $_ -is [psobject] }
# -> $true
([psobject] 42) -is [psobject]

# -> $true: [pscustomobject] is the same as [psobject] and 
#   does *not* create a custom object from arbitrary operands
# (only [pscustomobject] @{ ... } works)
([pscustomobject] 42) -is [psobject]

As for real-world ramifications (in addition to the difference in Get-Member representation - see below):

Note that even non-wrapped instances in the sense above do have ETS-defined properties (e.g., [datetime]::now.psextended reveals the .DateTime property).

(While #4347 may sound related, the distinct issue there is that additional properties may be added to outputs by provider cmdlets.)

Examples:

# Two seemingly equivalent ways of constructing a [string] instance:
# Using an expression vs. using a command:
$o1 = 'hi'; $o2 = New-Object System.String 'hi'

# Only the New-Object (*command*-generated) instance is wrapped, however.
> ($o1 -is [psobject]), ($o2 -is [psobject])
False
True

# With *multiple outputs*, *only the individual elements are wrapped*,
# because the implicitly constructed array that collects the output is itself
# NOT wrapped.
> $arr = Write-Output 1, 2; $arr -is [psobject]; $arr[0] -is [psobject]
False
True

# Two ways of constructing a [pscustomobject] instance
# with an array-valued .foo property that is *not* wrapped
# ($o2 itself, by contrast, *is* wrapped, because it is constructed with
# a *conmand*).
$o1 = [pscustomobject] @{ foo = 1, 2 }
$o2 = New-Object pscustomobject -property @{ 'foo' = 1, 2  }

# *Seemingly* equivalent ways, which, however result in a [psobject]-wrapped
# .foo property.
# Calculated property:
$o3 = '' | Select-Object @{ l='foo'; e = { 1, 2 } }
# Explicit [psobject] cast:
$o4 = [pscustomobject] @{ foo = [psobject] (1, 2) }

# All are instances of [System.Management.Automation.PSCustomObject]
> $o1, $o2, $o3, $o4 | % GetType | % Name
PSCustomObject
PSCustomObject
PSCustomObject
PSCustomObject

# All .foo properties are instances of [System.Object[]]
> $o1.foo, $o2.foo, $o3.foo, $o4.foo | % GetType | % Name
Object[]
Object[]
Object[]
Object[]

# However, only $o3 and $o4's .foo properties are considered [psobject] instances:
> ($o1.foo -is [psobject]), ($o2.foo -is [psobject]), ($o3.foo -is [psobject]), ($o4.foo -is [psobject])
False
False
True
True

# This subtle distinction can result in different behavior.
# Note how Get-Member represents the properties differently.
> $o1, $o3 | Get-Member foo | % Definition
Object[] foo=System.Object[]
System.Object[] foo=1 2

# *Windows PowerShell* only:
# Note how the JSONification of $o3 has an extraneous "value" wrapper for
# the array and a "Count" property.
> $o1, $o3 | ConvertTo-Json
[
  {
      "foo":  [
                  1,
                  2
              ]
  },
  {
      "foo":  {
                  "value":  [
                                1,
                                2
                            ],
                  "Count":  2
              }
  }
]

Environment data

Current as of:

PowerShell Core 7.5.0-rc.1
Windows PowerShell 5.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    Issue-Discussionthe issue may not have a clear classification yet. The issue may generate an RFC or may be reclassifWG-Enginecore PowerShell engine, interpreter, and runtime

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions