Powershell: -as operator does not recognize System.Management.Automation.PSCustomObject instances as such

Created on 25 Jul 2017  路  20Comments  路  Source: PowerShell/PowerShell

Related: #5579

Steps to reproduce

# Create a [System.Management.Automation.PSCustomObject]
# Note that you cannot create a System.Management.Automation.PSCustomObject
# instance with `New-Object System.Management.Automation.PSCustomObject ...`.
# [pscustomobject] is actually [psobject] ([System.Management.Automation.PSObject])
$co = [pscustomobject] @{ foo = 'bar' }

$co.GetType().FullName

'---'

# This should be and is $True.
# Note that using `-is [pscustomobject]` is NOT equivalent and while it also returns $True,
# it does so for any object that has an (invisible) extra [psobject] wrapper, due to the identity
# of [pscustomobject] and [psobject]. All objects returned from *cmdlets* have that extra
# wrapper; e.g., `(Get-Item /) -is [pscustomobject]` yields $True also, while the seemingly
# equivalent  `[System.IO.DirectoryInfo]::new('/') -is [pscustomobject]`  doesn't - see #5579
$co -is [System.Management.Automation.PSCustomObject]

'---'

# The $co -as ... expression should effectively pass $co through, but doesn't.
# Note that using `-as [pscustomobject]` is NOT equivalent - and pointless, because 
# it passes *any* object through, given that it is the same as `-as [psobject]`, which  is what ALL 
# objects in PowerShell are (semi-secretly) wrapped in.
$null -ne ($co -as [System.Management.Automation.PSCustomObject])

Expected behavior

System.Management.Automation.PSCustomObject
---
True
---
True

Actual behavior

System.Management.Automation.PSCustomObject
---
True
---
False # !!

Environment data

PowerShell Core v6.0.0-beta.4 on macOS 10.12.5
PowerShell Core v6.0.0-beta.4 on Ubuntu 16.04.2 LTS
PowerShell Core v6.0.0-beta.4 on Microsoft Windows 10 Pro (64-bit; v10.0.15063)
Windows PowerShell v5.1.15063.413 on Microsoft Windows 10 Pro (64-bit; v10.0.15063)
Issue-Discussion WG-Engine

All 20 comments

I just ran into this recently. Has there been any discussion on this outside off the GitHub issue? I'm a bit surprised that there are no further comments here. Being able to use $foo -as [PSCustomObject] successfully on hashtables would be a big boost in usability as it fits in a pipeline where the alternative form does not. It's also quite unintuitive that [PSCustomObject]$foo functions differently from $foo -as [PSCustomObject], at least in where $foo is a hashtable.

Isn't this by design? PSCustomObject can't be used to cast objects. It doesn't have any public constructors.

$object = [psobject]::new()
[System.Management.Automation.PSCustomObject]$object

Result:

Cannot convert the "" value of type "System.Management.Automation.PSCustomObject" to type
"System.Management.Automation.PSCustomObject".
At line:1 char:1
+ [System.Management.Automation.PSCustomObject]$object
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) [], RuntimeException
    + FullyQualifiedErrorId : ConvertToFinalInvalidCastException

From https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.pscustomobject?view=powershellsdk-1.1.0

Serves as a placeholder BaseObject when PSObject's constructor with no parameters is used.

If you can't cast an object as another type, -as returns false as it is intended to.

@markekraus:

Thanks for telling us how it's _implemented_, which is not necessarily the same as the _design intent_, however.

If we consult the documentation (granted, historically speaking, that is unfortunately a somewhat shaky basis):

The -as operator tries to convert the input object to the specified .NET Framework type

Now, what would you expect if the input object already _is_ of the specified type?

I certainly would expect to _pass the input object through_, as is indeed the case for _any type OTHER than [System.Management.Automation.PSCustomObject]_:

1 -as [int]  # passes integer 1 through
(get-date) -as [datetime]  # passes the date output Get-Date through
...

So, if the following returns $true (which it currently does):

[pscustomobject] @{ foo = 'bar' } -is [System.Management.Automation.PSCustomObject]

doesn't it stand to reason that the following - which currently returns $null - would pass the LHS _through_?

[pscustomobject] @{ foo = 'bar' } -as [System.Management.Automation.PSCustomObject]

The issue is further confused by the unfortunate identity of type accelerators [psobject] and [pscustomobject], _both_ of which point to [System.Management.Automation.PSObject], so the use of [pscustomobject] on the RHS of -as is the same as [psobject] / [System.Management.Automation.PSObject], which passes _anything_ through:

> 1 -as [pscustomobject]   # same as: 1 -as [psobject]
1

(As an aside: This behavior is in itself debatable: while _internally_, every object is wrapped in a [psobject] instance, there's no reason to reflect that to the _outside world_.)

how it's implemented, which is not necessarily the same as the design intent, however.

I don't believe that these are separate things. But that is a matter of opinion.

Now, what what you expect if the input object already is of the specified type?

I would expect conversion to fail if there is not a way to convert the object. Not all types can convert instances of their own type to their own type. PSCustomObject happens to be one of those. Kind of surprising, but true none the less.

But, I can agree the documentation on this is lacking. Like several other parts of the documentation, there is a false assumption that the reader is familiar with .NET and C#. -as behaves pretty much like as in C#. The documentation on as is pretty good for this topic.

I don't believe that these are separate things.

An _intent_ can have a _flawed implementation_. Q.E.D.

But, I can agree the documentation on this is lacking.

Yes, the documentation is lacking, but - more importantly - the _actual behavior_ is lacking _common sense_:

I would expect conversion to fail if there is not a way to convert the object. Not all types can convert instances of their own type to their own type. [...] -as behaves pretty much like as in C#.

~You're correct about _C#_'s behavior, but~:

  • ~I would argue that C#'s behavior in this regard equally lacks common sense.~
  • ~Conversely, _PowerShell isn't C#_ and _shouldn't be bound by C#'s behavior_ where it doesn't make sense.
    The fact that something like 1 -as [psobject] already passes 1 through while pretending that 1 _is still System.Int32_ ((1 -as [psobject]).GetType().FullName) shows you that PowerShell is _already_ deviating from C#. (Yet, 1 -is [psobject] returns $False - another head-scratcher)).~

~Of course, C# fixing the behavior of as would also solve the problem on the PowerShell side as well. (There may be a ton of reasons why this is not / no longer an option, but I'm blissfully ignorant of them.)~ (see below)


For the record, the as documentation states:

"

expression as type  

The code is equivalent to the following expression except that the expression variable is evaluated only one time.

expression is type ? (type)expression : (type)null  
Note that the as operator performs only reference conversions, nullable conversions, and boxing conversions. The `as` operator can't perform other conversions, such as user-defined conversions, which should instead be performed by using cast expressions.

"

~As you correctly explain, using cast (System.Management.Automation.PSCustomObject) with _anything_ - even an instance of that type itself - results in null, due to the lack of _any_ public constructors.~

An intent can have a flawed implementation. Q.E.D.

I'm say that the intent, implementation, operation and everything about -as is perfectly fine, same with as.

I would argue that C#'s behavior in this regard equally lacks common sense.

I really don't think it does. It all made sense to me. the surprising thing is that 1) some classes cannot convert to themselves and 2) PSCustomObject is one of those. Everything else about -as/as makes sense.

Conversely, PowerShell isn't C# and shouldn't be bound by C#'s behavior where it doesn't make sense.

Agreed, however, i disagree that -as should be any different than as because they both make perfect sense in how they operate and making some silly exception for PSCustomObject would be ridiculous.

The fact that something like 1 -as [psobject] already passes 1 through while pretending that 1 is still System.Int32 ((1 -as [psobject]).GetType().FullName) shows you that PowerShell is already deviating from C#

Nope, not deviating at all. Acts exactly the same as System.Object does with as in C#:

$Code = @'
using System;

namespace Test 
{
    public static class AsObject
    {
        public static string GetResult()
        {
            Int32 testInt = 1;
            Object obj = testInt as Object;
            string result = obj.GetType().FullName;
            return result;
        }
    }
}
'@
Add-Type -TypeDefinition $Code -Language CSharp 
[Test.AsObject]::GetResult()

Result:

none System.Int32

the surprising thing is that 1) some classes cannot convert to themselves and 2) PSCustomObject is one of those. Everything else about -as/as makes sense.

And it is that very surprise that is my point (but I appreciate that you introduced me to the subtler points of as).

making some silly exception for PSCustomObject would be ridiculous.

I'm not advocating an _exception_; I'm advocating that -as honor one fundamental expectation: if the LHS of -as already _is_ of the RHS type - _whatever that type may be_ - pass it through - which is in fact what C# does, after all:

// Obtain a [System.Management.Automation.PSCustomObject] instance
// and store it in a generic [System.Object] variable.
// Note that the [System.Management.Automation.PSCustomObject] cannot be instantiated
// directly - no public constructors - and must instead be created indirectly, via a 
// [System.Management.Automation.PSObject] wrapper, whose `.BaseObject` property
// contains the custom object.
object o = new PSObject().BaseObject;

// OK: Casting back to [System.Management.Automation.PSCustomObject] works.
PSCustomObject co = (PSCustomObject) o;

// OK with `as` too.
PSCustomObject co_as = o as PSCustomObject; 

So, if PowerShell tells me the following:

> $co = [pscustomobject] @{ one = 1 }; $co.GetType().FullName; $co -is [System.Management.Automation.PSCustomObject]
System.Management.Automation.PSCustomObject
$True

that is, if it treats the object as a bona fide [System.Management.Automation.PSCustomObject] instance in these contexts (which it should), then, of course, I'd expect

$co -as [System.Management.Automation.PSCustomObject]

to pass $co through.

_Presumably_, the internal [psobject] wrapper gets in the way (I haven't looked).


Acts exactly the same as System.Object does with as in C#:

Except that every .NET type is indeed derived from System.Object - _unlike from [psobject]_.

The [psobject] wrapper is a _PS implementation detail_ and that it surfaces prominently in some places (see #5551 and #5579) is unfortunate.

Just as as System.Object is pointless in C# (_anything_ is passed through), -as [psobject] is pointless in PowerShell - and behaves incorrectly to boot.

The unfortunate identity of [psobject] and [pscustomobject] means that people _will_ try something like $obj -as [pscustomobject] - and will be surprised to find that it passes _anything_ through.

To contrast the current -as behavior with that of -is:

-is [pscustomobject] does return $True for [System.Management.Automation.PSCustomObject] instances and true [System.Management.Automation.PSObject] instances _only_,
so it behaves more sensibly, but only _accidentally_:

> $co = [pscustomobject] @{ one = 1 }; $co -is [pscustomobject]
True

> 1 -is [pscustomobject]
False 

[pscustomobject] @{ one = 1 } produces a [psobject]-wrapped [System.Management.Automation.PSCustomObject] instance, which is why testing with -is [pscustomobject] - which is the same as -is [psobject] - succeeds.

Unfortunately, _other_ types are situationally also (extra-)wrapped in [psobject] - see #5579 - which can yield false positives:

> (Write-Output 1) -is [pscustomobject]
True  # !! - the use of *command* output made the difference

FYI: PSCustomObject is essentially a "typeless" object - a PSObject whose base object is itself. It's used when you need properties but don't have or need a type. For example - ... | select-object -property foo,bar,baz will extract the foo, bar and baz properties from the pipeline input object, then create a PSCustomObject and attach the extracted properties to that PSCustomObject. The resulting object has a fixed set of properties but it doesn't really have a type. You don't want to create a new concrete .NET type every time you run select-object, or import-json, or invoke a remote command or import CliXML, etc. So anywhere an object of no fixed type but with specific properties are needed, a PSCustomObject is used.

@BrucePay That much is clear to me.

I think the crux of this particular issue is whether -as and PSCustomObject are acting as by design. I believe that they are. I believe it may be surprising and somewhat confusing to the average PowerShell user, even though it makes perfect sense when you understand it in depth. If it is by design then the question becomes whether we consider special casing it somehow.

My personal stance is that current behavior is by design and perfectly fine as is. But, it would great if someone with better understanding clarify that point.

PowerShell tells white lies all the time, to shield us from the disconcerting complexities of the .NET framework and static typing.

And that's fine and dandy: I loves me the free-flowing, carefree way that types can be thrown around in PowerShell - for the most part, it's a great experience.

All I ask for is to be lied to _consistently_, so I can continue to find comfort in the illusion of a simpler world.

If
([pscustomobject] @{ prop = 1 }).GetType().FullName
yields System.Management.Automation.PSCustomObject, but
([pscustomobject] @{ prop = 1 }) -as [System.Management.Automation.PSCustomObject ] yields $null, my head starts spinning.

Is a custom object mysteriously not itself? What's happening?

Granted, -as [pscustomobject] _does_ work as expected, but:

(a) only because [pscustomobject] is really [psobject] (my head's RPM are increasing)

(b) it is useless, because something like (Get-Item /) -as [pscustomobject] is passed through for _any_ object (because, behind the scenes, everything is wrapped in [psobject]).

No one except the architects / maintainers of the language should have to know any of the behind-the-scenes-but-not-fully intricacies mentioned here.

Let me try to summarize the issue more succinctly:

If a custom object:

  • _self-reports_ as type [System.Management.Automation.PSCustomObject] via .GetType()

  • is correctly recognized by -is as that type

why oh why should -as [System.Management.Automation.PSCustomObject] not work?

If the -as [System.Management.Automation.PSCustomObject] use case strikes you as too exotic, let's recap the related head-spinners:

  • -as [pscustomobject] is true for ANY object and therefore useless.

  • -is [pscustomobject] is _often_ true for non-custom objects, namely when they have an _extra_ [psobject] wrapper - see #5579

    • e.g., [System.IO.DirectoryInfo]::new('/') -is [pscustomobject] is $False (OK), but it is $True (ouch) for the seemingly equivalent (Get-Item /) -is [pscustomobject].

@markekraus By design or not, the current behavior is unintuitive and likely to cause errors (as I experienced not too long ago). You said it yourself:

I believe it may be surprising and somewhat confusing to the average PowerShell user.

PowerShell functionality should be designed for its users, and leaking implementation details, no matter how much they make sense after one has been staring at the C# code, should be avoided wherever possible.

@bgshacklett The problem is that this would need to be special-cased. Where to we stop with the special-casing? There are a bunch of objects you would not be able to -as them to their own object type in PowerShell and .NET.

It may be surprising, but so is the pipeline and objects and a million other aspects of PowerShell.

I think this is better fixed with documentation, rather than risk breaking -as to special-case System.Management.Automation.PSCustomObject. If there were a large number of users tripped up by this regularly and the risk of regression to special-case it was low, I would champion the cause special-casing it. The risk for regression is high (I think, at least) and the number of users this trips up is low and if we documented it in the as operator documentation it could be remedied.

I'm all for making the language easy for users where it makes sense. I just don't think this is a worthy cause.

Where to we stop with the special-casing?

All we need to address are leaky abstractions that cause confusion and/or repeated mistakes and/or require cumbersome workarounds.
And, to be clear, that is _not special-casing_ - that's _fixing leaky abstractions_.

Incidentally, this is not just about -as, but also about -is, as I've hopefully clearly demonstrated.

There are a bunch of objects you would not be able to -as them to their own object type in PowerShell and .NET.

To recap the previous finding: _any_ .NET instance is an instance of its type with respect to as - irrespective of the presence of public constructors, parameterless constructors, explicit conversions, ... - see below for an example.


using System;

public static class Program {
    public class Foo {
        // Private constructor
        private Foo() {}
         // Static method that creates an instance via the private constructor.
        public static Foo Create() { return new Foo(); }
    }

    public static void Main() {        
         // This outputs 'Foo', demonstrating that the Foo instance created with the
         // static .Create method indeed recognized itself as a Foo instance with `as`
        Console.WriteLine((Foo.Create() as Foo).GetType().Name);
    }
}

And, to be clear, that is not special-casing - that's fixing leaky abstractions.

This _is_ special-casing. You cannot cast some objects as themselves. PSCustomObject is one of those. You want to make -as work for PSCustomObject when it would not normally work. That is the definition of special casing.

$Code = @'
using System;
using System.Management.Automation;

namespace Test 
{
    public static class As
    {
        public static bool AsPSCustomObject(object InputObject)
        {
            PSCustomObject result = InputObject as PSCustomObject;
            if (null != result)
            {
                return true;
            }
            else
            {
                return false;
            }
        }

        public static bool AsPSObject(object InputObject)
        {
            PSObject result = InputObject as PSObject;
            if (null != result)
            {
                return true;
            }
            else
            {
                return false;
            }
        }
    }
}
'@
Add-Type -TypeDefinition $Code
$TestObject = [PSCustomObject]@{A = 'a'}
[Test.As]::AsPSCustomObject($TestObject)
[Test.As]::AsPSObject($TestObject)

result

False
True

You want to change that behavior from what would happen in .NET to something different in PowerShell. That would most absolutely be special-casing.

Your test isn't exactly accurate. But I don't have the time to create an example or explain why.

You cannot cast some objects as themselves.

In C# you can, always. That is, if objects are cast to their _true_ type (as your example demonstrates).

That PowerShell pretends that a [psobject] (without a base object) is really a [System.Management.Automation.PSCustomObject] instance when you _reflect_ on the type with .GetType() and use -is [System.Management.Automation.PSCustomObject], but then pretends that it isn't when you use -as is the true special-casing here: special-casing of common sense.

-as in PowerShell _looks like_ C#'s as and _behaves like it_ in all cases but this.
That it doesn't, is a leak in the implementation that needs to be plugged.
Ditto for the related -is problems.

(Whether special-casing _behind the scenes_ is needed to plug this leak is immaterial.)

The larger issue here is really [psobject] peeking from behind the curtain in several places, causing outright problems in this case and subtler variations in behavior in others.


On a meta note:

Your test isn't exactly accurate. But I don't have the time to create an example or explain why.

Please don't post such comments. They add nothing to the discussion and only serve to antagonize.

@BrucePay:

That's great background information.

Even though no two [System.Management.Automation.PSCustomObject] instances are therefore guaranteed to have the same members, it is still useful to have the ability to distinguish them from instances of regular types; e.g., you may want a function to accept a [pscustombject] instance as a mockup in lieu of a regular type and then detect that case in the function.

With -is that works (though, regrettably, only with the _full_ type name, due to [pscustombject] really being [psobject]) , but with -as it doesn't.

Please don't post such comments. They add nothing to the discussion and only serve to antagonize.

Not my intent. It would just take more time to explain than I can give at the moment.

Was this page helpful?
0 / 5 - 0 ratings