TB

MoppleIT Tech Blog

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

Stronger Data Models with PowerShell Classes: Enums, Constructors, and Testable Objects

When scripts grow into systems, loose hashtables turn into liabilities. Types give you guardrails: predictable inputs, discoverable intent, and reusable outputs. In PowerShell, classes and enums let you design clear data models that validate early and behave consistently. In this post, you will model inputs as types (not guesses), enforce allowed values with enums, validate in constructors with helpful errors, and return objects you can test and reuse.

Why model inputs as types?

Hashtables are convenient but permissive. Classes shift you from “hope it works” to “make it correct.” Here’s what you gain:

  • Predictable inputs: Typed properties (string, int, enum) enforce shape at assignment time.
  • Clearer reviews: A class definition becomes living documentation of required and optional fields, defaults, and constraints.
  • Safer refactors: Constructor validation fails fast, keeping broken data out of your pipeline.
  • Discoverability: IntelliSense shows members and acceptable enum values; teammates don’t guess keys.
  • Reusable outputs: Methods like ToString(), ToPsObject(), and ToJson() produce stable, testable shapes for logs, APIs, and CI/CD steps.

Bottom line: you reduce ambiguity and runtime surprises. That’s especially valuable in DevOps automation, scheduled jobs, and any place where scripts run unattended.

Implementing a robust model with PowerShell classes

Define allowed values with an enum

enum Cadence { Daily; Weekly; Monthly }

Enums encode a closed set of allowed values. If someone tries to set a cadence outside the set, PowerShell throws immediately.

Build the class with defaults and validation

class ReportSpec {
  [string]$User
  [Cadence]$Cadence = [Cadence]::Weekly
  [int]$RetentionDays = 30

  ReportSpec([string]$User) {
    if ([string]::IsNullOrWhiteSpace($User) -or $User -notmatch '^[a-z0-9._-]+$') {
      throw [ArgumentException]::new('Invalid user: only lowercase letters, digits, dot, underscore, and hyphen are allowed.')
    }
    $this.User = $User
  }

  ReportSpec([string]$User, [Cadence]$Cadence, [int]$RetentionDays) : this($User) {
    $this.Cadence = $Cadence
    if ($RetentionDays -lt 1 -or $RetentionDays -gt 3650) {
      throw [ArgumentOutOfRangeException]::new('RetentionDays', $RetentionDays, 'RetentionDays must be between 1 and 3650.')
    }
    $this.RetentionDays = $RetentionDays
  }

  static [ReportSpec] Parse([hashtable]$h) {
    if (-not $h.ContainsKey('User')) {
      throw [ArgumentException]::new('Missing required key: User')
    }
    $cadence = if ($h.ContainsKey('Cadence') -and $h['Cadence']) {
      [Enum]::Parse([Cadence], [string]$h['Cadence'], $true)
    } else { [Cadence]::Weekly }

    $ret = if ($h.ContainsKey('RetentionDays') -and $h['RetentionDays']) { [int]$h['RetentionDays'] } else { 30 }

    return [ReportSpec]::new([string]$h['User'], [Cadence]$cadence, $ret)
  }

  [string] ToString() {
    return "User=$($this.User); Cadence=$($this.Cadence); Retain=$($this.RetentionDays)"
  }

  [pscustomobject] ToPsObject() {
    [pscustomobject]@{
      User          = $this.User
      Cadence       = [string]$this.Cadence  # stringify enum for JSON and logs
      RetentionDays = $this.RetentionDays
    }
  }

  [string] ToJson() {
    return ($this.ToPsObject() | ConvertTo-Json -Depth 3)
  }
}

# Usage
$spec = [ReportSpec]::new('morten')
$spec.Cadence = [Cadence]::Daily
$spec.RetentionDays = 60
$spec.ToString()

Key points:

  • Defaults: Cadence defaults to Weekly, RetentionDays to 30, making simple cases easy.
  • Validation: The constructor throws clear errors early. Reviewers see rules in one place.
  • Parse method: Converts loose inputs (like CSV rows or hashtables) into validated objects.
  • JSON projection: ToPsObject() ensures enums serialize as strings. Note that ConvertTo-Json will otherwise serialize enum values as numbers.

Using, testing, and shipping your types

Create specs from diverse inputs

You’ll often get data from CSV, REST, or config files. Use Parse to normalize and validate.

# From a hashtable
$h = @{ User = 'sara'; Cadence = 'Monthly'; RetentionDays = 90 }
$spec = [ReportSpec]::Parse($h)

# From CSV
$rows = @'
User,Cadence,RetentionDays
morten,Daily,60
sara,Monthly,90
'@ | ConvertFrom-Csv

$specs = $rows | ForEach-Object {
  $ht = @{}
  $_.PSObject.Properties | ForEach-Object { $ht[$_.Name] = $_.Value }
  [ReportSpec]::Parse($ht)
}

$specs | ForEach-Object { $_.ToString() }

Fail fast with clear errors

try {
  # Bad username (uppercase + space)
  [ReportSpec]::new('Alice Smith') | Out-Null
}
catch {
  Write-Warning "Validation failed: $($_.Exception.Message)"
}

Because validation lives in your class, you get consistent checks everywhere: scripts, functions, CI pipelines, or interactive sessions.

Serialize safely for APIs and pipelines

$spec = [ReportSpec]::new('morten', [Cadence]::Weekly, 30)
$json = $spec.ToJson()
$json
# {"User":"morten","Cadence":"Weekly","RetentionDays":30}

# Stable shape for REST
Invoke-RestMethod -Uri 'https://example/api/reports' -Method Post -Body $json -ContentType 'application/json'

Tip: When serializing collections, project via ToPsObject() to preserve enum names.

$payload = $specs | ForEach-Object { $_.ToPsObject() } | ConvertTo-Json -Depth 5

Unit test with Pester

Test behavior, not guesswork. Constructors, defaults, and methods become straightforward to verify.

# Pester v5 example
Describe 'ReportSpec' {
  It 'applies sensible defaults' {
    $s = [ReportSpec]::new('morten')
    $s.Cadence | Should -Be ([Cadence]::Weekly)
    $s.RetentionDays | Should -Be 30
  }

  It 'rejects invalid usernames' {
    { [ReportSpec]::new('Bad User') } | Should -Throw -ErrorType System.ArgumentException
  }

  It 'parses hashtable and validates domain values' {
    $h = @{ User='sara'; Cadence='Daily'; RetentionDays=10 }
    $s = [ReportSpec]::Parse($h)
    $s.User | Should -Be 'sara'
    $s.Cadence | Should -Be ([Cadence]::Daily)
  }

  It 'projects stable JSON with string enums' {
    $s = [ReportSpec]::new('morten', [Cadence]::Weekly, 7)
    $obj = $s.ToJson() | ConvertFrom-Json
    $obj.Cadence | Should -Be 'Weekly'
    $obj.RetentionDays | Should -Be 7
  }
}

Integrate in DevOps pipelines

  • Input contracts: Accept raw inputs (CSV, JSON) at the edge, then map through Parse to produce validated ReportSpec objects.
  • Logging: Use ToString() for concise logs and ToJson() for structured logs.
  • Change control: Adding a property or enum member creates a visible diff in the class, improving code reviews.
  • Module packaging: Put enum and class definitions in your module’s .psm1 or .ps1 files that load on import. Export functions that consume/produce these types.

From legacy hashtables to types

If you have existing scripts with hashtables, introduce a mapping layer once and keep the rest of your code typed:

# Legacy: raw hashtable moves through the system (easy to break)
$legacy = @{ User='morten'; cadence='DAILY'; RetentionDays='60' }

# Transitional map: normalize casing, coerce types, validate
$ht = @{
  User = $legacy.User
  Cadence = $legacy.cadence
  RetentionDays = [int]$legacy.RetentionDays
}
$spec = [ReportSpec]::Parse($ht)

# Downstream code stays strongly typed
Invoke-ReportJob -Spec $spec

Practical tips

  • Prefer enums over ValidateSet: [ValidateSet()] is great for function parameters but doesn’t protect class properties. Enums enforce allowed values everywhere.
  • Use guard clauses: Validate early in constructors; throw ArgumentException or ArgumentOutOfRangeException with actionable messages.
  • Offer projection methods: ToPsObject() and ToJson() keep serialization consistent, including stringifying enums.
  • Keep invariants obvious: Defaults and ranges belong in the class, not scattered across scripts.
  • Design for extension: Add new enum members (e.g., Quarterly) and properties with defaults to maintain backward compatibility.

Full example: build, validate, reuse

enum Cadence { Daily; Weekly; Monthly }

class ReportSpec {
  [string]$User
  [Cadence]$Cadence = [Cadence]::Weekly
  [int]$RetentionDays = 30

  ReportSpec([string]$User) {
    if ([string]::IsNullOrWhiteSpace($User) -or $User -notmatch '^[a-z0-9._-]+$') {
      throw [ArgumentException]::new('Invalid user: only lowercase letters, digits, dot, underscore, and hyphen are allowed.')
    }
    $this.User = $User
  }

  [string] ToString() { "User=$($this.User); $($this.Cadence); Retain=$($this.RetentionDays)" }
}

# Construct and adjust
$spec = [ReportSpec]::new('morten'); $spec.Cadence = [Cadence]::Daily; $spec.RetentionDays = 60
$spec.ToString()
# User=morten; Daily; Retain=60

What you get: clearer models, safer inputs, predictable behavior, and easier reviews—especially when multiple teams rely on consistent automation.

Build with types, not guesses. If you want more patterns like this, check out the PowerShell Advanced Cookbook: PowerShell Advanced Cookbook.

← All Posts Home →