PowerShell Classes vs Hashtables: Typed Validation for Scripts
PowerShell classes replace error‑prone hashtables with typed, validated objects that support methods and inheritance. A migration example shows validation at assignment, static factories, and version trade‑offs.
06 Aug 2026, 11:11 UTC

The Problem with Improvised Data Shapes
Most PowerShell scripts start with hashtables or PSCustomObject for structured data because they’re quick to write. A deployment script might pass around @{ Name = 'web-01'; Environment = 'prod'; Tags = @{ Owner = 'team-a' } } and access keys by string. This works until a typo like $server.Environemnt silently returns $null instead of throwing, or a function expects Tags to be a hashtable but receives an array. The contract exists only in the author’s head.
PowerShell classes (since 5.0, significantly improved in 7+) replace that ambiguity with typed, validated, method-bearing objects — without leaving the language or compiling C#. This post shows how to migrate a real-world configuration pattern to a class, what you gain, and where the rough edges remain.
From Hashtable to Typed Contract
Consider a module that manages cloud resources. The current pattern uses a hashtable with nested structures:
$vmConfig = @{
Name = 'app-server-01'
ResourceGroup = 'rg-prod'
Size = 'Standard_D4s_v3'
Tags = @{
Environment = 'production'
Owner = 'platform-team'
CostCenter = '12345'
}
Disks = @(
@{ Name = 'os'; SizeGB = 128; Type = 'Premium_LRS' }
@{ Name = 'data'; SizeGB = 512; Type = 'Standard_LRS' }
)
}
Every function that consumes this must defensively check keys, types, and nested depth. A class makes the shape explicit and enforces it at assignment time.
Defining the Resource Class with Validation
Here’s a minimal class that models the same data with property validation attributes. Save as ResourceClasses.psm1 in a module directory.
class ResourceTag {
[ValidateNotNullOrEmpty()]
[string]$Key
[ValidateNotNullOrEmpty()]
[string]$Value
ResourceTag([string]$Key, [string]$Value) {
$this.Key = $Key
$this.Value = $Value
}
[string] ToString() { return "$($this.Key)=$($this.Value)" }
}
class DiskConfig {
[ValidateNotNullOrEmpty()]
[string]$Name
[ValidateRange(1, 4096)]
[int]$SizeGB
[ValidateSet('Standard_LRS', 'Premium_LRS', 'StandardSSD_LRS', 'UltraSSD_LRS')]
[string]$Type = 'Premium_LRS'
DiskConfig([string]$Name, [int]$SizeGB, [string]$Type = 'Premium_LRS') {
$this.Name = $Name
$this.SizeGB = $SizeGB
$this.Type = $Type
}
}
class VirtualMachine {
[ValidateNotNullOrEmpty()]
[string]$Name
[ValidateNotNullOrEmpty()]
[string]$ResourceGroup
[ValidateSet('Standard_B2s', 'Standard_D2s_v3', 'Standard_D4s_v3', 'Standard_E4s_v3')]
[string]$Size = 'Standard_D2s_v3'
[ResourceTag[]]$Tags = @()
[DiskConfig[]]$Disks = @()
hidden [DateTime]$_created = (Get-Date)
VirtualMachine([string]$Name, [string]$ResourceGroup) {
$this.Name = $Name
$this.ResourceGroup = $ResourceGroup
}
static [VirtualMachine] CreateFromJson([string]$JsonPath) {
$data = Get-Content $JsonPath -Raw | ConvertFrom-Json
$vm = [VirtualMachine]::new($data.Name, $data.ResourceGroup)
$vm.Size = $data.Size
$vm.Tags = $data.Tags | ForEach-Object { [ResourceTag]::new($_.Key, $_.Value) }
$vm.Disks = $data.Disks | ForEach-Object { [DiskConfig]::new($_.Name, $_.SizeGB, $_.Type) }
return $vm
}
[hashtable] ToHashtable() {
return @{
Name = $this.Name
ResourceGroup = $this.ResourceGroup
Size = $this.Size
Tags = $this.Tags | ForEach-Object { @{ Key = $_.Key; Value = $_.Value } }
Disks = $this.Disks | ForEach-Object { @{ Name = $_.Name; SizeGB = $_.SizeGB; Type = $_.Type } }
Created = $this._created
}
}
[string] GetSummary() {
return "VM $($this.Name) in $($this.ResourceGroup) ($($this.Size)) with $($this.Disks.Count) disk(s)"
}
}
Key points in this definition:
- Validation attributes on properties (
ValidateRange,ValidateSet,ValidateNotNullOrEmpty) run automatically on every assignment.$vm.Size = 'Invalid'throws immediately. - Hidden field
$_createdstores internal state not exposed to callers or JSON serialization. - Static factory
CreateFromJsonhandles deserialization and nested object construction — callers get a fully validated instance. - Method
GetSummary()encapsulates formatting logic that would otherwise live in scattered helper functions.
Worked Example: Consuming the Class
In a script or interactive session (PowerShell 7.2+ recommended for readonly support):
Import-Module ./ResourceClasses.psm1
# Direct construction with validation
$vm = [VirtualMachine]::new('web-01', 'rg-prod')
$vm.Size = 'Standard_D4s_v3'
$vm.Tags = [ResourceTag[]] @(
[ResourceTag]::new('Environment', 'production')
[ResourceTag]::new('Owner', 'platform-team')
)
$vm.Disks = [DiskConfig[]] @(
[DiskConfig]::new('os', 128, 'Premium_LRS')
[DiskConfig]::new('data', 512, 'Standard_LRS')
)
# This throws: ValidateSet violation
# $vm.Size = 'NonExistentSize'
# Round-trip through JSON (public properties only)
$json = $vm | ConvertTo-Json -Depth 4
$json | Set-Content vm-config.json
# Rehydrate via factory
$vm2 = [VirtualMachine]::CreateFromJson('vm-config.json')
$vm2.GetSummary() # "VM web-01 in rg-prod (Standard_D4s_v3) with 2 disk(s)"
# Hidden field not serialized, reset on new instance
$vm2._created # accessible but not in JSON
Run this in a PowerShell 7.x terminal. The validation errors surface at assignment, not three functions later. IntelliSense in VS Code with the PowerShell extension shows property names and types as you type.
Trade-offs and Limitations
- Version compatibility: Generic classes (
class Cache[T]) and interface implementation (class MyLogger : ILogger) require PowerShell 7.0+. If your module must run on Windows PowerShell 5.1, avoid those features. - No built-in immutability: All properties are mutable. PowerShell 7.2+ adds
readonlyon fields (not properties), e.g.,readonly [string]$Idset only in the constructor. For true immutable objects, use hidden setters or return new instances from “mutating” methods. - No
INotifyPropertyChanged: Classes don’t support WPF/MAUI data binding out of the box. Implement the interface manually if needed. - Debugging quirks: Older VS Code PowerShell extension versions (pre‑2023) show incomplete locals in class constructors. Update the extension or use the terminal for reliable inspection.
- Serialization depth:
ConvertTo-Jsononly serializes public instance properties. Hidden fields, static members, and methods are excluded — which is usually what you want for config files.
Quick Verification Checklist
Before adopting classes in a shared module, confirm:
Get-Command -Syntax classreturns syntax in your minimum supported PowerShell version.class Box[T] { [T]$Value }parses without error on PowerShell 7.x; expectParserErroron 5.1.Measure-Command { 1..10000 | ForEach-Object { [VirtualMachine]::new("vm$", "rg") } }completes faster than equivalentPSCustomObjectcreation withAdd-Member.- PSScriptAnalyzer 1.20+ passes without
PSUseDeclaredVarsMoreThanAssignmentsfalse positives on property assignments.
When to Make the Switch
Migrate when:
- Data shapes cross function or module boundaries.
- Validation logic is duplicated across functions.
- Consumers would benefit from IntelliSense discovery.
- You need methods that operate on the data (not just hold it).
Stick with hashtables for throwaway scripts, one‑off API payloads, or when the shape changes per invocation. Classes pay off when the contract stabilizes and the cost of a typo exceeds the cost of a class definition.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.