Search code examples
powershelldecoratorcalculated-property

PSCustomObject with different string types


I have a workflow that is pretty picky with the PSCustomObjects it receives. I often use Select-Object to get the properties from the objects I want to keep and sometimes I need to convert some of the values to more usable formats. The easiest way to do this is to use the @{Name='Name'; Expression = {'Expression'}} technique.

BUT, that technique messes up the PSCustomObject in a way that blocks my further workflow.

The issue can be reproduced like this:

$object = [pscustomobject]@{
        Text        = "This is a string"
    }

In the output below, the Definition for string is 'string Text=This is a string'

$object | Select-Object * | Get-Member
<# Outputs
TypeName: System.Management.Automation.PSCustomObject

Name        MemberType   Definition
----        ----------   ----------
Equals      Method       bool Equals(System.Object obj)
GetHashCode Method       int GetHashCode()
GetType     Method       type GetType()
ToString    Method       string ToString()
Text        NoteProperty string Text=This is a string
#>

When adding a new NoteProperty with the Select-Object technique, the Definition is 'System.String Text2=This is a string'

This is what makes my next cmdlet throw.

$WhatHappensToText = $object | Select-Object @{Name='Text'; Expression={$_.Text}} 
$WhatHappensToText | Get-Member
<# Outputs
TypeName: Selected.System.Management.Automation.PSCustomObject

Name        MemberType   Definition
----        ----------   ----------
Equals      Method       bool Equals(System.Object obj)
GetHashCode Method       int GetHashCode()
GetType     Method       type GetType()
ToString    Method       string ToString()
Text        NoteProperty System.String Text=This is a string
#>

When stripping the surplus like below, Definition is back to 'string Text2=This is a string'

Exporting to Clixml and re-importing does the same

$WhatHappensToText | ConvertTo-Json | ConvertFrom-Json | Get-Member
<# Outputs
TypeName: System.Management.Automation.PSCustomObject

Name        MemberType   Definition
----        ----------   ----------
Equals      Method       bool Equals(System.Object obj)
GetHashCode Method       int GetHashCode()
GetType     Method       type GetType()
ToString    Method       string ToString()
Text        NoteProperty string Text=This is a string
#>

If I add the new NoteProperty like this, Definition is 'string Text2=This is a string' as I like it

$object2 = $object | Select-Object *
$object2 | foreach-object {$_ | Add-Member -MemberType NoteProperty -Name Text2 -Value $_.Text}
$object2 | Get-Member
<# Outputs
TypeName: Selected.System.Management.Automation.PSCustomObject

Name        MemberType   Definition
----        ----------   ----------
Equals      Method       bool Equals(System.Object obj)
GetHashCode Method       int GetHashCode()
GetType     Method       type GetType()
ToString    Method       string ToString()
Text        NoteProperty string Text=This is a string
Text2       NoteProperty string Text2=This is a string
#>

I have the following questions:

Why is the @{Name='Name'; Expression = {'Expression'}} technique adding System.String to the Definition and not string like in the Add-Member scenario?

Is there a way to make the @{Name='Name'; Expression = {'Expression'}} technique add a string and not a System.String?


Solution

  • Why is the @{Name='Name'; Expression = {'Expression'}} technique [i.e., using a calculated property] adding System.String to the Definition and not string like in the Add-Member scenario?

    This is a side effect of the unfortunate fact that the calculated-property technique creates a [psobject] wrapper around the property value specified.

    [psobject] is meant to be a transparent helper type used behind the scenes, and while such wrappers are typically invisible, they can situationally result in different behavior, such as in the case at hand.

    This problematic behavior, along with a list of scenarios where the behavior changes, is discussed in GitHub issue #5579.


    Is there a way to make the @{Name='Name'; Expression = {'Expression'}} technique add a string and not a System.String?

    This requires avoiding the [psobject] wrapper, which isn't possible with the calculated-property technique.

    Your options are:

    • Either: Use Add-Member, as in your question; if you add the -PassThru switch, the decorated object is passed through, allowing you to use the call as part of a pipeline:

      [object]::new() | Add-Member -PassThru Text Hi! | Get-Member
      
      • That said, direct use of Add-Member in a pipeline doesn't allow you to determine the property value dynamically, based on each input object. While you can remedy that by wrapping the call in a ForEach-Object call, the technique below is more efficient.
    • Or: Use the intrinsic psobject property to add a property to an existing object:

      [object]::new() | 
        ForEach-Object {
          # Add a property whose value is derived from the current input object.
          $_.psobject.Properties.Add(
            [psnoteproperty]::new('Text', "Hash code: $($_.GetHashCode())")
          )
          $_ # Pass the modified object through.
        } |
        Get-Member
      
      • Since using this technique in the pipeline requires use of ForEach-Object anyway, the property value can be derived dynamically from the current pipeline input object, via the automatic $_ variable, as shown.