Output Objects, Not Text in PowerShell: Composable, Testable, and Reusable Scripts
In PowerShell, the most powerful habit you can build is simple: output objects, not text. When your functions emit structured PSCustomObject instances and keep data flowing on the pipeline, you unlock predictable automation, easier testing, and highly reusable modules. You then reserve formatting for the edges—at the CLI, in scripts, or at call sites—so the same code works in terminals, background jobs, CI pipelines, and services without rewrites.
Why Objects Beat Text
- Composability: Objects with named properties can be filtered, sorted, grouped, and joined without brittle string parsing.
- Testability: Pester tests can assert on fields and types, not fragile substrings.
- Tooling:
Export-Csv,ConvertTo-Json, andWhere-Objectwork best with structured data. - Reliability: Stable property names create a contract for callers and future you.
- Portability: The same object output can be rendered for humans (
Format-Table) or serialized for machines (JSON, CSV) without changing the function.
Core Practices: Objects In, Objects Out
Return PSCustomObject from functions
Emit a single, well-shaped object (or a stream of them) with predictable properties. Don’t format or concatenate strings inside your function.
function Get-FolderStats {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
$files = Get-ChildItem -Path $Path -File -Recurse -ErrorAction Stop
$total = ($files | Measure-Object -Property Length -Sum).Sum
[pscustomobject]@{
Path = (Resolve-Path -Path $Path).Path
Files = $files.Count
SizeMB = [math]::Round(($total/1MB),2)
}
}
# Objects in, objects out
$stats = Get-FolderStats -Path './logs'
$stats | Format-Table -AutoSizeThis function returns an object with stable, typed fields. You can pipe it into any consumer without changing the function.
Keep the data on the pipeline
Use Write-Output implicitly by returning objects, not strings. Avoid emitting formatted text from within functions. That breaks composition and testing.
Give properties stable, descriptive names
- Consistency: Use stable names so callers can
Sort-Object,Where-Object, andSelect-Objectreliably. - Include units in the field name, not value: Prefer
SizeMBoverSizewith “MB” in the string. It preserves numeric types and avoids parsing. - Use appropriate types: Prefer
[int],[double],[datetime], and[bool]where applicable for better filtering and serialization.
Reserve Write-Host for human-only status
Write-Host is for messages intended only for humans and should not be parsed. For routable status, prefer Write-Information so callers can capture or suppress it.
Write-Information "Scanning $Path" -Tags Status -InformationAction ContinueTreat errors as errors, not data
Throw or use Write-Error for failures. Don’t return error text as a string. That ensures CI, jobs, and callers can detect errors reliably.
if (-not (Test-Path -LiteralPath $Path)) {
throw [System.IO.DirectoryNotFoundException]::new("Path not found: $Path")
}Formatting at the Edges
Never format inside the function. Format only at the call site. That keeps the core logic reusable across interactive shells, scripts, jobs, and CI.
Interactive (human) formatting
# CLI: choose columns for humans
Get-FolderStats -Path . | Format-Table Path, Files, SizeMB -AutoSizeMachine consumers and CI
# Serialize for systems
Get-FolderStats -Path . | ConvertTo-Json -Depth 3 | Set-Content -Path stats.json
Get-FolderStats -Path . | Export-Csv -Path stats.csv -NoTypeInformation
# Filter, sort, and perform logic
Get-FolderStats -Path . | Where-Object { $_.Files -gt 100 } | Out-NullAnti-pattern and refactor
# Anti-pattern: formatting inside function
function Get-Thing {
$obj = [pscustomobject]@{ Name = 'alpha'; Count = 42 }
$obj | Format-Table -AutoSize # Locks output to a human view
}
# Refactor: return objects; format later
function Get-Thing {
[pscustomobject]@{ Name = 'alpha'; Count = 42 }
}
Get-Thing | Sort-Object Count -Descending | ConvertTo-JsonDesigning Reusable, Composable Cmdlets
Support pipeline input
Accept objects or strings from the pipeline so your function chains naturally.
function Get-FolderStats {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[string]$Path
)
process {
$files = Get-ChildItem -Path $Path -File -Recurse -ErrorAction Stop
$total = ($files | Measure-Object -Property Length -Sum).Sum
[pscustomobject]@{
Path = (Resolve-Path -Path $Path).Path
Files = [int]$files.Count
SizeMB = [double][math]::Round(($total/1MB), 2)
}
}
}
Get-ChildItem -Directory | Get-FolderStats | Sort-Object SizeMB -Descending | Select-Object -First 5Use stable contracts
- Document property names and types.
- When adding fields, avoid breaking existing names; consider a
Versionproperty if needed. - Prefer culture-invariant formatting at edges (
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8';[CultureInfo]::InvariantCulturewhen formatting strings).
Serialization tips
- Use
ConvertTo-Json -Depth Nfor nested objects. - For large outputs, stream to CSV/JSON rather than building gigantic arrays in memory.
- Avoid serializing formatted text; keep values typed.
Testing Objects, Not Strings
By returning objects, tests can assert on properties and types rather than fragile string snapshots.
# Pester example
Describe 'Get-FolderStats' {
It 'emits a typed object with stable fields' {
$r = Get-FolderStats -Path '.'
$r | Should -Not -BeNullOrEmpty
$r | Get-Member -Name Path, Files, SizeMB -MemberType NoteProperty | Should -Not -BeNullOrEmpty
$r.Files | Should -BeGreaterThanOrEqual 0
$r.SizeMB | Should -BeGreaterThanOrEqual 0
$r.Path | Should -Match '\\.|^\.' # path string exists
}
It 'can be serialized to JSON' {
$json = Get-FolderStats -Path '.' | ConvertTo-Json -Depth 3
$obj = $json | ConvertFrom-Json
$obj.PSObject.Properties.Name | Should -Contain 'SizeMB'
}
}Real-World Workflows
CLI usage
Get-FolderStats -Path 'C:/Windows' |
Sort-Object SizeMB -Descending |
Select-Object Path, Files, SizeMB |
Format-Table -AutoSizeCI pipeline
$report = Get-FolderStats -Path './artifacts'
if ($report.SizeMB -gt 500) {
Write-Error "Artifacts exceed size budget: $($report.SizeMB) MB"
}
$report | ConvertTo-Json -Depth 3 | Set-Content -Path './artifacts/report.json'Export for analytics
Get-ChildItem -Directory -Path './logs' |
Get-FolderStats |
Export-Csv -Path './logs/folder-stats.csv' -NoTypeInformationPerformance and Reliability Tips
- Stream results: Use
process {}to output items as you compute them; don’t accumulate large arrays unless necessary. - Avoid early stringification: Keep values typed until the last mile. String conversions throw away structure and hurt performance downstream.
- Computed properties: Prefer computing once per object rather than repeatedly in the pipeline; avoid
Select-Objectin hot loops unless needed. - Culture invariance: When formatting strings for logs, use invariant culture to avoid locale-specific surprises. Keep objects culture-neutral.
- Security: Objects minimize string parsing, reducing injection risks and improving validation (e.g., typed
[int]vs. parsing numbers from text).
Common Smells and How to Fix Them
- Smell: Function writes pretty tables. Fix: Return objects, move
Format-Tableto the caller. - Smell: Values include units in strings. Fix: Put units in property names (
DurationMs,SizeMB) and keep numeric types. - Smell: Mixed text and objects on pipeline. Fix: Use
Write-InformationorWrite-Verbosefor messages, objects for data. - Smell: Errors returned as strings. Fix:
throworWrite-Errorwith proper exceptions. - Smell: Changing property names between releases. Fix: Version your contract and add, don’t break, property names.
Build object-first habits in PowerShell: emit PSCustomObject from your functions, keep data on the pipeline, and format only at the edges with Format-Table, Format-List, Export-Csv, or ConvertTo-Json. You’ll get cleaner pipelines, easier reuse, predictable outputs, and better tooling across the board.
Want to go deeper? Explore advanced patterns, testing, and module design in resources like the PowerShell Advanced Cookbook: https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/