TB

MoppleIT Tech Blog

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

PowerShell Best Practice: Emit PSCustomObject and Format at the Edge

If you want your PowerShell scripts to scale from quick one-liners to reusable automation, stop printing strings and start emitting objects. When your functions return PSCustomObject instead of pre-formatted text, callers can sort, filter, export, and compose pipelines without scraping output. You get predictable behavior, cleaner logs, and tooling that just works.

Why Output Objects, Not Text

PowerShell is an object shell. Emitting objects is the difference between code that looks good on a demo and code that survives CI jobs, scheduled tasks, and operations dashboards.

  • Predictable pipelines: Upstream changes don't break downstream consumers relying on property names instead of string parsing.
  • Easier reuse: A single function supports multiple use cases (CLI tables, JSON for APIs, CSV for analysts) without modification.
  • Better tooling: Sort-Object, Where-Object, Group-Object, Export-Csv, and ConvertTo-Json operate naturally on objects.
  • Cleaner logs: Log structured data, not ephemeral formatting.
  • Safer upgrades: Adding properties is additive; changing string formats is a breaking change.

Here's a simple, object-first function you can drop into your toolbox:

function Get-DiskUsage {
  [CmdletBinding()]
  param([string]$Drive = 'C')
  $d = Get-PSDrive -Name $Drive -ErrorAction Stop
  $total = $d.Used + $d.Free
  [pscustomobject]@{
    Drive   = $d.Name
    FreeGB  = [math]::Round($d.Free/1GB,2)
    UsedGB  = [math]::Round($d.Used/1GB,2)
    PctFree = if ($total -gt 0) { [int](($d.Free/$total)*100) } else { 0 }
  }
}

$info = Get-DiskUsage -Drive 'C'
# Caller decides presentation
$info | Format-Table -AutoSize
# Or machine-friendly
# $info | ConvertTo-Json -Depth 3

The function returns a predictable object. The caller decides how to render it, whether as a table, JSON, or CSV.

Designing Object-First Functions

Build advanced functions with CmdletBinding

Advanced functions behave like built-in cmdlets and give you standard features (common parameters, pipeline binding, support for -WhatIf/-Confirm):

function Get-Widget {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
  [OutputType([pscustomobject])]
  param(
    [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
    [ValidateSet('Small','Medium','Large')]
    [string]$Size,

    [switch]$PassThru
  )
  process {
    if ($PSCmdlet.ShouldProcess("Widget of size $Size", 'Create')) {
      $obj = [pscustomobject]@{
        PSTypeName = 'Contoso.Widget'
        Size       = $Size
        CreatedAt  = [datetime]::UtcNow
      }
      if ($PassThru) { $obj }
    }
  }
}
  • OutputType helps tooling and readers understand what you emit.
  • Parameter metadata improves usability and validation.
  • ShouldProcess enables safe "what if" operations.

Return PSCustomObject, not strings

Emit objects with stable, intention-revealing property names. Keep data types consistent (numbers as numbers, dates as DateTime, Booleans as [bool]):

[pscustomobject]@{
  PSTypeName = 'System.Disk.Usage'
  Drive      = $d.Name
  FreeGB     = [double][math]::Round($d.Free/1GB, 2)
  UsedGB     = [double][math]::Round($d.Used/1GB, 2)
  PctFree    = [int]([Math]::Round(($d.Free/($d.Free+$d.Used))*100, 0))
}

Tip: In modern PowerShell, property order is preserved as inserted, but if you want to be explicit when constructing a hashtable first, use [ordered]@{...} before casting to [pscustomobject].

Use the right streams (and reserve Write-Host for progress)

  • Output (success) stream: return your data by placing the object on the pipeline (simply by writing it or leaving it as the last expression). Avoid Write-Output unless you need clarity; returning is idiomatic.
  • Verbose: Write-Verbose for developer diagnostics (-Verbose opt-in).
  • Information: Write-Information for structured, routable informational messages.
  • Warning: Write-Warning for non-terminating issues.
  • Error: Write-Error for non-terminating errors; throw for terminating exceptions when you can't continue.
  • Progress/Host: Use Write-Progress (preferred) or Write-Host for user-facing progress/status only. Don't put data on these streams.
Write-Verbose "Starting scan..."
Write-Progress -Activity 'Disk Scan' -Status 'Enumerating drives' -PercentComplete 42
if ($problem) { Write-Warning 'Drive X is offline' }
try {
  # Work that may fail
}
catch {
  Write-Error -ErrorRecord $_
}

Don't format inside your function

Never call Format-Table, Format-List, or Out-Host from within a function that others will consume. Formatting is a terminal operation that converts objects to format data; once formatted, they can't be sorted, filtered, or exported reliably.

Format at the Edge: Pipelines, Sorting, and Exporting

CLI-friendly formatting

Let the caller decide how the data should look in a terminal:

Get-DiskUsage C, D, E | Sort-Object PctFree | Format-Table Drive, FreeGB, UsedGB, PctFree -AutoSize

Need more detail?

Get-DiskUsage -Drive C | Format-List *

Machine-friendly output

Export to common formats without changing your function:

# JSON for APIs and logs
Get-DiskUsage -Drive C | ConvertTo-Json -Depth 3 | Out-File disk.json

# CSV for spreadsheets or data pipelines
Get-DiskUsage C, D, E | Export-Csv disk.csv -NoTypeInformation

# CLIXML for fidelity (preserves types)
Get-DiskUsage -Drive C | Export-Clixml disk.xml

Composing with other tools

Because you emit objects, composition is natural:

# Alert on low space
Get-DiskUsage C, D, E |
  Where-Object PctFree -lt 15 |
  ForEach-Object {
    # Route to whatever you need: email, Teams, webhook
    $_ | ConvertTo-Json -Compress | Invoke-RestMethod -Method Post -Uri https://example/api/alerts -Body $_
  }

# Summaries by threshold
Get-DiskUsage C, D, E |
  Group-Object { if ($_.PctFree -lt 10) { 'Critical' } elseif ($_.PctFree -lt 20) { 'Warning' } else { 'Healthy' } } |
  Select-Object Name, Count

Want parallelism? PowerShell 7 makes it easy to fan out work while still returning objects:

'C','D','E' | ForEach-Object -Parallel {
  Get-DiskUsage -Drive $_
} | Sort-Object PctFree

Practical Patterns and Tips

Stabilize your schema

  • Choose stable, descriptive property names (Drive, FreeGB, PctFree).
  • Use consistent types across all outputs. Don't mix strings and numbers in the same property.
  • Be additive: add new properties instead of changing existing ones.

Surface metadata when helpful

Consider stamping a PSTypeName to enable custom formatting views and type extensions without polluting your function:

[pscustomobject]@{ PSTypeName = 'System.Disk.Usage'; Drive = 'C'; FreeGB = 42; UsedGB = 58; PctFree = 42 }

Later, you can ship a formatting file (.format.ps1xml) to define default table/list views for this type—still formatting at the edge.

Validate inputs and fail well

  • Validate parameters with [ValidateSet], [ValidatePattern], [ValidateRange], and [ValidateScript].
  • Use terminating errors (throw) when the function cannot proceed; use Write-Error when it can continue processing other pipeline items.
  • Add -ErrorAction support by not swallowing errors; let callers decide.

Test as data

Object-first design simplifies tests. With Pester, assert on properties, not text:

It 'emits usable properties' {
  $o = Get-DiskUsage -Drive 'C'
  $o | Should -BeOfType psobject
  $o.PSObject.Properties.Name | Should -Contain 'PctFree'
  $o.PctFree | Should -BeGreaterThanOrEqual 0
}

When (and how) to show progress

For long-running tasks, report progress without contaminating the pipeline:

$drives = 'C','D','E'
for ($i = 0; $i -lt $drives.Count; $i++) {
  $drive = $drives[$i]
  Write-Progress -Activity 'Collecting disk usage' -Status "Drive $drive" -PercentComplete (($i/$drives.Count)*100)
  Get-DiskUsage -Drive $drive
}

Reserve Write-Host for simple, human-only status in interactive sessions. For automation, Write-Progress, Write-Verbose, and Write-Information are better choices because they're routable and controllable.

End-to-end example: object-first to report

Here's a small end-to-end script that collects data as objects, logs JSON to disk, and renders a human-friendly table at the very end:

$data = Get-DiskUsage C, D, E

# Log for machines
$data | ConvertTo-Json -Depth 3 | Out-File disk-usage.json -Encoding utf8

# Human-friendly output (format at the edge)
$data | Sort-Object PctFree | Format-Table Drive, FreeGB, UsedGB, PctFree -AutoSize

Real-world benefits

  • DevOps pipelines: Publish JSON to an artifact store and let dashboards ingest it.
  • Observability: Feed structured metrics into your log pipeline without brittle scraping.
  • Compliance reports: Export CSVs for audit teams without changing function code.
  • Tooling synergy: Import-Csv, Measure-Object, and custom modules compose naturally on objects.

Build object-first scripts in PowerShell and you'll unlock predictable pipelines, easier reuse, cleaner logs, and better tooling. For deeper patterns and advanced techniques, explore the PowerShell ecosystem and consider resources like the PowerShell Advanced Cookbook. You can find it here: PowerShell Advanced Cookbook.

← All Posts Home →