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(), andToJson()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:
Cadencedefaults toWeekly,RetentionDaysto 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 thatConvertTo-Jsonwill 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 5Unit 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
Parseto produce validatedReportSpecobjects. - Logging: Use
ToString()for concise logs andToJson()for structured logs. - Change control: Adding a property or enum member creates a visible diff in the class, improving code reviews.
- Module packaging: Put
enumandclassdefinitions in your module’s.psm1or.ps1files 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 $specPractical 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
ArgumentExceptionorArgumentOutOfRangeExceptionwith actionable messages. - Offer projection methods:
ToPsObject()andToJson()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=60What 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.