TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

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, and Where-Object work 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 -AutoSize

This 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, and Select-Object reliably.
  • Include units in the field name, not value: Prefer SizeMB over Size with “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 Continue

Treat 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 -AutoSize

Machine 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-Null

Anti-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-Json

Designing 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 5

Use stable contracts

  • Document property names and types.
  • When adding fields, avoid breaking existing names; consider a Version property if needed.
  • Prefer culture-invariant formatting at edges ($PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'; [CultureInfo]::InvariantCulture when formatting strings).

Serialization tips

  • Use ConvertTo-Json -Depth N for 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 -AutoSize

CI 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' -NoTypeInformation

Performance 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-Object in 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

  1. Smell: Function writes pretty tables. Fix: Return objects, move Format-Table to the caller.
  2. Smell: Values include units in strings. Fix: Put units in property names (DurationMs, SizeMB) and keep numeric types.
  3. Smell: Mixed text and objects on pipeline. Fix: Use Write-Information or Write-Verbose for messages, objects for data.
  4. Smell: Errors returned as strings. Fix: throw or Write-Error with proper exceptions.
  5. 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/

← All Posts Home →