TB

MoppleIT Tech Blog

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

Strongly-Typed Models with PowerShell Classes: Safer Inputs, Cleaner Logs, Predictable Scripts

When the shape of your data matters across scripts and jobs, PowerShell classes give you a predictable, strongly-typed model that travels with your code. By encapsulating validation, defaults, parsing, and printing in one place, you make complex data easier to reason about and much safer to use. In practice, that means clearer models, safer inputs, easier testing, and predictable output every time.

This post shows you how to model real-world data with PowerShell classes, enforce invariants via constructors, normalize messy input through static builders, and produce clean logs with ToString(). You will also see how to wire these models into jobs and CI, and how to avoid common pitfalls.

Why strongly-typed models in PowerShell?

Ad-hoc hashtables and loosely-typed objects are flexible, but they shift all validation and defaulting into scattered checks throughout your scripts. Classes centralize that logic.

  • Predictability: A defined schema with types gives you reliable behavior across functions, scripts, and jobs.
  • Safety: Constructors can reject invalid inputs early, before an API call or deployment step.
  • Clean logging: ToString() provides a consistent, human-friendly summary in logs.
  • Reusability: Shared models make it easy to evolve contracts between modules and teams.
  • Testability: Unit tests target a single, small surface area—your model class—for validation rules and normalization.

The core pattern: validation, defaults, parsing, and printing

Below is a compact example that captures the pattern: a class that enforces required fields in the constructor, has sensible defaults, includes a static builder to normalize inputs, and prints a concise log line.

class AppConfig {
  [string]$ApiBase
  [int]$TimeoutSec = 15
  [bool]$Enabled = $true

  AppConfig([string]$api, [int]$timeout = 15, [bool]$enabled = $true) {
    if (-not $api) { throw 'ApiBase is required' }
    if ($timeout -le 0) { throw 'TimeoutSec must be > 0' }
    $this.ApiBase = $api
    $this.TimeoutSec = $timeout
    $this.Enabled = $enabled
  }

  [string] ToString() {
    'ApiBase={0} Timeout={1} Enabled={2}' -f $this.ApiBase, $this.TimeoutSec, $this.Enabled
  }

  static [AppConfig] FromJson([string]$path) {
    $obj = Get-Content -Path $path -Raw | ConvertFrom-Json -Depth 10
    [AppConfig]::new($obj.ApiBase, $obj.TimeoutSec, $obj.Enabled)
  }
}

# Usage
# $cfg = [AppConfig]::FromJson('.\config.json')
# "$cfg"

This is already a robust improvement over dynamic objects. You can take it further with a general-purpose builder that accepts file paths, JSON strings, hashtables, or PSCustomObject inputs. That allows you to centralize all normalization logic—and your public functions can accept the same parameter type everywhere: [AppConfig].

Generalizing input: a static builder

class AppConfig {
  [string]$ApiBase
  [int]$TimeoutSec = 15
  [bool]$Enabled = $true

  AppConfig([string]$api, [int]$timeout = 15, [bool]$enabled = $true) {
    if (-not $api) { throw 'ApiBase is required' }
    if ($timeout -le 0) { throw 'TimeoutSec must be > 0' }
    $this.ApiBase = $api
    $this.TimeoutSec = $timeout
    $this.Enabled = $enabled
  }

  [void] Validate() {
    if (-not $this.ApiBase) { throw 'ApiBase is required' }
    if ($this.TimeoutSec -le 0) { throw 'TimeoutSec must be > 0' }
  }

  [string] ToString() {
    'ApiBase={0} Timeout={1} Enabled={2}' -f $this.ApiBase, $this.TimeoutSec, $this.Enabled
  }

  static [AppConfig] FromJson([string]$path) {
    $obj = Get-Content -Path $path -Raw | ConvertFrom-Json -Depth 10
    return [AppConfig]::new($obj.ApiBase, ($obj.TimeoutSec), ($obj.Enabled))
  }

  static [AppConfig] From([object]$input) {
    switch ($input) {
      { $_ -is [string] -and (Test-Path -LiteralPath $_) } {
        return [AppConfig]::FromJson([string]$input)
      }
      { $_ -is [string] } {
        $obj = $input | ConvertFrom-Json -Depth 10
        $timeout = if ($obj.TimeoutSec) { [int]$obj.TimeoutSec } else { 15 }
        $enabled = if ($obj.PSObject.Properties.Name -contains 'Enabled') { [bool]$obj.Enabled } else { $true }
        return [AppConfig]::new($obj.ApiBase, $timeout, $enabled)
      }
      { $_ -is [hashtable] } {
        $h = [hashtable]$input
        $timeout = if ($h.ContainsKey('TimeoutSec') -and $h.TimeoutSec) { [int]$h.TimeoutSec } else { 15 }
        $enabled = if ($h.ContainsKey('Enabled')) { [bool]$h.Enabled } else { $true }
        return [AppConfig]::new($h.ApiBase, $timeout, $enabled)
      }
      { $_ -is [pscustomobject] } {
        $o = [pscustomobject]$input
        $timeout = if ($o.TimeoutSec) { [int]$o.TimeoutSec } else { 15 }
        $enabled = if ($o.PSObject.Properties.Name -contains 'Enabled') { [bool]$o.Enabled } else { $true }
        return [AppConfig]::new($o.ApiBase, $timeout, $enabled)
      }
      default {
        throw 'Unsupported input type for AppConfig.From(...)'
      }
    }
  }

  [void] ExportJson([string]$path) {
    $this | Select-Object ApiBase, TimeoutSec, Enabled | ConvertTo-Json -Depth 10 | Set-Content -Path $path -Encoding UTF8
  }
}

# Examples of normalization
# [AppConfig]::From('{"ApiBase":"https://api.example.com","TimeoutSec":5}')
# [AppConfig]::From(@{ ApiBase = 'https://api.example.com'; Enabled = $false })
# [AppConfig]::From('.\config.json')

Any script or function that needs configuration can now accept a single parameter type and defer all input trivia to the model itself:

function Invoke-HealthCheck {
  param(
    [Parameter(Mandatory)]
    [AppConfig]$Config
  )
  $uri = '{0}/health' -f $Config.ApiBase
  Invoke-RestMethod -Uri $uri -TimeoutSec $Config.TimeoutSec | Out-Null
  Write-Host "OK: $Config"
}

Invoke-HealthCheck -Config ([AppConfig]::From('.\config.json'))

Working across scripts and jobs

Typed models shine when multiple scripts or jobs share the same data contract. To use your class in background jobs or remoting sessions, ensure the class is available in that runspace. The simplest way is to place it in a module and import it wherever it is needed.

Package the model in a module

# Models.psm1
class AppConfig {
  [string]$ApiBase
  [int]$TimeoutSec = 15
  [bool]$Enabled = $true
  AppConfig([string]$api, [int]$timeout = 15, [bool]$enabled = $true) {
    if (-not $api) { throw 'ApiBase is required' }
    if ($timeout -le 0) { throw 'TimeoutSec must be > 0' }
    $this.ApiBase = $api
    $this.TimeoutSec = $timeout
    $this.Enabled = $enabled
  }
  [string] ToString() {
    'ApiBase={0} Timeout={1} Enabled={2}' -f $this.ApiBase, $this.TimeoutSec, $this.Enabled
  }
}
Export-ModuleMember -Function * -Alias * -Variable *

Pass the model into a job

Import-Module .\Models.psm1
$cfg = [AppConfig]::new('https://api.example.com', 10, $true)
$modulePath = Join-Path $PSScriptRoot 'Models.psm1'

$job = Start-Job -ScriptBlock {
  param($m, $c)
  Import-Module $m
  # Strongly typed inside the job as well
  "Job received: $c"
} -ArgumentList $modulePath, $cfg

Receive-Job $job -Wait -AutoRemoveJob

Because PowerShell serializes objects when crossing runspace boundaries, the receiving side must import the module that defines the class to rehydrate the type properly. Without the module, you will get a deserialized generic object.

Security, performance, and API ergonomics

Keep secrets out of logs

  • Redact or omit sensitive fields in ToString(). Consider a separate ToLogString() method if you need more detail.
  • Use SecureString or external secret stores and avoid embedding credentials in your model whenever possible.

Prefer immutable or constrained properties

  • Use constructor parameters for required fields and prefer not to mutate them later. In PowerShell classes you can define accessors, e.g., [string] $ApiBase { get; private set; } in PS 7+, to protect invariants.
  • Validate derived fields in a dedicated Validate() method so you can call it after deserialization.

Make serialization predictable

  • Limit what you export via ExportJson() by projecting only the safe fields.
  • For nested classes, raise -Depth in ConvertTo-Json appropriately to avoid truncation.

Version your contracts

  • Add an optional SchemaVersion property and handle migrations in your static From(...) method to avoid breaking older configs.

Testing the model with Pester

Unit tests for your model are cheap and high value. They lock down defaults, validation rules, and serialization behavior.

# AppConfig.Tests.ps1 (Pester v5)
Describe 'AppConfig' {
  It 'rejects an empty ApiBase' {
    { [AppConfig]::new($null) } | Should -Throw -Because 'ApiBase is required'
  }

  It 'applies default TimeoutSec when missing' {
    $cfg = [AppConfig]::From(@{ ApiBase = 'https://api.test' })
    $cfg.TimeoutSec | Should -Be 15
  }

  It 'prints a stable log format' {
    $cfg = [AppConfig]::new('https://api.test', 20, $false)
    "$cfg" | Should -Match 'ApiBase=https://api.test Timeout=20 Enabled=False'
  }
}

Practical tips and checklist

  • Put classes in a module and import them wherever the type is needed (functions, jobs, CI workers).
  • Validate in the constructor and in a separate Validate() method for deserialization scenarios.
  • Normalize all inputs with a static From(...) builder; keep callers simple.
  • Keep ToString() short, stable, and safe—great for logs, diffs, and monitoring.
  • Export only the public, non-sensitive shape via ExportJson().
  • Add additional helpers (e.g., WithOverrides()) to clone configs safely without mutation.

Wrap-up

PowerShell classes give you a clean, strongly-typed backbone for your automation: constructors enforce required fields, static builders normalize messy inputs, and ToString() yields clean logs. Once you start modeling important shapes—like API configs, deployment plans, or job payloads—your scripts become easier to test, safer to run, and far more predictable in production.

Want to go deeper on robust patterns in PowerShell? Build your toolkit with the PowerShell Advanced CookBook. Read more here: PowerShell Advanced CookBook →

← All Posts Home →