Search code examples
powershellselect-object

Generated string names in Calculated Properties aren't accepted by select cmdlet


I want to generate the following table:

AAA BBB CCC
--- --- ---
 10  10  10
 10  10  10
 10  10  10
 10  10  10
 10  10  10

So I write the following code using a foreach loop to generate the column names:

$property = @('AAA', 'BBB', 'CCC') | foreach {
    @{ name = $_; expression = { 10 } }
}
@(1..5) | select -Property $property

But I get the following error saying the name is not a string:

select : The "name" key has a type, System.Management.Automation.PSObject, that is not valid; expected type is System.String.
At line:4 char:11
+ @(1..5) | select -Property $property
+           ~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) [Select-Object], NotSupportedException
    + FullyQualifiedErrorId : DictionaryKeyIllegalValue2,Microsoft.PowerShell.Commands.SelectObjectCommand

To get the code work, I have to convert the $_ to string like below:

$property = @('AAA', 'BBB', 'CCC') | foreach {
    @{ name = [string]$_; expression = { 10 } }
}
@(1..5) | select -Property $property

Or like below:

$property = @('AAA', 'BBB', 'CCC') | foreach {
    @{ name = $_; expression = { 10 } }
}
$property | foreach { $_.name = [string]$_.name }
@(1..5) | select -Property $property

The question is: the $_ is already a string. Why do I have to convert it to string again? And why select thinks that the name is PSObject?

To confirm that it's already a string, I write the following code to print the type of name:

$property = @('AAA', 'BBB', 'CCC') | foreach {
    @{ name = $_; expression = { 10 } }
}
$property | foreach { $_.name.GetType() }

The following result confirms that it's already a string:

IsPublic IsSerial Name                                     BaseType            
-------- -------- ----                                     --------            
True     True     String                                   System.Object       
True     True     String                                   System.Object       
True     True     String                                   System.Object       

I know that there are many other easier ways to generate the table. But I want to understand why I have to convert a string to string to make the code work, and why select doesn't think that the string is a string. For what it's worth, my $PSVersionTable.PSVersion is:

Major  Minor  Build  Revision
-----  -----  -----  --------
5      1      18362  1474    

Solution

  • You're seeing the unfortunate effects of incidental, normally invisible [psobject] wrappers PowerShell uses behind the scenes.

    In your case, because the input strings are supplied via the pipeline, they get wrapped in and stored as [psobject] instances in your hashtables, which is the cause of the problem.

    The workaround - which is neither obvious nor should it be necessary - is to discard the wrapper by accessing .psobject.BaseObject:

    $property = 'AAA', 'BBB', 'CCC' | ForEach-Object {
        @{ name = $_.psobject.BaseObject; expression = { 10 } }
    }
    1..5 | select -Property $property
    

    Note:

    • In your case, a simpler alternative to .psobject.BaseObject (see the conceptual about_Intrinsic Members help topic) would have been to call .ToString(), given that you want a string.

    • To test a given value / variable for the presence of such a wrapper, use -is [psobject]; with your original code, the following yields $true, for instance:

      • $property[0].name -is [psobject]
      • Note, however, that this test is meaningless for [pscustomobject] instances, where it is always $true (custom objects are in essence [psobject] instances without a .NET base objects - they only have dynamic properties).

    That the normally invisible [psobject] wrappers situationally, obscurely result in behavioral differences is arguably a bug and the subject of GitHub issue #5579.


    Simpler and faster alternative, using the .ForEach() array method:

    $property = ('AAA', 'BBB', 'CCC').ForEach({
      @{ name = $_; expression = { 10 } }
    })
    1..5 | select -Property $property
    

    Unlike the pipeline, the .ForEach() method does not wrap $_ in [psobject], so the problem doesn't arise and no workaround is needed.

    Using the method is also faster, although note that, unlike the pipeline, it must collect all its input in memory up front (clearly not a problem with an array literal).