xmlpowershell

Why does PowerShell require explicit conversion to string for a string variable


According to the documentation, the Join-Path function returns a string, so I should be able to use the returned value as string, but in fact PowerShell raised below error unless I make a explicit conversion for the result of Join-Path. Why do I need to convert a variable, which is already a string, to type string?

Cannot set "path" because only strings can be used as values to set XmlNode properties.
At C:\Users\Rayman\Downloads\example.ps1:6 char:1
+ $node.path = (Join-Path 'C:\Users\Rayman\Downloads' 'VSCode-win32-x64 ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], SetValueException
    + FullyQualifiedErrorId : XmlNodeSetShouldBeAString

Here is a mini example:

$xmlPath = 'C:\Users\Rayman\Downloads\example.xml'
[xml]$xml = Get-Content $xmlPath
$node = $xml.SelectSingleNode('//Software')
$node.SetAttribute('path', '')
# I don't know why I have to use [string] to convert a string type variable
$node.path = [string](Join-Path 'C:\Users\Rayman\Downloads' 'VSCode-win32-x64-1.73.1')
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
    <Software id="VSCode" />
</Configuration>

My PowerShell version is 5.1.19041.5965.


Solution

  • The answer on Why is PowerShell telling me a String is not a String? And only when calling String.Length, first is very closely related however not exactly the same. In both cases the issue originates by the PowerShell XML adapter unable to handle a psobject wrapped object; though in the linked answer the issue was resolved in V3... by no longer wrapping the .NET object in psobject presumably after accessing a member but the adapter was never fixed back then.

    Some background first, it's important to note that all output from a cmdlet (binary) that is not null is wrapped in psobject. This is due to the use of PSCmdlet.WriteObject, digging deep in the source we can see:

    internal void _WriteObjectSkipAllowCheck(object sendToPipeline)
    {
        ThrowIfStopping();
    
        if (AutomationNull.Value == sendToPipeline)
            return;
    
        sendToPipeline = LanguagePrimitives.AsPSObjectOrNull(sendToPipeline);
    
        this.OutputPipe.Add(sendToPipeline);
    }
    

    And we can see this is true by doing:

    (Join-Path 'C:\Users\Rayman\Downloads' 'VSCode-win32-x64-1.73.1') -is [psobject]
    # True
    

    Then we can do some debugging on Windows PowerShell 5.1, first reproduce the error:

    $node = ([xml] '<Configuration><Software id="VSCode" /></Configuration>').
        SelectSingleNode('//Software')
    
    $node.SetAttribute('path', '')
    $node.path = Join-Path 'C:\Users\Rayman\Downloads' 'VSCode-win32-x64-1.73.1'
    

    Inspecting $Error[0].Exception.StackTrace we can see the issue originates, as explained in the linked answer, in the PowerShell Xml adapter:

    at System.Management.Automation.XmlNodeAdapter.PropertySet(PSProperty property, Object setValue, Boolean convertIfPossible)
    at System.Management.Automation.Adapter.BasePropertySet(PSProperty property, Object setValue, Boolean convert)
    at System.Management.Automation.PSProperty.set_Value(Object value)
    at System.Management.Automation.Language.PSSetMemberBinder.SetAdaptedValue(Object obj, String member, Object value)
    at System.Dynamic.UpdateDelegates.UpdateAndExecute2[T0,T1,TRet](CallSite site, T0 arg0, T1 arg1)
    at System.Management.Automation.Interpreter.DynamicInstruction`3.Run(InterpretedFrame frame)
    at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
    

    We can see how the PowerShell team fixed the adapter problem for newer versions, if we look at XmlNodeAdapter.PropertySet we can see they've added the call to convert the incoming object to string:

    protected override void PropertySet(PSProperty property, object setValue, bool convertIfPossible)
    {
        // XML is always a string so implicitly convert to string
        string valueString = LanguagePrimitives.ConvertTo<string>(setValue); // <- Here!
        
        ...
        ...
    }
    

    This is essentially the same as casting [string] to the output of Join-Path in your example, casting is pretty much the same as a call to LanguagePrimitives.ConvertTo<T>(..):

    $result = [System.Management.Automation.LanguagePrimitives]::ConvertTo(
        (Join-Path 'C:\Users\Rayman\Downloads' 'VSCode-win32-x64-1.73.1'),
        [string])
    
    $result -is [psobject] # False