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.