Stronger Data Models with PowerShell Classes: Predictable Types, Clean Output, Safer Refactors
Predictable outputs start with clear types. When your scripts return well-structured objects, you get safer refactors, easier code reviews, and fewer surprises in the pipeline. In PowerShell, you can achieve this by modeling results with small, focused classes that validate inputs, normalize data at the edges, and emit pipeline-friendly PSCustomObject output. In this post, youll build a tiny data model for disk information, then see how to extend it, test it, and plug it into CI/CD for reliable automation.
Design a Tiny, Typed Model
Heres a compact class that models a disk, validates inputs in the constructor, rounds numeric values once at the boundary, and exposes a method to return a pipeline-friendly object:
class DiskInfo {
[string]$Name
[double]$FreeGB
[double]$TotalGB
DiskInfo([string]$name, [double]$free, [double]$total) {
if ([string]::IsNullOrWhiteSpace($name)) { throw 'Name required' }
$this.Name = $name
$this.FreeGB = [math]::Round($free, 2)
$this.TotalGB = [math]::Round($total, 2)
}
[double] GetPercentFree() {
if ($this.TotalGB -le 0) { return 0 }
return [math]::Round(($this.FreeGB / $this.TotalGB) * 100, 1)
}
[pscustomobject] ToObject() {
[pscustomobject]@{
Name = $this.Name
FreeGB = $this.FreeGB
TotalGB = $this.TotalGB
PercentFree = $this.GetPercentFree()
}
}
}
Why validate in the constructor?
- Fail fast: If a disk name is missing, you learn immediately, not several functions later.
- Guard invariants: Keep invalid state out of your objects (e.g., negative sizes, blank names).
- Predictable behavior: Downstream code can assume the instance is valid.
Why round at the edge?
- Consistency: Normalize values when crossing boundaries (e.g., raw CIM data to your model).
- Reviewability: Rounding rules live in one place, simplifying reviews and refactors.
- Performance: Avoid repeated rounding within inner loops and later stages.
Why return PSCustomObject?
- Pipeline ergonomics:
PSCustomObjectformats well, is sortable/filterable, and exports cleanly. - Interoperability: Easily consumed by cmdlets like
Sort-Object,Where-Object,ConvertTo-Json, andExport-Csv. - Separation of concerns: Your class is your internal model; the exported object is your stable contract.
Use the Model in the Pipeline
The next step is mapping real system data into your model. Use CIM to read logical disks, convert to GB, and project into DiskInfo instances for consistent, sortable output.
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object {
$totalGB = [math]::Round($_.Size/1GB, 2)
$freeGB = [math]::Round($_.FreeSpace/1GB, 2)
[DiskInfo]::new($_.DeviceID.TrimEnd(':'), $freeGB, $totalGB).ToObject()
} | Sort-Object Name
Practical commands youll actually use
- Show a clean table in the console:
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object { [DiskInfo]::new($_.DeviceID.TrimEnd(':'), ($_.FreeSpace/1GB), ($_.Size/1GB)).ToObject() } |
Sort-Object Name |
Format-Table Name,FreeGB,TotalGB,PercentFree -AutoSize
- Alert on low space (e.g., below 15%):
$threshold = 15
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object { [DiskInfo]::new($_.DeviceID.TrimEnd(':'), ($_.FreeSpace/1GB), ($_.Size/1GB)).ToObject() } |
Where-Object { $_.PercentFree -lt $threshold } |
ForEach-Object { Write-Warning ("{0}: {1}% free" -f $_.Name, $_.PercentFree) }
- Export to JSON and CSV for dashboards and reviews:
# JSON for APIs/dashboards
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object { [DiskInfo]::new($_.DeviceID.TrimEnd(':'), ($_.FreeSpace/1GB), ($_.Size/1GB)).ToObject() } |
ConvertTo-Json -Depth 3 | Set-Content .\disks.json
# CSV for spreadsheet-friendly auditing
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object { [DiskInfo]::new($_.DeviceID.TrimEnd(':'), ($_.FreeSpace/1GB), ($_.Size/1GB)).ToObject() } |
Export-Csv .\disks.csv -NoTypeInformation -UseCulture
Notice that your pipeline never manipulates raw CIM members directly. By centralizing conversions and validation in the class, you reduce duplication and drift. If you change rounding or add a new field, you do it once. Everything downstream benefits.
Extend the Model: Enums, Factories, Tests, and CI
Add more safety with enums and constraints
As your model evolves, you can encode more domain rules. Enums and basic checks give you reliability without bloat.
enum DiskHealth { Unknown; Low; OK }
class DiskInfo {
[string]$Name
[double]$FreeGB
[double]$TotalGB
[DiskHealth]$Health
DiskInfo([string]$name, [double]$free, [double]$total) {
if ([string]::IsNullOrWhiteSpace($name)) { throw 'Name required' }
if ($free -lt 0 -or $total -lt 0) { throw 'Sizes must be non-negative' }
$this.Name = $name
$this.FreeGB = [math]::Round($free, 2)
$this.TotalGB = [math]::Round($total, 2)
$percent = $this.GetPercentFree()
$this.Health = if ($this.TotalGB -le 0) { [DiskHealth]::Unknown }
elseif ($percent -lt 15) { [DiskHealth]::Low }
else { [DiskHealth]::OK }
}
[double] GetPercentFree() {
if ($this.TotalGB -le 0) { return 0 }
return [math]::Round(($this.FreeGB / $this.TotalGB) * 100, 1)
}
static [DiskInfo] FromCim([object]$cim) {
$name = $cim.DeviceID.TrimEnd(':')
$free = [math]::Round(($cim.FreeSpace/1GB), 2)
$total = [math]::Round(($cim.Size/1GB), 2)
return [DiskInfo]::new($name, $free, $total)
}
[pscustomobject] ToObject() {
[pscustomobject]@{
Name = $this.Name
FreeGB = $this.FreeGB
TotalGB = $this.TotalGB
PercentFree = $this.GetPercentFree()
Health = $this.Health.ToString()
}
}
}
Practical tips:
- Keep constructors small and focused; push data-fetching into static factory methods like
FromCim(). - If you need more complex validation, consider helper functions or a dedicated
Validate()method invoked by the constructor. - Use enums when a value has a finite, named set of states; it improves readability and reduces magic strings.
Unit test the model with Pester
Strong types encourage strong tests. Because your logic lives in methods, Pester can validate behavior without calling CIM or touching the file system.
# DiskInfo.Tests.ps1
Describe 'DiskInfo' {
It 'calculates percent free' {
$d = [DiskInfo]::new('C', 10, 100)
$d.GetPercentFree() | Should -Be 10.0
}
It 'rounds values at construction time' {
$d = [DiskInfo]::new('D', 10.234, 20.987)
$d.FreeGB | Should -Be 10.23
$d.TotalGB | Should -Be 20.99
}
It 'rejects invalid names' {
{ [DiskInfo]::new('', 1, 10) } | Should -Throw
{ [DiskInfo]::new($null, 1, 10) } | Should -Throw
}
It 'handles zero total capacity safely' {
$d = [DiskInfo]::new('Z', 0, 0)
$d.GetPercentFree() | Should -Be 0
}
}
Run tests locally with Invoke-Pester, then wire them into CI so refactors stay safe:
# GitHub Actions example (powershell steps only)
- name: Run Pester
shell: pwsh
run: |
Install-Module Pester -Force -Scope CurrentUser
Invoke-Pester -Output Detailed
Packaging and reuse
For reuse, put your class in a module. Classes compile when the module is loaded, so keep the file small and self-contained:
# Module layout
MyDisks\
MyDisks.psd1
MyDisks.psm1 # contains: class DiskInfo, enum DiskHealth, exported functions
Export convenience functions that wrap your model while keeping the class as the source of truth:
function Get-DiskInfoObject {
[CmdletBinding()] param()
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object { [DiskInfo]::FromCim($_).ToObject() }
}
Export-ModuleMember -Function Get-DiskInfoObject
Performance and security tips
- Materialize once: Convert raw CIM data to your model a single time; avoid repeatedly recomputing derived values.
- Prefer typed properties:
[double]beats variant math in inner loops. - Validate all inputs: Constructor checks prevent injection of invalid or hostile data.
- Log at the edges: If you need telemetry, log inputs and exceptions where data crosses boundaries, not in tight loops.
By combining typed models, constructor validation, and pipeline-friendly output, you get predictable models, cleaner outputs, easier reviews, and safer refactors. This approach scales from tiny scripts to full automation projects and helps your future self understand exactly what your code guarantees.
Want to keep sharpening your PowerShell design skills? Build stronger data models, then explore patterns, testing, and advanced techniques in resources like the PowerShell Advanced Cookbook.