This guide assumes Powershell 5.1 or 7+ (classes shipped in 5.0) and that you've written a few advanced functions before.
Powershell got real classes in 5.0 and the language has been quietly ambivalent about them ever since. They're powerful, they enable DSC and IValidateSetValuesGenerator-style integrations, and they have one specific quirk that bites every team that adopts them carelessly. This post is the small honest take.
A Class, Top to Bottom
class Widget
{
# properties
[string]$Name
[int]$Index
hidden [datetime]$CreatedAt = (Get-Date)
# default constructor
Widget() { }
# parameterized constructor
Widget([string]$name, [int]$index) {
$this.Name = $name
$this.Index = $index
}
# method
[string] ToString() {
return "Widget[$($this.Name)#$($this.Index)]"
}
# static method
static [Widget] Parse([string]$raw) {
$parts = $raw.Split(':')
return [Widget]::new($parts[0], [int]$parts[1])
}
}
$w = [Widget]::new('foo', 1)
$w.ToString() # Widget[foo#1]
[Widget]::Parse('bar:42')
The syntax is C#-esque on purpose. Properties have types, methods have return types, constructors are name-of-class blocks, and $this references the instance.
Validation Attributes on Properties
The same attributes you use on parameters work on class properties:
class Server
{
[ValidateNotNullOrEmpty()]
[string]$Hostname
[ValidateRange(1, 65535)]
[int]$Port = 443
[ValidatePattern('^[a-z]+$')]
[string]$Environment
}
$s = [Server]::new()
$s.Hostname = '' # ERROR: validation fails
$s.Port = 99999 # ERROR: out of range
Validation runs on every property assignment, including from the constructor. This is one of the genuine wins over functions returning a PSCustomObject you get type and value safety for free.
Inheritance
class Animal
{
[string]$Name
[string] Speak() { return '...' }
}
class Dog : Animal
{
[string] Speak() { return 'woof' }
[string] Fetch() { return "$($this.Name) brought it back" }
}
$d = [Dog]::new()
$d.Name = 'Rex'
$d.Speak() # 'woof'
$d.Fetch()
Single inheritance only. No interfaces in 5.1; PS 7 added basic interface support but it's still narrow. If you need rich inheritance hierarchies, you're probably reaching for the wrong tool write the types in C# and ship them as a binary module.
Static Members
class Logger
{
static [string]$Prefix = 'app'
static [void] Log([string]$msg) {
Write-Host "[$([Logger]::Prefix)] $msg"
}
}
[Logger]::Prefix = 'myop'
[Logger]::Log('hello') # [myop] hello
Useful for module-wide configuration and factory methods. Don't go overboard a class full of statics is a namespace pretending to be a class.
Enums While We're Here
Enums shipped with classes:
enum Severity
{
Low = 1
Medium = 5
High = 10
}
[Severity]::High
[Severity]'High'
[int][Severity]::High # 10
Pair them with [ValidateSet([type])] and you get tab completion for free in functions that take them.
The Reload Problem
The single thing that bites every team that adopts classes:
Powershell classes can't be redefined in the same session.
Define a class, edit it, re-source the file you'll get an error or worse, silently keep using the old version. The class is bound to the assembly that was generated when the file first loaded; subsequent loads add a new version that nothing references.
Three workarounds:
- Restart the host on every change. What CI does anyway. Painful for inner-loop work.
- Wrap the class in a function that returns instances. The function can be re-sourced; the class definition can't change underneath, but the surface area you call usually can.
- Import-Module -Force on a
.psm1. A module with a class in it can be re-imported with-Force, which gives you a new module-private class but only because the module gets a new scope. Top-level scripts don't get this.
# In MyOps.psm1
class Widget { ... }
function New-Widget { ... }
Export-ModuleMember -Function New-Widget
# Caller
Import-Module ./MyOps.psm1 -Force # works - new Widget class each time
This is why most production Powershell that uses classes packages them inside a module.
When a Class Earns Its Slot
The honest list:
- You need property validation enforced on every assignment. A class is the cleanest way.
- You need to implement a Powershell-recognized interface
IValidateSetValuesGenerator,IArgumentCompleter, DSC resource. These require a class. - You need methods on the object that callers will discover via tab completion.
[Widget]::new('foo').<TAB>actually surfaces methods. - You need a stable type identity for
[Widget]to mean something across pipelines.
When a PSCustomObject Plus Functions Is Better
The list nobody admits:
- Quick data-shape.
[pscustomobject]@{ Name=$n; Id=$i }is one line; a class declaration is six. - Heavy reload during development.
PSCustomObjectdoesn't have the bind-once problem. - Anything that flows down a pipeline with no behavior attached. Pure data. Reach for the object literal.
- Anything you want to splat. You can't splat a class instance.
- Deep JSON round-trip.
ConvertFrom-JsonproducesPSCustomObject; round-tripping through a class adds friction.
Default to
PSCustomObject. Promote to a class only when you need one of the four "earns-its-slot" reasons. Most "this should be a class" instincts come from another language. Powershell isn't that language.
DSC Where Classes Are Not Optional
DSC resources written in Powershell must be classes (since DSC v2):
[DscResource()]
class FileLine
{
[DscProperty(Key)]
[string]$Path
[DscProperty(Mandatory)]
[string]$Line
[void] Set() { Add-Content -Path $this.Path -Value $this.Line }
[bool] Test() { return (Get-Content $this.Path -ErrorAction SilentlyContinue) -contains $this.Line }
[FileLine] Get() {
$this.Line = (Get-Content $this.Path -ErrorAction SilentlyContinue) -join "`n"
return $this
}
}
If you're writing DSC, embrace classes it's the only path. If you're not writing DSC, the class-vs-object choice is open.
Performance Notes
Class instantiation is significantly faster than [pscustomobject]@{...} for hot loops:
| Approach | 100k allocations |
|---|---|
[pscustomobject]@{ a = $i; b = "x" } |
~700 ms |
[MyClass]::new($i, "x") |
~120 ms |
The win comes from skipping the PSObject adapter wrapping. If you're allocating lots of small objects in a tight loop, classes are 4–6× faster. Outside hot loops the difference doesn't matter.
A Pragmatic Decision Table
| Situation | Use |
|---|---|
| Quick data shape, one-shot | PSCustomObject |
| Output flowing down the pipeline | PSCustomObject |
| Need property validation on every set | Class |
| Need methods discoverable by tab completion | Class |
| Implementing a Powershell-recognized interface | Class (required) |
| DSC resource | Class (required) |
| Hot-allocation loop in a long-running module | Class |
| You want hot-edit-and-reload during development | PSCustomObject or move the type to a C# binary module |
What to Do Next
PowerShell classes are real and useful, but they aren't the natural shape of most PowerShell code. Default to PSCustomObject and reach for a class when you need validation, interfaces, DSC, or hot-loop allocation speed. Wrap classes in modules when you adopt them so -Force can rebind.
The decision rule for tomorrow's code:
- Ad-hoc data shape, used in one script? Use
PSCustomObject. Done. - Same shape used across three or more scripts, with non-trivial validation? A class in a module, exported, with one constructor that enforces invariants.
- Inheritance hierarchies, abstract bases, type-check casts? Write a compiled C# module instead. PowerShell classes are not the right tool for that complexity, and the language tells you so the moment
-Forcereload starts behaving strangely.
Pairs naturally with the hashtables vs PSCustomObject post (the same boundary decision applied to data containers rather than data types) and with custom types without classes (the often-better lighter-weight pattern using PSTypeName and Update-TypeData).


