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:

  1. Restart the host on every change. What CI does anyway. Painful for inner-loop work.
  2. 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.
  3. 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. PSCustomObject doesn'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-Json produces PSCustomObject; 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:

  1. Ad-hoc data shape, used in one script? Use PSCustomObject. Done.
  2. Same shape used across three or more scripts, with non-trivial validation? A class in a module, exported, with one constructor that enforces invariants.
  3. 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 -Force reload 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).