Search code examples
powershellpscustomobjectadd-member

PowerShell Add-Member ... odd behaviour?


Can anyone explain what appears to be odd behaviour when using Add-Member to add an attribute to a PSCustomObject object? For some reason, once you've added the member, the object is represented like a hashtable when displayed, even though it's still a PSCustomObject, e.g.:

Create a simple object:

[PSCustomObject] $test = New-Object -TypeName PSCustomObject -Property @{ a = 1; b = 2; c = 3; }

Check its type:

$test.GetType();

...which returns:

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     False    PSCustomObject                           System.Object

Then get its contents:

$test;

...which returns:

c b a
- - -
3 2 1

Add a property:

Add-Member -InputObject $test -MemberType NoteProperty -Name d -Value 4 -TypeName Int32;

Confirm its type hasn't changed:

$test.GetType();

...which returns:

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     False    PSCustomObject                           System.Object

Finally, get its contents again:

$test;

...which returns:

@{c=3; b=2; a=1; d=4}

Whereas I was hoping to get:

c d b a
- - - -
3 4 2 1

Any thoughts would be welcome, as I've been picking away at this for ages.

Many thanks


Solution

  • Omit the -TypeName Int32 argument in the Add-Member call: it does not specify the -Value argument's type.

    # Do NOT use -TypeName, unless you want to assign the custom
    # object stored in $test a specific ETS type identity.
    Add-Member -InputObject $test -MemberType NoteProperty -Name d -Value 4
    

    Note that Int32 ([int]) is implied for an unquoted argument that can be interpreted as a decimal number that fits into the [int] range, such as 4.

    If you do need to specify the type explicitly, use a cast in an expression, e.g. ... -Value ([long] 4)


    As for what you tried:

    -TypeName Int32 assigns the full name of this type, System.Int32, as the first entry in the list of ETS type names associated with the input object. (ETS is PowerShell's Extended Type System.)

    You can see this list by accessing the intrinsic .pstypenames property (and you also see its first entry in the header of Get-Member's output):

    PS> $test.pstypenames
    
    System.Int32
    System.Management.Automation.PSCustomObject
    System.Object
    

    As you can see, System.Int32 was inserted before the object's true .NET type identity (System.Management.Automation.PSCustomObject); the remaining entries show the inheritance hierarchy.

    Such ETS type names are normally used to associated custom behaviors with objects, irrespective of their true .NET type identities, notably with respect to extending the type (see about_Types.ps1xml) or associating custom display formatting with it (see about_Format.ps1xml).

    Since PowerShell then thought that your [pscustomobject] was of type [int] (System.Int32), it applied its usual output formatting for that type to it, which in essence means calling .ToString() on the instance.

    Calling .ToString() on a [pscustomobject] instance results in the hashtable-like representation you saw, as the following example demonstrates:

    • Note the use of the PSv3+ syntactic sugar for creating the [pscustomobject] instance ([pscustomobject] @{ ... }), which is not only more efficient than a New-Object call but also preserves the property order.
    PS> ([pscustomobject] @{ a = 'foo bar'; b = 2}).psobject.ToString()
    
    @{a=foo bar; b=2}
    

    Note the use of the intrinsic .psobject property, which is necessary to work around a long-standing bug where direct .ToString() calls on [pscustomobject] instances return the empty string - see GitHub issue #6163.