You can use an in-memory, i.e. string representation of CSV data using a here-string and parse it into objects with ConvertFrom-Csv
:
# This creates objects ([pscustomobject] instances) with properties
# named for the fields in the header line (the first line), i.e:
# .name, .id. .type, and .loc
# NOTE:
# * The whitespace around the fields is purely for *readability*.
# * If any field values contain "," themselves, enclose them in "..."
$mycsv =
@'
name, id, type, loc
Brave, Brave.Brave, 1, winget
Adobe Acrobat (64-bit), {AC76BA86-1033-1033-7760-BC15014EA700}, 2,
GitHub CLI, GitHub.cli, 3, C:\portable
'@ | ConvertFrom-Csv
$mycsv | Format-List
then provides the desired output (without Format-List
, you'd get implicit Format-Table
formatting, because the objects have no more than 4 properties).
- As an aside:
Format-List
in essence provides the for-display formatting you've attempted with your loop of Write-Host
calls; if you really need the latter approach, note that, as pointed out in Walter Mitty's answer, you need to enclose property-access expressions such as $_.name
in $(...)
in order to expand as such inside an expandable (double-quoted) PowerShell string ("..."
) - see this answer for a systematic overview of the syntax of PowerShell's expandable strings (string interpolation).
Note:
This approach is convenient:
It allows you to omit quoting, unless needed, namely only if a field value happens to contain ,
itself.
Use "..."
(double-quoting) around field values that themselves contain ,
('...'
, i.e. single-quoting does not have syntactic meaning in CSV data, and any '
characters are retained verbatim).
- Should such a field additionally contain
"
chars., escape them as ""
It allows you to use incidental whitespace for more readable formatting, as shown above.
You may also use a separator other than ,
(e.g., |
) in the input and pass it to ConvertFrom-Csv
via the -Delimiter
parameter.
Note: CSV data is in general untyped, which means that ConvertFrom-Csv
(as well as Import-Csv
) creates objects whose properties are all strings ([string
]-typed).
Optional reading: A custom CSV notation that enables creation of typed properties:
Convenience function ConvertFrom-CsvTyped
(source code below) overcomes the limitation of ConvertFrom-Csv
invariably creating only string-typed properties, by enabling a custom header notation that supports preceding each column name in the header line with a type literal; e.g. [int] ID
(see this answer for a systematic overview of PowerShell's type literals, which can refer to any .NET type).
This enables you to create (non-string) typed properties from the input CSV, as long as the target type's values can be represented as numbers or string literals, which includes:
- Numeric types (
[int]
, [long]
, [double]
, [decimal]
, ...)
- Date and time-related types
[datetime]
, [datetimeoffset]
, and [timespan]
[bool]
(use 0
and 1
as the column values)
- To test whether a given type can be used, cast it from a sample number or string, e.g.:
[timespan] '01:00'
or [byte] 0x40
Examples - note the type literals preceding the 2nd and third column names, [int]
and [datetime]
:
@'
Name, [int] ID, [datetime] Timestamp
Forty-two, 0x2a, 1970-01-01
Forty-three, 0x2b, 1970-01-02
'@ | ConvertFrom-CsvTyped
Output - note how the hex. numbers were recognized as such (and formatted as decimals by default), and how the data strings were recognized as [datetime]
instances:
Name ID Timestamp
---- -- ---------
Forty-two 42 1/1/1970 12:00:00 AM
Forty-three 43 1/2/1970 12:00:00 AM
Adding -AsSourceCode
to the call above allows you to output the parsed objects as a PowerShell source code string, namely as an array of [pscustomobject]
literals:
@'
Name, [int] ID, [datetime] Timestamp
Forty-two, 0x2a, 1970-01-01
Forty-three, 0x2b, 1970-01-02
'@ | ConvertFrom-CsvTyped -AsSourceCode
Output - note that if you were to use this in a script or as input to Invoke-Expression
(for testing only), you'd get the same objects and for-display output as above:
@(
[pscustomobject] @{ Name = 'Forty-two'; ID = [int] 0x2a; Timestamp = [datetime] '1970-01-01' }
[pscustomobject] @{ Name = 'Forty-three'; ID = [int] 0x2b; Timestamp = [datetime] '1970-01-02' }
)
ConvertFrom-CsvTyped
source code:
function ConvertFrom-CsvTyped {
<#
.SYNOPSIS
Converts CSV data to objects with typed properties;
.DESCRIPTION
This command enhances ConvertFrom-Csv as follows:
* Header fields (column names) may be preceded by type literals in order
to specify a type for the properties of the resulting objects, e.g. "[int] Id"
* With -AsSourceCode, the data can be transformed to an array of
[pscustomobject] literals.
.PARAMETER Delimiter
The single-character delimiter (separator) that separates the column values.
"," is the (culture-invariant) default.
.PARAMETER AsSourceCode
Instead of outputting the parsed CSV data as objects, output them as
as source-code representations in the form of an array of [pscustomobject] literals.
.EXAMPLE
"Name, [int] ID, [datetime] Timestamp`nForty-two, 0x40, 1970-01-01Z" | ConvertFrom-CsvTyped
Parses the CSV input into an object with typed properties, resulting in the following for-display output:
Name ID Timestamp
---- -- ---------
Forty-two 64 12/31/1969 7:00:00 PM
.EXAMPLE
"Name, [int] ID, [datetime] Timestamp`nForty-two, 0x40, 1970-01-01Z" | ConvertFrom-CsvTyped -AsSourceCode
Transforms the CSV input into an equivalent source-code representation, expressed
as an array of [pscustomobject] literals:
@(
[pscustomobject] @{ Name = 'Forty-two'; ID = [int] 0x40; Timestamp = [datetime] '1970-01-01Z' }
)
#>
[CmdletBinding(PositionalBinding = $false)]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string[]] $InputObject,
[char] $Delimiter = ',',
[switch] $AsSourceCode
)
begin {
$allLines = ''
}
process {
if (-not $allLines) {
$allLines = $InputObject -join "`n"
}
else {
$allLines += "`n" + ($InputObject -join "`n")
}
}
end {
$header, $dataLines = $allLines -split '\r?\n'
# Parse the header line in order to derive the column (property) names.
[string[]] $colNames = ($header, $header | ConvertFrom-Csv -ErrorAction Stop -Delimiter $Delimiter)[0].psobject.Properties.Name
[string[]] $colTypeNames = , 'string' * $colNames.Count
[type[]] $colTypes = , $null * $colNames.Count
$mustReType = $false; $mustRebuildHeader = $false
if (-not $dataLines) { throw "No data found after the header line; input must be valid CSV data." }
foreach ($i in 0..($colNames.Count - 1)) {
if ($colNames[$i] -match '^\[([^]]+)\]\s*(.*)$') {
if ('' -eq $Matches[2]) { throw "Missing column name after type specifier '[$($Matches[1])]'" }
if ($Matches[1] -notin 'string', 'System.String') {
$mustReType = $true
$colTypeNames[$i] = $Matches[1]
try {
$colTypes[$i] = [type] $Matches[1]
}
catch { throw }
}
$mustRebuildHeader = $true
$colNames[$i] = $Matches[2]
}
}
if ($mustRebuildHeader) {
$header = $(foreach ($colName in $colNames) { if ($colName -match [regex]::Escape($Delimiter)) { '"{0}"' -f $colName.Replace('"', '""') } else { $colName } }) -join $Delimiter
}
if ($AsSourceCode) {
# Note: To make the output suitable for direct piping to Invoke-Expression (which is helpful for testing),
# a *single* string mut be output.
(& {
"@("
& { $header; $dataLines } | ConvertFrom-Csv -Delimiter $Delimiter | ForEach-Object {
@"
[pscustomobject] @{ $(
$(foreach ($i in 0..($colNames.Count-1)) {
if (($propName = $colNames[$i]) -match '\W') {
$propName = "'{0}'" -f $propName.Replace("'", "''")
}
$isString = $colTypes[$i] -in $null, [string]
$cast = if (-not $isString) { '[{0}] ' -f $colTypeNames[$i] }
$value = $_.($colNames[$i])
if ($colTypes[$i] -in [bool] -and ($value -as [int]) -notin 0, 1) { Write-Warning "'$value' is interpreted as `$true - use 0 or 1 to represent [bool] values." }
if ($isString -or $null -eq ($value -as [double])) { $value = "'{0}'" -f $(if ($null -ne $value) { $value.Replace("'", "''") }) }
'{0} = {1}{2}' -f $colNames[$i], $cast, $value
}) -join '; ') }
"@
}
")"
}) -join "`n"
}
else {
if (-not $mustReType) {
# No type-casting needed - just pass the data through to ConvertFrom-Csv
& { $header; $dataLines } | ConvertFrom-Csv -ErrorAction Stop -Delimiter $Delimiter
}
else {
# Construct a class with typed properties matching the CSV input dynamically
$i = 0
@"
class __ConvertFromCsvTypedHelper {
$(
$(foreach ($i in 0..($colNames.Count-1)) {
' [{0}] ${{{1}}}' -f $colTypeNames[$i], $colNames[$i]
}) -join "`n"
)
}
"@ | Invoke-Expression
# Pass the data through to ConvertFrom-Csv and cast the results to the helper type.
try {
[__ConvertFromCsvTypedHelper[]] (& { $header; $dataLines } | ConvertFrom-Csv -ErrorAction Stop -Delimiter $Delimiter)
}
catch { $_ }
}
}
}
}