Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
October 4, 2022

Are You My Type?

One of the reasons that PowerShell is so amazing is that not only does it work with objects, but it works with objects that have a type. Not only is a process object different than a service object, but there are type differences in properties. A process object has a StartTime property. This property is a [datetime] object.

PS C:\> Get-Process -id $pid | Get-Member starttime

   TypeName: System.Diagnostics.Process

Name      MemberType Definition
----      ---------- ----------
StartTime Property   datetime StartTime {get;}

Because it is a [datetime], PowerShell can properly sort on this property. Or we can get crazy.

PS C:\>  Get-Process | Group-Object {$_.starttime.hour} 

Count Name Group
----- ---- -----
    1      {System.Diagnostics.Process (Idle)}
    7 3    {System.Diagnostics.Process (DbxSvc), System.Diagnostics.Process (Dropbox), System.Diagnostics.Process (Dro…
    7 7    {System.Diagnostics.Process (conhost), System.Diagnostics.Process (LenovoVantage-(LenovoBoostAddin)), Syste…
  180 8    {System.Diagnostics.Process (AggregatorHost), System.Diagnostics.Process (AppVShNotify), System.Diagnostics…
   63 9    {System.Diagnostics.Process (ApplicationFrameHost), System.Diagnostics.Process (cmd), System.Diagnostics.Pr…
   10 10   {System.Diagnostics.Process (conhost), System.Diagnostics.Process (crashpad_handler), System.Diagnostics.Pr…
   16 11   {System.Diagnostics.Process (cmd), System.Diagnostics.Process (conhost), System.Diagnostics.Process (crashp…
    7 12   {System.Diagnostics.Process (conhost), System.Diagnostics.Process (conhost), System.Diagnostics.Process (cr…
    7 13   {System.Diagnostics.Process (conhost), System.Diagnostics.Process (conhost), System.Diagnostics.Process (Ru…
   17 14   {System.Diagnostics.Process (conhost), System.Diagnostics.Process (DataExchangeHost), System.Diagnostics.Pr…
   27 15   {System.Diagnostics.Process (conhost), System.Diagnostics.Process (firefox), System.Diagnostics.Process (fi…
    5 16   {System.Diagnostics.Process (conhost), System.Diagnostics.Process (crashpad_handler), System.Diagnostics.Pr…
    1 17   {System.Diagnostics.Process (Dropbox)}
    2 18   {System.Diagnostics.Process (PhoneExperienceHost), System.Diagnostics.Process (RuntimeBroker)}
    1 19   {System.Diagnostics.Process (svchost)}
    1 20   {System.Diagnostics.Process (slack)}

If the StartTime property were a string, this would be a much more complicated task. In PowerShell, we want things to be typed.

Most of the time, PowerShell handles this automatically.

PS C:\> 4*5
20
PS C:\> 4*"5"
20

In the second example, PowerShell recognized that we most likely wanted to treat the string “5” as a number. But be careful.

PS C:\> "5" * 4
5555

PowerShell assumed we wanted the string “5” four times. One way we can get around this, especially if we are working with a variable, is to give PowerShell a nudge in the right direction.

PS C:\> $i= "5"
PS C:\> $i * 4
5555
PS C:\> ($i -as [int]) * 4
20
PS C:\> [int]$i * 4
20

This is especially true if you use Read-Host.

PS C:\> $r = Read-Host "Enter a value"
Enter a value: 100
PS C:\> $r * 2
100100

This is why we have the -Is operator.

PS C:\> $r -is [int]
False
PS C:\> $r -is [string]
True

Read-Host writes strings to the pipeline.

PS C:\> $r = Read-Host "Enter a value"
Enter a value: 1/1/2023
PS C:\> $r
1/1/2023
PS C:\> $r.adddays(45)
InvalidOperation: Method invocation failed because [System.String] does not contain a method named 'adddays'.

I could tell PowerShell to treat $r as a [datetime].

PS C:\> ([datetime]$r).AddDays(45)

Wednesday, 15 February 2023 00:00:00

Or, I could cast the variable as a [datetime] from the beginning.

PS C:\> [datetime]$r = Read-Host "Enter a value"
Enter a value: 1/1/2023 2:00PM
PS C:\> $r.adddays(45)

Wednesday, 15 February 2023 14:00:00

This is why we type parameter names in functions and scripts.

Param(
 [string]$Path,
 [int32]$FileCount,
 [switch]$Recurse
)

The one area where PowerShell falls short is when importing data from a CSV file. When you use Import-CSV PowerShell creates a custom object from the data, which is nice but treats every property as a string. I want to fix that.

Imported Properties

I am using this CSV data.

"Id","Name","StartTime","Path","UserName"
"22916","PhoneExperienceHost","09/02/2022 10:09:59","C:\Program Files\WindowsApps\Microsoft.YourPhone_1.22062.543.0_x64__8wekyb3d8bbwe\PhoneExperienceHost.exe","TINY340\Jeff"
"5816","pia-service","09/02/2022 10:08:58","C:\Program Files\Private Internet Access\pia-service.exe","NT AUTHORITY\SYSTEM"
"13476","powershell","09/02/2022 10:09:29","C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe","TINY340\Jeff"
"19660","powershell","09/02/2022 10:09:47","C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe","TINY340\Jeff"
"21248","powershell","09/02/2022 10:15:18","C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe","TINY340\Jeff"
"6832","PowerToys","09/02/2022 10:09:32","C:\Program Files\PowerToys\PowerToys.exe","TINY340\Jeff"
"18976","PowerToys.AlwaysOnTop","09/02/2022 10:09:44","C:\Program Files\PowerToys\modules\AlwaysOnTop\PowerToys.AlwaysOnTop.exe","TINY340\Jeff"
"19032","PowerToys.Awake","09/02/2022 10:09:44","C:\Program Files\PowerToys\modules\Awake\PowerToys.Awake.exe","TINY340\Jeff"
"12344","PowerToys.ColorPickerUI","09/02/2022 10:09:44","C:\Program Files\PowerToys\modules\ColorPicker\PowerToys.ColorPickerUI.exe","TINY340\Jeff"
"19240","PowerToys.FancyZones","09/02/2022 10:09:44","C:\Program Files\PowerToys\modules\FancyZones\PowerToys.FancyZones.exe","TINY340\Jeff"
"19040","PowerToys.KeyboardManagerEngine","09/02/2022 10:09:44","C:\Program Files\PowerToys\modules\KeyboardManager\KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe","TINY340\Jeff"
"18596","PowerToys.PowerLauncher","09/02/2022 10:09:44","C:\Program Files\PowerToys\modules\launcher\PowerToys.PowerLauncher.exe","TINY340\Jeff"
"6240","PresentationFontCache","09/02/2022 10:09:29","C:\WINDOWS\Microsoft.Net\Framework64\v3.0\WPF\PresentationFontCache.exe","NT AUTHORITY\LOCAL SERVICE"
"14976","pwsh","09/06/2022 08:24:31","C:\Program Files\PowerShell\7\pwsh.exe","TINY340\Jeff"
"17928","pwsh","09/02/2022 10:10:52","C:\Program Files\PowerShell\7\pwsh.exe","TINY340\Jeff"
"29364","pwsh","09/07/2022 10:12:20","C:\Program Files\PowerShell\7\pwsh.exe","TINY340\Jeff"

I can import the data and get objects, but every property is a string.

PS C:\> Import-Csv .\data.csv | Get-Member

   TypeName: System.Management.Automation.PSCustomObject

Name        MemberType   Definition
----        ----------   ----------
Equals      Method       bool Equals(System.Object obj)
GetHashCode Method       int GetHashCode()
GetType     Method       type GetType()
ToString    Method       string ToString()
Id          NoteProperty string Id=22916
Name        NoteProperty string Name=PhoneExperienceHost
Path        NoteProperty string Path=C:\Program Files\WindowsApps\Microsoft.YourPhone_1.22062.543.0_x64__8wekyb3d8bbwe\…
StartTime   NoteProperty string StartTime=09/02/2022 10:09:59
UserName    NoteProperty string UserName=TINY340\Jeff

This means a command like this fails to give me the expected result.

PS C:\> Import-Csv .\data.csv | Sort-Object ID,Name -descending | Select-Object ID,Name -first 5

Id    Name
--    ----
6832  PowerToys
6240  PresentationFontCache
5816  pia-service
29364 pwsh
22916 PhoneExperienceHost

I could use the type operators.

PS C:\> Import-Csv .\data.csv | Sort-Object {$_.ID -as [int32]},Name -Descending |
Select-Object ID,Name -first 5

Id    Name
--    ----
29364 pwsh
22916 PhoneExperienceHost
21248 powershell
19660 powershell
19240 PowerToys.FancyZones

But that is tedious as far as I’m concerned. It would be much nicer to have typed data from Import-Csv.

Because I know what to expect in data.csv, I can define an ordered hashtable of types for each property.

$typeHash = [ordered]@{
    ID        = [int32]
    Name      = [string]
    StartTime = [datetime]
    Path      = [string]
    UserName  = [string]
}

This needs to be ordered to process everything correctly. Now I can import the CSV file and pipe the custom object to a ForEach-Object block that will re-create the custom object this time using the property types.

$out = Import-Csv .\data.csv | ForEach-Object {
    $in = $_
    $typehash.GetEnumerator() | ForEach-Object -Begin {
        $new = [ordered]@{}
    } -Process {
        $new.Add($_.name, $in.($_.name) -as $_.value)
    } -End {
        [pscustomobject]$new
    }
}

The result is a collection of objects with strongly typed properties.

PS C:\> $out | get-Member -MemberType Properties

   TypeName: System.Management.Automation.PSCustomObject

Name      MemberType   Definition
----      ----------   ----------
ID        NoteProperty int ID=22916
Name      NoteProperty string Name=PhoneExperienceHost
Path      NoteProperty string Path=C:\Program Files\WindowsApps\Microsoft.YourPhone_1.22062.543.0_x64__8wekyb3d8bbwe\Ph… 
StartTime NoteProperty datetime StartTime=9/2/2022 10:09:59 AM
UserName  NoteProperty string UserName=TINY340\Jeff

Because I didn’t want to have to type that ForEach-Object construct every time, I turned it into a function.

Function ConvertTo-Type {
    [cmdletbinding()]
    [alias("ctt")]
    [OutputType("object")]
    Param(
        [Parameter(
            Position = 0, 
            Mandatory, 
            HelpMessage = "Specify the hashtable of property names and types."
        )]
        [ValidateNotNullOrEmpty()]
        [hashtable]$TypeHash,
        [Parameter(Position = 1, ValueFromPipeline, HelpMessage = "Input objects to be converted to type.")]
        [object]$InputObject,
        [Parameter(HelpMessage = "Insert a custom typename")]
        [ValidateNotNullOrEmpty()]
        [string]$Typename
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
    } #begin

    Process {
        Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Processing input"

        $typehash.GetEnumerator() | ForEach-Object -Begin {
            #this bit requires PS7 or rewrite for Windows PowerShell
            $new = $typeName ? [ordered]@{"PSTypeName"=$typeName} : [ordered]@{}
        } -Process {
            $new.Add($_.name, $inputobject.($_.name) -as $_.value)
        } -End {
            [pscustomobject]$new
        }
    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close ConvertTo-Type

I still have to have an idea of what data will be in the CSV file so that I can define an appropriate hashtable for the TypeHash parameter. The function will also insert a custom type name so that you can use format and type extension files.

PS C:\> Import-Csv .\data.csv |
ConvertTo-Type $typehash -Typename PSFoo -OutVariable c |
Get-Member -MemberType Properties

   TypeName: PSFoo

Name      MemberType   Definition
----      ----------   ----------
ID        NoteProperty int ID=22916
Name      NoteProperty string Name=PhoneExperienceHost
Path      NoteProperty string Path=C:\Program Files\WindowsApps\Microsoft.YourPhone_1.22062.543.0_x64__8wekyb3d8bbwe\Ph… 
StartTime NoteProperty datetime StartTime=9/2/2022 10:09:59 AM
UserName  NoteProperty string UserName=TINY340\Jeff

Now the imported data is much easier to use.

$c | Sort-Object ID,Name -Descending | Select-Object ID,Name -first 5

   ID Name
   -- ----
29364 pwsh
22916 PhoneExperienceHost
21248 powershell
19660 powershell
19240 PowerToys.FancyZones

Predicting Type

A drawback to my ConvertTo-Type function is that I need to know how to use it in advance. It would be nice to have something more dynamic. The concept is to look at the properties of the imported object. If the property looks like a number, treat it as an [int]. If the property looks like a date, make it a [datetime]. How can I do this? This is the whole point of regular expressions.

$datetime = "^\d{1,2}/\d{1,2}/\d{2,4}|^\d{1,2}:\d{1,2}:\d{1,2}"
$number = "^\d+$|^-\d+$"
$double = "^\d+\.\d+$|^-\d+\.\d+$"
$boolean = "^True$|^False$"

I can match up values based on the regular expression and cast to type accordingly. Let me show you the function, and then I’ll highlight a few points.

#requires -version 5.1

Function Import-TypedCSV {
    [cmdletbinding()]
    Param (
        [Parameter(
            Position = 0, 
            Mandatory, 
            HelpMessage = "Enter the CSV filename and path",
            ValueFromPipeline, 
            ValueFromPipelineByPropertyName
        )]
        [ValidateScript({ Test-Path $_ })]
        [Alias("PSPath")]
        [string]$Path,
        [Parameter(HelpMessage = "Specify the delimiter. The default is a comma.")]
        [ValidateNotNullorEmpty()]
        [string]$Delimiter = ",",
        [Parameter(HelpMessage = "Insert a custom typename")]
        [ValidateNotNullOrEmpty()]
        [string]$Typename
    )

    Begin {
        Write-Verbose "Starting $($MyInvocation.Mycommand)"  
        Write-Verbose "Using delimiter: $delimiter"
    } #begin

    Process {
        Write-Verbose "Importing $path"

        #import the data
        $data = Import-Csv -Path $Path -Delimiter $Delimiter

        #get the first element
        Write-Verbose "Importing $($data.count) items."

        #get the property names
        $properties = $data[0].psobject.properties.name
        Write-Verbose "Found $($properties.count) properties"

        #Define some regular expression patterns to identify types based on values
        #the default will be a string. You mioght need to refine the regex patterns
        #to match your data needs.
        $datetime = "^\d{1,2}/\d{1,2}/\d{2,4}|^\d{1,2}:\d{1,2}:\d{1,2}"
        $number = "^\d+$|^-\d+$"
        $double = "^\d+\.\d+$|^-\d+\.\d+$"
        $boolean = "^True$|^False$"

        #define a variable for the custom properties
        $custom = @()

        #build a set of scriptblocks to treat each property as the correct type
        #based on the regular expression match.

        foreach ($property in $properties) {
            $value = $data[0].$property
            #cast each $property specifically as a string and remove spaces
            [string]$property = $property.Replace(" ", "")
            Write-Verbose ("Testing property {0}. Sample value is {1}" -f $property, $value)
            if ($value) {
                Switch -regex ($value) {
                    $datetime {
                        Write-Verbose "Casting as Datetime" 
                        $type = "datetime"  
                        $exp = $ExecutionContext.InvokeCommand.NewScriptBlock("[$type]`$_.$property")             
                    }
                    $number {
                        #further test value to determine an appropriate class for the type of number
                        Write-Verbose "Testing $value for a more granular type"

                        Switch ($value) {
                            { $_ -as [int32] } {
                                Write-Verbose "Casting as Int32" 
                                $type = "Int32"
                                Break
                            }   
                            { $_ -as [uint32] } {
                                Write-Verbose "Casting as Uint32" 
                                $type = "uint32"
                                Break
                            }   
                            { $_ -as [int64] } {
                                Write-Verbose "Casting as int64" 
                                $type = "int64"
                                Break
                            }   
                            { $_ -as [uint64] } {
                                Write-Verbose "Casting as Uint64" 
                                $type = "uint64"
                                Break
                            }   

                            Default {
                                #treat as a string if nothing matches
                                Write-Verbose "Can't determine numeric value. Casting as a string."
                                $type = "string"
                            }

                        } #switch

                        $exp = $ExecutionContext.InvokeCommand.NewScriptBlock("[$type]`$_.$property")
                    }

                    $double {
                        Write-Verbose "Casting as Double" 
                        $type = "Double"    
                        $exp = $ExecutionContext.InvokeCommand.NewScriptBlock("[$type]`$_.$property")            
                    }
                    $boolean {
                        Write-Verbose "Casting as a Boolean"
                        $type = "boolean"

                        #convert True and False strings into valid Boolean values
                        $exp = $ExecutionContext.InvokeCommand.NewScriptBlock("if (`$_.$property -match 'True') {`$v=1} else {`$v=0} ; `$v -as [$type]")

                    }
                    Default {
                        Write-Verbose "Casting as String"
                        $type = "String"            
                    }
                } #switch
            } #if item.property
            else {
                #there might be an empty value in the first object so prompt for the type
                $msg = ("There was no value found for {0}. What type do you want to use? For example Int32, Datetime or String" -f $property)
                $type = Read-Host $msg
                $exp = $ExecutionContext.InvokeCommand.NewScriptBlock("if (`$_.$property) {[$type]`$_.$property} else {[null]`$_.$property}")
                Write-Verbose "Casting $property as $type. User-provided value."
            }

            #add the property to the custom property array
            if ($type -eq "String") {
                $custom += $Property
            }
            else {
                #create a scriptblock from the property name
                $custom += @{Name = "$Property"; Expression = $exp }
            }

        } #foreach

        #build a string showing the scriptblocks to be used by Select-Object
        Write-Verbose "Using Select-Object to define custom properties"
        $select = [System.Collections.Generic.list[string]]::new()
        foreach ($item in $custom) {
            if ($item -is [string]) {
                $select.Add($item)
            }
            elseif ($item -is [hashtable]) {
                $t = "@{Name='$($item.name)';Expression={$($item.expression)}}"
                $select.Add($t)
            }
        }

        Write-Verbose "Select-Object -property $($select -join ',')"
        #Now take data and 're-type' the objects
        $out = $data | Select-Object -Property $custom
        #insert a custom typename if specified
        if ($Typename) {
            Write-Verbose "Inserting typename $Typename"
            foreach ($obj in $out) {
                $obj.psobject.typenames.Insert(0,$Typename)
            }
        }
        $out

    } #process

    End {
        Write-Verbose "Ending $($MyInvocation.Mycommand)"
    } #end

} #end function

The function imports data from the CSV file. It looks at the first object's properties to determine the type for each value. If the property on the first item has no value, the function will prompt you for a type name to use. Otherwise, it will use the regular expression patterns in a Switch statement.

During each match, I define a scriptblock to treat the property as the correct type. Eventually, the function will run the equivalent of a PowerShell expression like this using Select-Object and custom property hashtables.

Import-CSV .\data.csv | Select-Object -property @{Name='Id';Expression={[Int32]$_.Id}},Name,@{Name='StartTime';Expression={[datetime]$_.StartTime}},Path,UserName

The function is defining the custom scriptblocks on-the-fly.

$out = $data | Select-Object -Property $custom

Here’s a sample.

PS C:\> Import-TypedCSV .\data.csv | Sort-Object StartTime,Name | Select-object ID,Starttime,Name

   Id StartTime            Name
   -- ---------            ----
 5816 9/2/2022 10:08:58 AM pia-service
13476 9/2/2022 10:09:29 AM powershell
 6240 9/2/2022 10:09:29 AM PresentationFontCache
 6832 9/2/2022 10:09:32 AM PowerToys
18976 9/2/2022 10:09:44 AM PowerToys.AlwaysOnTop
19032 9/2/2022 10:09:44 AM PowerToys.Awake
12344 9/2/2022 10:09:44 AM PowerToys.ColorPickerUI
19240 9/2/2022 10:09:44 AM PowerToys.FancyZones
19040 9/2/2022 10:09:44 AM PowerToys.KeyboardManagerEngine
18596 9/2/2022 10:09:44 AM PowerToys.PowerLauncher
19660 9/2/2022 10:09:47 AM powershell
22916 9/2/2022 10:09:59 AM PhoneExperienceHost
17928 9/2/2022 10:10:52 AM pwsh
21248 9/2/2022 10:15:18 AM powershell
14976 9/6/2022 8:24:31 AM  pwsh
29364 9/7/2022 10:12:20 AM pwsh

The function also lets you specify a custom type name.

PS C:\>Import-TypedCSV .\data.csv -Typename PSProc | Get-Member -MemberType Properties    

   TypeName: PSProc

Name      MemberType   Definition
----      ----------   ----------
Id        NoteProperty System.Int32 Id=22916
Name      NoteProperty string Name=PhoneExperienceHost
Path      NoteProperty string Path=C:\Program Files\WindowsApps\Microsoft.YourPhone_1.22062.543.0_x64__8wekyb3d8bbwe\Ph… 
StartTime NoteProperty System.DateTime StartTime=9/2/2022 10:09:59 AM
UserName  NoteProperty string UserName=TINY340\Jeff

If the first object is incomplete, PowerShell will prompt you.

PS C:\> import-typedcsv S:\mydata.csv
There was no value found for Path. What type do you want to use? For example Int32, Datetime or String: string
There was no value found for Company. What type do you want to use? For example Int32, Datetime or String: string 
There was no value found for Product. What type do you want to use? For example Int32, Datetime or String:

Summary

You may not always need to use imported CSV data with types. The fact that everything is a string often isn’t an issue. But if you need to leverage PowerShell to group, sort, or filter, you will want to take types into account. I gave you several tools you can use to face this challenge.

(c) 2022-2025 JDH Information Technology Solutions, Inc. - all rights reserved
Don't miss what's next. Subscribe to Behind the PowerShell Pipeline:
Start the conversation:
GitHub Bluesky LinkedIn About Jeff
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.