TB

MoppleIT Tech Blog

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

Practical PowerShell Classes for Safer Scripts: Constructors, Methods, and Typed Pipelines

PowerShell classes let you model your domain with real types, encode business rules once, and make invalid states unrepresentable. When you validate and normalize data in constructors, expose behavior with methods, and return typed results, your scripts become safer to refactor, easier to test, and far more predictable in pipelines.

Why classes make your scripts safer

Traditional ad-hoc [pscustomobject] values are flexible but easy to misuse. A single misspelled property or unexpected null can break a deployment or produce silent data loss. Classes help you:

  • Enforce invariants with constructors so bad data fails fast.
  • Express intent via explicit types (including enums) instead of loose strings.
  • Bundle behavior with data so callers don’t re-implement the same logic.
  • Return typed results so pipelines are predictable and refactors are safer.

Let’s model a simple ServerInfo type and then build on it with practical techniques you can reuse across scripts and modules.

Model invalid states out with constructors and enums

Start by representing allowed values with an enum instead of permissive strings. Then validate and normalize in the constructor.

# Restrict environment to known values
enum EnvKind { Dev; Test; Prod }

class ServerInfo {
  [string]   $Name
  [EnvKind]  $Env
  [int]      $UptimeDays

  ServerInfo([string]$name, [EnvKind]$env, [int]$uptime) {
    if ([string]::IsNullOrWhiteSpace($name)) { throw 'Name is required.' }
    if ($uptime -lt 0) { throw 'UptimeDays cannot be negative.' }

    # Normalize
    $this.Name = $name.Trim()
    $this.Env = $env
    $this.UptimeDays = $uptime
  }

  [string] ToString() { '{0} ({1}) - {2}d' -f $this.Name, $this.Env, $this.UptimeDays }
}

# Produce typed objects you can trust
$items = @(
  [ServerInfo]::new('web01','Prod',12),
  [ServerInfo]::new('api01','Test',5)
)
$items | ForEach-Object { $_.ToString() }

Why an enum? Because it encodes allowed states at the type level. Attempts to pass "Production" will fail during construction, not hours later in a runbook. If you can’t use an enum (e.g., values come from an external source you can’t fully control), you can mimic the check with a ValidateSet-style guard in the constructor:

class LegacyServerInfo {
  [string]$Name; [string]$Env; [int]$UptimeDays
  LegacyServerInfo([string]$name,[string]$env,[int]$uptime){
    if ([string]::IsNullOrWhiteSpace($name)) { throw 'Name is required.' }
    if ($env -notin @('Dev','Test','Prod')) { throw 'Env must be Dev, Test, or Prod.' }
    if ($uptime -lt 0) { throw 'UptimeDays cannot be negative.' }
    $this.Name = $name.Trim()
    $this.Env = $env
    $this.UptimeDays = $uptime
  }
}

Normalize early, normalize once

Do all canonicalization in the constructor so consumers see clean, consistent 'Name is required.' } if ($uptime -lt 0) { throw 'UptimeDays cannot be negative.' } $this.Name = $name.Trim() $this.Env = $env $this.UptimeDays = $uptime } [bool] IsProd(){ $this.Env -eq [EnvKind]::Prod } [bool] IsStale([int]$minDays){ $this.UptimeDays -ge $minDays } # Compute the next maintenance window (e.g., Sunday 01:00 local time) [datetime] NextMaintenance([int]$dayOfWeek = [int][System.DayOfWeek]::Sunday, [int]$hour = 1){ $now = Get-Date $target = Get-Date -Hour $hour -Minute 0 -Second 0 while (([int]$target.DayOfWeek -ne $dayOfWeek) -or ($target -le $now)) { $target = $target.AddDays(1) } return $target } [string] ToString(){ '{0} ({1}) - {2}d' -f $this.Name, $this.Env, $this.UptimeDays } } $servers = @( [ServerInfo]::new('web01','Prod',12), [ServerInfo]::new('api01','Test',5), [ServerInfo]::new('job01','Dev',21) ) # Behavior-driven filters are easier to read and refactor $servers | Where-Object { $_.IsProd() -and $_.IsStale(7) } | Sort-Object UptimeDays -Descending | ForEach-Object { "Maintenance due: $($_.Name) at $($_.NextMaintenance())" }

Notice how the pipeline reads like a sentence. You’re not re-implementing environment checks or date math in every script; you call the method and move on.

Return typed results for predictable pipelines

Strong output contracts catch mistakes earlier and make your tools easier to compose. You can export a function that always emits [ServerInfo] objects and nothing else.

function Get-ServerInfoFromCsv {
  [CmdletBinding()]
  [OutputType([ServerInfo])]
  param(
    [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
    [string]$Path
  )
  process {
    foreach ($row in (Import-Csv -Path $Path)) {
      try {
        [ServerInfo]::new($row.Name, [EnvKind]$row.Env, [int]$row.UptimeDays)
      } catch {
        Write-Error -ErrorAction Continue -Message "Invalid row for '$($row.Name)': $($_.Exception.Message)"
      }
    }
  }
}

# Predictable, typed outputs enable safe transformations
$prodDue = Get-ServerInfoFromCsv -Path './servers.csv' |
  Where-Object IsProd |
  Where-Object { $_.IsStale(10) }

# The variable holds only [ServerInfo] instances
$prodDue.GetType().Name  # Object[] of ServerInfo

For bulk import with robust error isolation, consider a TryCreate pattern that never throws during batch processing:

class ServerFactory {
  static [bool] TryCreate([object]$row, [ref]$result, [ref]$errorMessage){
    try {
      $result.Value = [ServerInfo]::new($row.Name, [EnvKind]$row.Env, [int]$row.UptimeDays)
      return $true
    } catch {
      $errorMessage.Value = $_.Exception.Message
      return $false
    }
  }
}

$good = @(); $bad = @()
foreach ($r in (Import-Csv './servers.csv')) {
  $out = $null; $err = $null
  if ([ServerFactory]::TryCreate($r, [ref]$out, [ref]$err)) { $good += $out } else { $bad += [pscustomobject]@{ Row = $r; Error = $err } }
}

$good.Count  # Valid [ServerInfo]
$bad         # Invalid rows + reason

Make classes feel native in the shell

Default formatting

Give your class a friendly default table view so it displays well without extra formatting:

Update-TypeData -TypeName ServerInfo -DefaultDisplayPropertySet Name,Env,UptimeDays -Force

Now $servers renders in an ergonomic way across sessions.

Serialize predictably

When you persist or transmit instances, define stable conversions:

class ServerInfo {
  [string]$Name; [EnvKind]$Env; [int]$UptimeDays
  ServerInfo([string]$name,[EnvKind]$env,[int]$uptime){ if(-not $name){throw 'Name is required.'}; if($uptime -lt 0){throw 'UptimeDays cannot be negative.'}; $this.Name=$name.Trim(); $this.Env=$env; $this.UptimeDays=$uptime }

  [pscustomobject] ToRecord(){
    [pscustomobject]@{ Name = $this.Name; Env = $this.Env.ToString(); UptimeDays = $this.UptimeDays }
  }

  static [ServerInfo] FromRecord([pscustomobject]$o){
    return [ServerInfo]::new($o.Name, [EnvKind]$o.Env, [int]$o.UptimeDays)
  }
}

# CSV round-trip
$servers | ForEach-Object { $_.ToRecord() } | Export-Csv './servers.csv' -NoTypeInformation
$servers2 = Import-Csv './servers.csv' | ForEach-Object { [ServerInfo]::FromRecord($_) }

Practical tips and pitfalls

  • Keep constructors cheap. Heavy I/O or remote calls inside new() slow pipelines. Prefer lazy methods or factory functions for side effects.
  • Prefer enums over free-form strings. They’re self-documenting and prevent invalid values.
  • Expose behavior with methods. Don’t leak business rules everywhere; add IsStale(), IsProd(), or NextMaintenance() and reuse them.
  • Return typed results. Use [OutputType()] on functions and explicit return types on methods for discoverability and editor tooltips.
  • Fail fast with clear messages. Throw in constructors when invariants are broken. In batch import, prefer a TryCreate pattern to collect errors without stopping the run.
  • Provide a stable ToString() for logs. Don’t parse ToString() output; use ToRecord() for structured data.
  • Test invariants with Pester. Add unit tests for constructor validation and method behavior. Typed models are easier to test than loose objects.
  • Module-ize your models. Place classes in a module (.psm1) so teams reuse the same safe models everywhere.

End-to-end example: predictably filter and schedule

This small script ties it all together: import servers, keep only valid production hosts that require maintenance, then emit a schedule you can email or push to a ticketing system.

enum EnvKind { Dev; Test; Prod }
class ServerInfo {
  [string]$Name; [EnvKind]$Env; [int]$UptimeDays
  ServerInfo([string]$name,[EnvKind]$env,[int]$uptime){
    if([string]::IsNullOrWhiteSpace($name)){ throw 'Name is required.' }
    if($uptime -lt 0){ throw 'UptimeDays cannot be negative.' }
    $this.Name=$name.Trim(); $this.Env=$env; $this.UptimeDays=$uptime
  }
  [bool] IsProd(){ $this.Env -eq [EnvKind]::Prod }
  [bool] IsStale([int]$minDays){ $this.UptimeDays -ge $minDays }
  [datetime] NextMaintenance([int]$dayOfWeek = [int][System.DayOfWeek]::Sunday, [int]$hour = 1){
    $now = Get-Date; $target = Get-Date -Hour $hour -Minute 0 -Second 0
    while (([int]$target.DayOfWeek -ne $dayOfWeek) -or ($target -le $now)) { $target = $target.AddDays(1) }
    $target
  }
}

# Import, validate, and filter
$all = Import-Csv './servers.csv' | ForEach-Object {
  try { [ServerInfo]::new($_.Name, [EnvKind]$_.Env, [int]$_.UptimeDays) } catch { Write-Error -Message "Bad row for '$($_.Name)': $($_.Exception.Message)" -ErrorAction Continue }
}

$maintenance = $all |
  Where-Object IsProd |
  Where-Object { $_.IsStale(14) } |
  Sort-Object UptimeDays -Descending |
  ForEach-Object {
    [pscustomobject]@{
      Server = $_.Name
      Env    = $_.Env
      Uptime = $_.UptimeDays
      Window = $_.NextMaintenance()
    }
  }

$maintenance | Format-Table -AutoSize
# Or export for downstream automation
$maintenance | Export-Csv './maintenance-plan.csv' -NoTypeInformation

What you get: stronger types, clearer intent, safer refactors, and predictable outputs that compose cleanly in CI/CD and operations workflows.

Build robust models in PowerShell. Explore the PowerShell Advanced CookBook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

← All Posts Home →