I'm building a PowerShell WinForms GUI to pick an Active Directory OU using a TreeView. Inside the dialog, the selected node's .Tag is always a string (as expected). However, when I call the function and assign its return value to a variable in my main form, it always becomes a System.Object[] array of numbers (which appear to be ASCII codes), not a string.
This causes downstream issues when I try to use the returned value. I have included debug popups at each step to show the type and value of the variable.
I included the script below. Click "Find Source OU" and select an OU in the dialog. I made some debug popups after selecting the OU: The first popup inside the picker shows the Tag as a string. After the picker closes, the debug shows the type and value of $selectedOU (always System.Object[] with numbers). Another popup shows the raw elements and their types (all System.Int32). The next debug shows the type and value after assignment to $script:selectedOUFullDN. The final debug before the move shows the type and value of $targetPath.
What is happening: Inside the picker: The Tag is always a string. After the picker: $selectedOU is always a System.Object[] array of numbers (ASCII codes), not a string. After assignment: The debug shows the type and value after assignment to $script:selectedOUFullDN. Before move: The debug shows the type and value of $targetPath.
Where is the issue? It seems that when the return value from Show-OUBrowser is assigned to $selectedOU in the main form, it always becomes an array of numbers (ASCII codes) instead of a string. This is confirmed by the debug popups, which show the type and value at each step.
The question: Why does PowerShell always return an array of numbers from a WinForms dialog function, even though the function returns a string? Is there a more reliable way to always get a string from such a dialog? What is the best practice to handle this scenario?
Minimal Script (copy-paste to test):
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.DirectoryServices
# --- Main Form ---
$form = New-Object System.Windows.Forms.Form
$form.Text = "MoveOU Minimal"
$form.Size = New-Object System.Drawing.Size(600, 400)
$form.StartPosition = "CenterScreen"
# --- File Path Label ---
$filePathLabel = New-Object System.Windows.Forms.Label
$filePathLabel.Location = New-Object System.Drawing.Point(10, 20)
$filePathLabel.Size = New-Object System.Drawing.Size(560, 20)
$filePathLabel.Text = "No file selected"
# --- Browse Button ---
$openFileButton = New-Object System.Windows.Forms.Button
$openFileButton.Location = New-Object System.Drawing.Point(10, 50)
$openFileButton.Size = New-Object System.Drawing.Size(75, 25)
$openFileButton.Text = "Browse"
$openFileDialog = New-Object System.Windows.Forms.OpenFileDialog
$openFileDialog.Filter = "CSV files (*.csv)|*.csv"
$openFileDialog.Title = "Select a CSV File"
# --- Find Source OU Button ---
$findOUButton = New-Object System.Windows.Forms.Button
$findOUButton.Location = New-Object System.Drawing.Point(10, 90)
$findOUButton.Size = New-Object System.Drawing.Size(120, 25)
$findOUButton.Text = "Find Source OU"
# --- Selected OU Label ---
$selectedOULabel = New-Object System.Windows.Forms.Label
$selectedOULabel.Location = New-Object System.Drawing.Point(140, 95)
$selectedOULabel.Size = New-Object System.Drawing.Size(440, 20)
$selectedOULabel.Text = "No OU selected"
# --- Process Button ---
$processButton = New-Object System.Windows.Forms.Button
$processButton.Location = New-Object System.Drawing.Point(10, 130)
$processButton.Size = New-Object System.Drawing.Size(75, 25)
$processButton.Text = "Process"
# --- Output TextBox ---
$outputTextBox = New-Object System.Windows.Forms.TextBox
$outputTextBox.Location = New-Object System.Drawing.Point(10, 170)
$outputTextBox.Size = New-Object System.Drawing.Size(560, 180)
$outputTextBox.Multiline = $true
$outputTextBox.ScrollBars = [System.Windows.Forms.ScrollBars]::Vertical
$outputTextBox.ReadOnly = $true
# --- Add Controls ---
$form.Controls.Add($filePathLabel)
$form.Controls.Add($openFileButton)
$form.Controls.Add($findOUButton)
$form.Controls.Add($selectedOULabel)
$form.Controls.Add($processButton)
$form.Controls.Add($outputTextBox)
# --- Browse Button Event ---
$openFileButton.Add_Click({
$result = $openFileDialog.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
$filePathLabel.Text = $openFileDialog.FileName
}
})
# --- OU Picker Dialog (Base Function, Do Not Change) ---
function Show-OUBrowser {
$ouBrowserForm = New-Object System.Windows.Forms.Form
$ouBrowserForm.Text = "Active Directory OU Browser"
$ouBrowserForm.Size = New-Object System.Drawing.Size(500, 600)
$ouBrowserForm.StartPosition = "CenterScreen"
$treeView = New-Object System.Windows.Forms.TreeView
$treeView.Dock = [System.Windows.Forms.DockStyle]::Fill
$ouBrowserForm.Controls.Add($treeView)
function Populate-TreeView {
$domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$root = $domain.GetDirectoryEntry()
$rootNode = New-Object System.Windows.Forms.TreeNode($root.Name)
$rootNode.Tag = [string]$root.distinguishedName
$treeView.Nodes.Add($rootNode)
$searcher = New-Object System.DirectoryServices.DirectorySearcher($root)
$searcher.Filter = "(objectClass=organizationalUnit)"
$searcher.SearchScope = "Subtree"
$searcher.PropertiesToLoad.Add("name")
$searcher.PropertiesToLoad.Add("distinguishedName")
$results = $searcher.FindAll()
$nodesByDN = @{}
$nodesByDN[$root.distinguishedName] = $rootNode
foreach ($result in $results) {
$ou = $result.GetDirectoryEntry()
$dn = [string]$ou.distinguishedName.Value
$name = $ou.name.Value
$node = New-Object System.Windows.Forms.TreeNode($name)
$node.Tag = $dn
$parentDN = $dn -replace "^OU=$name,", ""
if ($nodesByDN.ContainsKey($parentDN)) {
$nodesByDN[$parentDN].Nodes.Add($node)
} else {
$rootNode.Nodes.Add($node)
}
$nodesByDN[$dn] = $node
}
$rootNode.Expand()
}
Populate-TreeView
# On double-click, show the Tag value and its type (debug)
$treeView.Add_NodeMouseDoubleClick({
if ($treeView.SelectedNode -ne $null) {
$tag = $treeView.SelectedNode.Tag
$type = $tag.GetType().FullName
$ascii = ($tag.ToCharArray() | ForEach-Object { [int]$_ }) -join ','
[void][System.Windows.Forms.MessageBox]::Show("Tag:`n$tag`nType: $type`nASCII: $ascii", "Debug Output")
$ouBrowserForm.DialogResult = [System.Windows.Forms.DialogResult]::OK
$ouBrowserForm.Close()
}
})
$result = $ouBrowserForm.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK -and $treeView.SelectedNode -ne $null) {
$tag = $treeView.SelectedNode.Tag
$type = $tag.GetType().FullName
$ascii = ($tag.ToCharArray() | ForEach-Object { [int]$_ }) -join ','
[void][System.Windows.Forms.MessageBox]::Show("RETURNED Tag:`n$tag`nType: $type`nASCII: $ascii", "Returned from OU Picker")
$returnValue = [string]$treeView.SelectedNode.Tag
return $returnValue
}
return $null
}
# --- Find Source OU Button Event ---
$findOUButton.Add_Click({
$selectedOU = Show-OUBrowser
# --- RAW DEBUG: Show type and elements if array ---
if ($selectedOU -is [object[]] -or $selectedOU -is [char[]]) {
$elementTypes = $selectedOU | ForEach-Object { "$($_.GetType().FullName): $_" }
[System.Windows.Forms.MessageBox]::Show(
"selectedOU (raw):`n$selectedOU`nType: $($selectedOU.GetType().FullName)`nElements:`n$($elementTypes -join "`n")",
"DEBUG RAW"
)
}
[System.Windows.Forms.MessageBox]::Show("selectedOU:`n$selectedOU`nType: $($selectedOU.GetType().FullName)", "DEBUG after picker")
if ($selectedOU -ne $null) {
$selectedOULabel.Text = "Selected: $selectedOU"
$script:selectedOUFullDN = $selectedOU
}
})
# --- Process Button Event ---
$processButton.Add_Click({
$output = ""
if ($filePathLabel.Text -eq "No file selected") {
[System.Windows.Forms.MessageBox]::Show("Please select a CSV file.", "Error")
return
}
if (-not $script:selectedOUFullDN) {
[System.Windows.Forms.MessageBox]::Show("Please select a target OU.", "Error")
return
}
$targetPath = $script:selectedOUFullDN
[System.Windows.Forms.MessageBox]::Show("targetPath:`n$targetPath`nType: $($targetPath.GetType().FullName)", "DEBUG before move")
$csvData = Import-Csv -Path $filePathLabel.Text
foreach ($row in $csvData) {
$object = $row.samaccountname
try {
$adObject = Get-ADUser -Filter "SamAccountName -eq '$object'" -Properties DistinguishedName -ErrorAction SilentlyContinue
if (-not $adObject) {
$adObject = Get-ADComputer -Filter "SamAccountName -eq '$object$'" -Properties DistinguishedName -ErrorAction SilentlyContinue
}
if ($adObject) {
$objectDN = $adObject.DistinguishedName
Move-ADObject -Identity $objectDN -TargetPath $targetPath
$output += "$object moved to $targetPath.`n"
} else {
$output += "Object $object not found in AD.`n"
}
} catch {
${err} = $_
$output += ("Error moving {0}: {1}`n" -f $object, ${err}.Exception.Message)
}
}
$outputTextBox.Text = $output
})
$form.ShowDialog() | Out-Null
After the picker: $selectedOU is always a System.Object[] array of numbers (ASCII codes)
What makes you think the numbers describe ASCII codepoints?
A PowerShell function can emit output at any time during its invocation lifecycle, long before any return
statement might be encountered. Any output not redirected or assigned will "bubble up" to the caller:
function outer {
# output from inner will be emitted as output from outer
inner
}
function inner {
# this will be emitted as output
1
# we can use a local assignment to capture the output of an expression
# 2 will never be emitted from this function
$null = 2
# this will be emitted as the _last_ output item
return 3
# this will never be reached, we've already returned
4
}
$output = outer
# this is $true despite no `3` expression present in `outer` and no `return` statements anywhere in `outer`
$output[1] -eq 3
With this in mind, there are 2 obvious sources of (small) numbers emanating from Show-OUBrowser
in the code you've shared - TreeNodeCollection.Add
and StringCollection.Add
:
function Show-OUBrowser {
# ...
function Populate-TreeView {
# TreeNodeCollection.Add(...) returns insert index
$treeView.Nodes.Add($rootNode)
# StringCollection.Add(...) returns insert index
$searcher.PropertiesToLoad.Add("name")
$searcher.PropertiesToLoad.Add("distinguishedName")
# ...
foreach ($result in $results) {
# ...
if ($nodesByDN.ContainsKey($parentDN)) {
# TreeNodeCollection.Add(...) returns insert index
$nodesByDN[$parentDN].Nodes.Add($node)
} else {
# TreeNodeCollection.Add(...) returns insert index
$rootNode.Nodes.Add($node)
}
# ...
}
# ...
}
# all those integers emitted by Populate-TreeView will be output here
Populate-TreeView
# ...
}
Every time you call $treeNode.Nodes.Add
or $searcher.PropertiesToLoad.Add
, an integer describing the collection index at which the new item was inserted is returned, which would explain why you end up with an array starting with a bunch of small integer values as output from Show-OUBrowser
.
You can either suppress the output from the individual calls inside Populate-TreeView
:
$null = $treeView.Nodes.Add($rootNode)
# and
$null = $searcher.PropertiesToLoad.Add("name")
# repeat for remaining Add() calls
... or you can swallow it all at the callsite where Populate-TreeView
is invoked:
function Show-OUBrowser {
# ...
$null = Populate-TreeView # we only care about the side-effects of this call, suppress all output
# ...
}