TB

MoppleIT Tech Blog

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

PowerShell Classes for Safer Data Models: Guardrails, Parse/ToString, and Object-First Scripts

If your PowerShell scripts pass around loosely shaped hashtables and ad-hoc strings, you’re shouldering unnecessary risk. By modeling domain data with classes, you validate early, document intent, and keep scripts honest. Constructors with guardrails, predictable Parse()/ToString() methods, and object-first returns give you stronger models, clearer contracts, and safer data across modules, pipelines, and automation.

Why typed classes beat ad-hoc objects

PowerShell classes let you encode domain rules where they belong: with the data. Instead of sprinkling validation across scripts, you validate once in the constructor and rely on the type everywhere else.

  • Early failure: invalid data is rejected at creation time, not halfway through a deployment.
  • Self-documenting contracts: parameter types communicate rules to callers and tooling.
  • Predictable IO: Parse()/ToString() provide round-trippable representations for logs, CLI input, and config files.
  • Composability: compose classes into richer models (e.g., a Project that owns a UserId).
  • Safer automation: return objects, not strings, so callers can pipe, format, and serialize reliably.

Pattern: Constructor guardrails + Parse()/ToString()

Start with a small, focused type that encapsulates validation and stable formatting. The example below models a UserId with a constrained character set and length. The constructor enforces invariants, ToString() returns a canonical value, and Parse() gives a predictable entry point for string input.

class UserId {
  [string]$Value

  UserId([string]$v) {
    if ([string]::IsNullOrWhiteSpace($v) -or $v -notmatch '^[a-z0-9._-]{3,30}$') {
      throw [ArgumentException]::new([string]::Format('Invalid UserId: {0}', $v))
    }
    $this.Value = $v
  }

  [string] ToString() { $this.Value }

  static [UserId] Parse([string]$s) { [UserId]::new($s) }

  # Optional: TryParse for non-throwing workflows
  static [bool] TryParse([string]$s, [ref] [UserId]$result) {
    try {
      $result.Value = [UserId]::new($s)
      return $true
    } catch {
      $result.Value = $null
      return $false
    }
  }

  # Optional: equality for case-insensitive comparison and dictionary keys
  [bool] Equals([object]$o) {
    if ($null -eq $o -or $o.GetType() -ne [UserId]) { return $false }
    return $this.Value -ieq ([UserId]$o).Value
  }

  [int] GetHashCode() {
    return [StringComparer]::OrdinalIgnoreCase.GetHashCode($this.Value)
  }
}

Use Parse() when you want a clear conversion intent, and TryParse() when you want to avoid exceptions in hot paths (e.g., bulk import). Keep ToString() stable and unambiguous so logs and config round-trip cleanly.

Composition and serialization

Model richer objects by composing types. Serialize as records for JSON/CSV, keeping the public surface predictable.

class Project {
  [string]   $Name
  [UserId]   $Owner
  [datetime] $CreatedAt

  Project([string]$n, [UserId]$o) {
    if ([string]::IsNullOrWhiteSpace($n)) { throw [ArgumentException]::new('Name required') }
    $this.Name      = $n.Trim()
    $this.Owner     = $o
    $this.CreatedAt = [datetime]::UtcNow
  }

  [pscustomobject] ToRecord() {
    [pscustomobject]@{
      Name      = $this.Name
      Owner     = $this.Owner.ToString()
      CreatedAt = $this.CreatedAt
    }
  }

  static [Project] FromRecord([pscustomobject]$r) {
    if ($null -eq $r -or [string]::IsNullOrWhiteSpace([string]$r.Name) -or [string]::IsNullOrWhiteSpace([string]$r.Owner)) {
      throw [ArgumentException]::new('Record must have Name and Owner')
    }
    return [Project]::new([string]$r.Name, [UserId]::Parse([string]$r.Owner))
  }

  [string] ToString() { '{0} (Owner: {1})' -f $this.Name, $this.Owner }
}

# Usage
$owner = [UserId]::Parse('morten')
$proj  = [Project]::new('Cookbook', $owner)
$proj.ToRecord() | ConvertTo-Json -Depth 4

ToRecord() keeps your serialized format stable, even if you later add new private properties to your class.

Return objects, not strings

Favor object returns in functions. Let formatting views handle display. Your callers keep structure and can convert to strings when needed.

function New-Project {
  [CmdletBinding()]
  [OutputType([Project])]
  param(
    [Parameter(Mandatory)] [string] $Name,
    # Parameter binding will invoke the UserId(string) constructor for you
    [Parameter(Mandatory)] [UserId] $Owner
  )
  [Project]::new($Name, $Owner)
}

# Both calls are valid; the second leverages constructor binding
$p1 = New-Project -Name 'Cookbook' -Owner ([UserId]::Parse('morten'))
$p2 = New-Project -Name 'Runbook'  -Owner 'devops-bot'

Integrating with scripts, CLIs, and pipelines

Typed parameters and early validation

When you type a parameter as [UserId], PowerShell will automatically attempt to construct that class from the incoming value. If validation fails in the constructor, the error is raised at the boundary, not after side effects occur.

function Set-ProjectOwner {
  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter(Mandatory)] [string]  $ProjectName,
    [Parameter(Mandatory)] [UserId]  $NewOwner
  )
  if ($PSCmdlet.ShouldProcess($ProjectName, 'Set owner')) {
    # Lookup and update logic here
    "Owner for {0} set to {1}" -f $ProjectName, $NewOwner | Out-Host
  }
}

Round-tripping with JSON/CSV

Keep IO predictable by standardizing on record shapes. For bulk operations, parse inputs into your classes before mutations.

# CSV import: Name,Owner columns
Import-Csv projects.csv |
  ForEach-Object { [Project]::FromRecord($_) } |
  ForEach-Object { $_.ToRecord() } |
  ConvertTo-Json -Depth 4 | Set-Content projects.json

# JSON restore
$records = Get-Content projects.json | ConvertFrom-Json
$projects = foreach ($r in $records) { [Project]::FromRecord($r) }

Non-throwing bulk parse

Use TryParse() in bulk data processing to collect errors without halting the pipeline.

$uids = 'ok-user', 'NO', '$bad', 'valid_user'

$parsed = foreach ($s in $uids) {
  $uid = $null
  if ([UserId]::TryParse($s, [ref]$uid)) { $uid } else { [pscustomobject]@{ Error = "Invalid UserId: $s" } }
}

Testing your invariants

Pester tests ensure guardrails hold as your rules evolve.

Describe 'UserId' {
  It 'accepts valid ids' {
    { [UserId]::new('dev-ops_42') } | Should -Not -Throw
  }
  It 'rejects invalid ids' {
    { [UserId]::new('$bad!') } | Should -Throw
  }
}

Describe 'Project' {
  It 'serializes to a stable record' {
    $p = [Project]::new('Cookbook', [UserId]::Parse('morten'))
    $rec = $p.ToRecord()
    $rec.Name   | Should -Be 'Cookbook'
    $rec.Owner  | Should -Be 'morten'
  }
}

Practical tips and best practices

  • Keep types small and focused. One responsibility per class (e.g., identifier, email, version).
  • Validate on construction. Fail fast where data enters the system (CLI arg, config, API).
  • Stabilize formatting. Make ToString() canonical and round-trippable with Parse().
  • Prefer composition. Build richer aggregates from smaller validated types.
  • Expose record shapes. Use ToRecord()/FromRecord() for persistence, messaging, and reporting.
  • Type your parameters. Let PowerShell’s binder construct your classes and surface early errors.
  • Use specific exceptions. Throw ArgumentException/InvalidOperationException with helpful messages.
  • Implement Equals()/GetHashCode() when identity matters (e.g., dictionary keys, set operations).
  • Ship types in a module. Keep classes in a .psm1 or a compiled module; import to reuse across repos and pipelines.
  • Add formatting views. For human-friendly output, define a .format.ps1xml view instead of building strings in your functions.

Full example: mini module snippet

Package your types and commands together for reuse in automation and CI/CD.

# MyDomain.psm1 (excerpt)
class UserId {
  [string]$Value
  UserId([string]$v) {
    if ([string]::IsNullOrWhiteSpace($v) -or $v -notmatch '^[a-z0-9._-]{3,30}$') {
      throw [ArgumentException]::new([string]::Format('Invalid UserId: {0}', $v))
    }
    $this.Value = $v
  }
  [string] ToString() { $this.Value }
  static [UserId] Parse([string]$s) { [UserId]::new($s) }
}

class Project {
  [string] $Name
  [UserId] $Owner
  Project([string]$n, [UserId]$o) {
    if ([string]::IsNullOrWhiteSpace($n)) { throw [ArgumentException]::new('Name required') }
    $this.Name  = $n.Trim()
    $this.Owner = $o
  }
  [pscustomobject] ToRecord() { [pscustomobject]@{ Name = $this.Name; Owner = $this.Owner.ToString() } }
}

function New-Project {
  [CmdletBinding()]
  [OutputType([Project])]
  param(
    [Parameter(Mandatory)] [string] $Name,
    [Parameter(Mandatory)] [UserId] $Owner
  )
  [Project]::new($Name, $Owner)
}

function Export-Project {
  [CmdletBinding()] param([Parameter(ValueFromPipeline,Mandatory)][Project]$Project,[string]$Path)
  process { $Project.ToRecord() | ConvertTo-Json -Depth 4 | Set-Content -Path $Path }
}

With this structure, CI tasks can pass validated objects through your pipeline, not fragile strings. Logs and artifacts are stable and predictable, and failures happen early and clearly.

Wrap-up

By modeling domain data with PowerShell classes, you harden your automation: constructors enforce guardrails, Parse()/ToString() give predictable IO, and functions return objects that compose and serialize cleanly. The result is less boilerplate, fewer edge cases, and more confidence in production pipelines.

Build confidence with typed patterns. Read the PowerShell Advanced Cookbook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

← All Posts Home →