TB

MoppleIT Tech Blog

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

Strong, Typed Output with PowerShell Classes: Stable Pipelines by Design

You can make your PowerShell pipelines far more predictable by explicitly modeling results as classes, validating at construction time, and formatting only at the edges. Instead of returning loosely shaped PSCustomObject values, you return typed instances that carry a clear contract. The result: stable pipelines, fewer surprises, easier tests, and better tooling.

In this post, youll learn how to design strong, typed output with PowerShell classes, use static builders to parse raw inputs, and keep formatting separate from your core data flow. Well walk through practical patterns you can reuse in scripts, modules, and CI/CD pipelines.

Why Typed Output Matters

PowerShells dynamic nature is great for exploration, but it often leads to brittle pipelines in production:

  • Property names and shapes drift over time.
  • Values vary by source (e.g., version as a string in one step, a number in another).
  • Downstream code relies on incidental formatting instead of types.
  • Tests assert on text, not data, making refactors risky.

By defining classes, you lock down a contract at the boundary:

  • Predictable types: e.g., parse Version into [version] once, then compare and sort numerically everywhere.
  • Early validation: catch bad input at construction, not three steps later.
  • Tooling wins: IntelliSense, tab completion, and discoverability in editors.
  • Stable pipelines: downstream steps dont break because a property changed type or casing.

String vs. typed comparisons

Consider how version sorting breaks when you treat versions as strings:

# String sort (wrong)
'2.0.0','10.0.0','1.2.3' | Sort-Object
# => 1.2.3, 10.0.0, 2.0.0

# Typed sort (correct)
[version[]]@('2.0.0','10.0.0','1.2.3') | Sort-Object
# => 1.2.3, 2.0.0, 10.0.0

Now bake that guarantee into your output type so every consumer benefits automatically.

Model Results as Classes (with Validation and Builders)

This pattern captures the core idea: validate once at construction, parse raw inputs via a static builder, and only emit valid, typed instances.

class PackageInfo {
  [string]$Name
  [version]$Version

  hidden static [bool] TryParseVersion([string]$ver, [ref][version]$out) {
    return [version]::TryParse($ver, $out)
  }

  PackageInfo([string]$name, [string]$ver) {
    if ([string]::IsNullOrWhiteSpace($name)) {
      throw [ArgumentException]::new('Name is required.')
    }
    [version]$v = $null
    if (-not [PackageInfo]::TryParseVersion($ver, [ref]$v)) {
      throw [ArgumentException]::new("Invalid version: $ver")
    }
    $this.Name = $name
    $this.Version = $v
  }

  static [PackageInfo] FromObject([psobject]$o) {
    if (-not $o.PSObject.Properties.Match('Name').Count -or -not $o.PSObject.Properties.Match('Version').Count) {
      throw [ArgumentException]::new('Object must have Name and Version.')
    }
    return [PackageInfo]::new([string]$o.Name, [string]$o.Version)
  }

  static [bool] TryFrom([psobject]$o, [ref][PackageInfo]$pkg) {
    try {
      $pkg.Value = [PackageInfo]::FromObject($o)
      return $true
    } catch {
      $pkg.Value = $null
      return $false
    }
  }

  [string] ToString() {
    return '{0}@{1}' -f $this.Name, $this.Version
  }
}

$raw = @(
  @{ Name='App'; Version='1.2.3' },
  @{ Name='Lib'; Version='2.0.0' },
  @{ Name='Bad'; Version='x.y.z' }
)

$items = foreach ($r in $raw) {
  [PackageInfo]$pkg = $null
  if ([PackageInfo]::TryFrom([pscustomobject]$r, [ref]$pkg)) { $pkg }
  else { Write-Warning "Skipping: $($r | ConvertTo-Json -Compress)" }
}

$items | Sort-Object Version -Descending

Key takeaways:

  • Constructor validation guarantees every PackageInfo is well-formed.
  • Static builders (FromObject and TryFrom) centralize parsing logic and allow you to keep edge handling (errors/warnings) out of the core class.
  • Typed properties give you correct comparisons and simpler filters, e.g. $_.Version.Major -ge 2.

Typed functions as stable boundaries

Wrap your class behind a function that advertises typed output. This creates a clear boundary for consumers and improves discoverability in tooling.

function Get-PackageInfo {
  [CmdletBinding()]
  [OutputType([PackageInfo])]
  param(
    [Parameter(Mandatory, ValueFromPipeline)]
    [psobject]$InputObject
  )
  process {
    try {
      [PackageInfo]::FromObject($InputObject)
    } catch {
      Write-Error -ErrorAction Continue -Message $_.Exception.Message
    }
  }
}

# Usage: parse and filter by typed Version
$raw | Get-PackageInfo | Where-Object { $_.Version.Major -ge 2 } | Sort-Object Version

Enums for allowed values

When a property has a closed set of values (e.g., Source: Registry, Git, File), prefer an enum. You get tab completion, validation, and safer filtering.

enum PackageSource { Registry; Git; File }
class SourceInfo {
  [PackageSource]$Source
  [string]$Location
  SourceInfo([string]$source, [string]$location) {
    [PackageSource]$s = $null
    if (-not [PackageSource]::TryParse($source, $true, [ref]$s)) {
      throw [ArgumentException]::new("Invalid source: $source")
    }
    if ([string]::IsNullOrWhiteSpace($location)) {
      throw [ArgumentException]::new('Location is required.')
    }
    $this.Source = $s
    $this.Location = $location
  }
}

Composing objects is straightforward: you can include SourceInfo inside PackageInfo or return them side-by-side as separate objects that share an identifier.

Format at the Edges: Views, ToString, and Integrations

Keep your core pipeline purely data-oriented. Emit typed instances from functions and modules. Format only at the edges: CLI display, reports, logs, and serialized outputs.

Human-friendly default display

You can define a display view for your class using a .format.ps1xml file. This preserves the purity of your objects while providing a clean CLI view.

<?xml version="1.0" encoding="utf-8" ?>
<Configuration>
  <ViewDefinitions>
    <View>
      <Name>PackageInfoDefault</Name>
      <ViewSelectedBy>
        <TypeName>PackageInfo</TypeName>
      </ViewSelectedBy>
      <TableControl>
        <TableHeaders>
          <TableColumnHeader><Label>Name</Label></TableColumnHeader>
          <TableColumnHeader><Label>Version</Label></TableColumnHeader>
        </TableHeaders>
        <TableRowEntries>
          <TableRowEntry>
            <TableColumnItems>
              <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
              <TableColumnItem><PropertyName>Version</PropertyName></TableColumnItem>
            </TableColumnItems>
          </TableRowEntry>
        </TableRowEntries>
      </TableControl>
    </View>
  </ViewDefinitions>
</Configuration>

# Load the view when your module imports
Update-FormatData -PrependPath (Join-Path $PSScriptRoot 'PackageInfo.format.ps1xml')

Overriding ToString() for a compact id (e.g., Name@Version) is also useful for logs and warnings, while the full object remains richly typed.

Serialize for APIs and reports

When sending data to external systems, convert at the edge. The class keeps your pipeline safe; the serializer handles the shape needed externally.

$packages = $raw | Get-PackageInfo | Sort-Object Name
$packages | ConvertTo-Json -Depth 3 | Set-Content packages.json
$packages | Export-Csv -NoTypeInformation -Path packages.csv

This keeps JSON/CSV decisions out of your core logic and makes integration points obvious and testable.

Testing typed outputs

Typed outputs shine in tests because you can assert on structure and semantics, not just text rendering.

# Pester example
Describe 'Get-PackageInfo' {
  It 'emits only PackageInfo objects' {
    $raw = @(
      @{ Name='App'; Version='1.2.3' },
      @{ Name='Bad'; Version='x.y.z' }
    )
    $result = $raw | Get-PackageInfo -ErrorAction SilentlyContinue
    $result | Should -All -BeOfType PackageInfo
  }
  It 'sorts by numeric version' {
    $raw = @(
      @{ Name='A'; Version='2.0.0' },
      @{ Name='B'; Version='10.0.0' }
    )
    ($raw | Get-PackageInfo | Sort-Object Version | Select-Object -First 1).Name | Should -Be 'A'
  }
}

Practical tips

  • Validate at construction: throw early to keep bad data out of the pipeline.
  • Separate parsing from modeling: use static builders and TryFrom-style methods for resilience.
  • Avoid formatting mid-pipeline: never pipe to Format-* until the final step.
  • Use enums for closed sets and typed properties for comparisons (e.g., [datetime], [version], [int]).
  • Annotate functions with [OutputType()]: helps documentation and tooling.
  • Keep contracts stable: when you must change, add new properties rather than changing types.

Putting It All Together

Model your domain with classes, parse raw inputs with static builders, emit only valid typed instances, and format at the edges. Youll gain:

  • Clear contracts: consumers know exactly what to expect.
  • Predictable outputs: less defensive code downstream.
  • Easier tests: assert on types and properties, not strings.
  • Better tooling: IntelliSense, discoverability, and reliable refactors.

Start small: pick one script or function that emits PSCustomObject and replace it with a class. Enforce invariants in the constructor, add a FromObject builder, and wire up a .format.ps1xml view if the CLI output matters. Your pipelines will immediately feel sturdier.

If you want to go deeper on advanced patterns and tooling, check out the PowerShell Advanced Cookbook  it covers robust scripting techniques, error handling, modules, and more. Read here: PowerShell Advanced Cookbook.

← All Posts Home →