This post assumes PowerShell 5.1 or 7+, familiarity with modules and advanced functions, and a basic understanding of why the class keyword has tradeoffs. It's a reference you keep open while you build.

The largest open-source PowerShell modules PSReadLine, the Az.* family, dbatools, AWS Tools all avoid script-defined classes. They use PSCustomObject plus the Extended Type System (ETS). This isn't a workaround. It's the path the engine was designed around.

This post is the pattern, end to end.

Why Avoid PowerShell Classes

  • Module export semantics are fragile. A class defined in a .psm1 is visible inside the module but not cleanly resolvable by external callers after Import-Module. Dot-sourcing works in the current scope but breaks when paths differ across hosts.
  • No real interface implementation. You cannot implement IComparable, IDisposable, or other .NET contracts reliably from a script class.
  • Parsed at load time. Conditional class definitions "use this shape on Windows, that one on Linux" are impossible because the parser sees both branches before any condition runs.
  • Reload breaks silently. Editing a class and re-importing the module gives you a new class with the same name; existing instances still reference the old one.
  • IntelliSense is inconsistent. VS Code catches some class completion, ISE catches less, ConsoleHost almost none.

The alternative: stamp a type name onto a property bag via PSTypeName, layer behavior with the ETS, and validate through factory functions. The engine treats the result as a first-class type for dispatch, formatting, and binding.

The PSCustomObject + PSTypeName Pattern

[PSCustomObject]@{} creates a PSObject wrapping a property bag. Two things make it powerful:

  1. The PSTypeName key is special. The engine pushes its value onto PSTypeNames the ordered list used for ETS lookups, format dispatch, and parameter binding.
  2. ETS registrations bind by name. Update-TypeData -TypeName 'MyModule.Server' and a Format.ps1xml selecting <TypeName>MyModule.Server</TypeName> both attach to that string. No class involved.
$server = [PSCustomObject]@{
    PSTypeName = 'MyModule.Server'
    Name       = 'web-01'
    Ip         = '10.0.0.42'
    Os         = 'Ubuntu 24.04'
    Status     = 'active'
    CpuCount   = 8
    MemoryGB   = 32
    Tags       = @('production', 'web')
}

$server.PSTypeNames
# MyModule.Server
# System.Management.Automation.PSCustomObject
# System.Object

Declaring OutputType

Functions that produce stamped objects should advertise the type:

function Get-Server
{
    [CmdletBinding()]
    [OutputType('MyModule.Server')]
    param()
    # ...
}

[OutputType()] accepts string type names. PSScriptAnalyzer enforces it, and VS Code surfaces the type in hover tooltips.

Type Checking Without is

Because the type is a string in PSTypeNames, checking is a membership test:

if ($InputObject.PSTypeNames -notcontains 'MyModule.Server')
{
    throw "Expected MyModule.Server; got [$($InputObject.PSTypeNames[0])]"
}

Factory Functions as Constructors

Instead of [MyType]::new(), callers use New-Server. The factory validates inputs, sets defaults, and returns a stamped object. Validation attributes on the parameters replace property-setter logic a class would normally provide.

function New-Server
{
    [CmdletBinding()]
    [OutputType('MyModule.Server')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(Mandatory)]
        [ValidatePattern('^([0-9]{1,3}\.){3}[0-9]{1,3}$')]
        [string]$Ip,

        [Parameter()]
        [string]$Os = '',

        [Parameter(Mandatory)]
        [ValidateSet('active', 'maintenance', 'decommissioned')]
        [string]$Status,

        [Parameter()]
        [ValidateRange(1, 1024)]
        [int]$CpuCount = 1,

        [Parameter()]
        [ValidateRange(1, 4096)]
        [int]$MemoryGB = 4,

        [Parameter()]
        [string[]]$Tags = @()
    )

    Write-Verbose "New-Server Name=$Name Ip=$Ip Status=$Status"

    [PSCustomObject]@{
        PSTypeName = 'MyModule.Server'
        Name       = $Name
        Ip         = $Ip
        Os         = $Os
        Status     = $Status
        CpuCount   = $CpuCount
        MemoryGB   = $MemoryGB
        Tags       = $Tags
    }
}

A second type for variety an application deployment record:

function New-Deployment
{
    [CmdletBinding()]
    [OutputType('MyModule.Deployment')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$AppName,

        [Parameter(Mandatory)]
        [ValidatePattern('^\d+\.\d+\.\d+$')]
        [string]$Version,

        [Parameter(Mandatory)]
        [ValidateSet('dev', 'staging', 'production')]
        [string]$Environment,

        [Parameter()]
        [datetime]$DeployedAt = [datetime]::UtcNow,

        [Parameter()]
        [string]$DeployedBy = $env:USERNAME,

        [Parameter()]
        [ValidateSet('success', 'failed', 'rolling-back')]
        [string]$Status = 'success'
    )

    Write-Verbose "New-Deployment $AppName $Version -> $Environment"

    [PSCustomObject]@{
        PSTypeName = 'MyModule.Deployment'
        AppName    = $AppName
        Version    = $Version
        Environment = $Environment
        DeployedAt = $DeployedAt
        DeployedBy = $DeployedBy
        Status     = $Status
    }
}

Treat the factory as the only legal constructor. Never expose raw [PSCustomObject]@{ PSTypeName = ... } literals in your module's public surface. Validation lives in one place; downstream consumers cannot route around it.

Extending Types with Update-TypeData

Update-TypeData registers ScriptProperties, ScriptMethods, AliasProperties, and a DefaultDisplayPropertySet against any type name including your custom PSTypeName strings.

Computed properties

$isProd = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptProperty'
    MemberName = 'IsProduction'
    Value      = { $this.Tags -contains 'production' }
    Force      = $true
}
Update-TypeData @isProd

$subnet = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptProperty'
    MemberName = 'Subnet'
    Value      = {
        if ($this.Ip -match '^(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}$')
        {
            $matches[1] + '.0/24'
        }
        else { '' }
    }
    Force      = $true
}
Update-TypeData @subnet

$ageHours = @{
    TypeName   = 'MyModule.Deployment'
    MemberType = 'ScriptProperty'
    MemberName = 'AgeHours'
    Value      = { [Math]::Round(([datetime]::UtcNow - $this.DeployedAt).TotalHours, 1) }
    Force      = $true
}
Update-TypeData @ageHours

$this inside a ScriptProperty block is the object the property is being read from. The block runs on every access keep it cheap.

ScriptMethods

$toJson = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptMethod'
    MemberName = 'ToJson'
    Value      = {
        $this | Select-Object Name, Ip, Os, Status, CpuCount, MemoryGB, Tags |
            ConvertTo-Json -Compress
    }
    Force      = $true
}
Update-TypeData @toJson

$addTag = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptMethod'
    MemberName = 'AddTag'
    Value      = {
        param([string]$Tag)
        if ($this.Tags -notcontains $Tag)
        {
            $this.Tags = @($this.Tags) + $Tag
        }
        return $this
    }
    Force      = $true
}
Update-TypeData @addTag

AddTag returns $this for fluent chaining: $srv.AddTag('web').AddTag('critical').ToJson().

DefaultDisplayPropertySet

By default every NoteProperty prints. For a seven-field object that's noisy. The fix:

$srvDisplay = @{
    TypeName                  = 'MyModule.Server'
    DefaultDisplayPropertySet = 'Name', 'Ip', 'Status', 'CpuCount', 'MemoryGB'
    Force                     = $true
}
Update-TypeData @srvDisplay

$depDisplay = @{
    TypeName                  = 'MyModule.Deployment'
    DefaultDisplayPropertySet = 'AppName', 'Version', 'Environment', 'Status', 'AgeHours'
    Force                     = $true
}
Update-TypeData @depDisplay

Now $server | Format-Table shows five columns. $server | Format-List * still shows everything when you need it.

Where to call Update-TypeData

In the .psm1, at module load time. Guard with -Force to handle reimports cleanly:

# MyModule.psm1 - register ETS members at load time
# -Force replaces any existing registration with the same MemberName

$isProd = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptProperty'
    MemberName = 'IsProduction'
    Value      = { $this.Tags -contains 'production' }
    Force      = $true
}
Update-TypeData @isProd

# ... remaining registrations ...

-Force replaces one member, not all. Registering IsProduction with -Force overwrites that member only; other members on the same type survive. Use Remove-TypeData -TypeName ... for a clean slate.

Controlling Display with Format.ps1xml

DefaultDisplayPropertySet picks the right fields. Format.ps1xml controls how they render column widths, alignment, date formatting, conditional ANSI color.

Format.ps1xml for both types

<?xml version="1.0" encoding="utf-8" ?>
<Configuration>
  <ViewDefinitions>

    <View>
      <Name>MyModule.Server</Name>
      <ViewSelectedBy>
        <TypeName>MyModule.Server</TypeName>
      </ViewSelectedBy>
      <TableControl>
        <TableHeaders>
          <TableColumnHeader><Label>Name</Label><Width>18</Width></TableColumnHeader>
          <TableColumnHeader><Label>IP</Label><Width>15</Width></TableColumnHeader>
          <TableColumnHeader><Label>OS</Label><Width>18</Width></TableColumnHeader>
          <TableColumnHeader><Label>Status</Label><Width>16</Width></TableColumnHeader>
          <TableColumnHeader><Label>CPU</Label><Width>5</Width><Alignment>Right</Alignment></TableColumnHeader>
          <TableColumnHeader><Label>RAM</Label><Width>5</Width><Alignment>Right</Alignment></TableColumnHeader>
        </TableHeaders>
        <TableRowEntries>
          <TableRowEntry>
            <TableColumnItems>
              <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
              <TableColumnItem><PropertyName>Ip</PropertyName></TableColumnItem>
              <TableColumnItem><PropertyName>Os</PropertyName></TableColumnItem>
              <TableColumnItem>
                <ScriptBlock>
                  switch ($_.Status)
                  {
                      'active'           { "$([char]27)[32m$($_.Status)$([char]27)[0m" }
                      'maintenance'      { "$([char]27)[33m$($_.Status)$([char]27)[0m" }
                      'decommissioned'   { "$([char]27)[31m$($_.Status)$([char]27)[0m" }
                      default            { $_.Status }
                  }
                </ScriptBlock>
              </TableColumnItem>
              <TableColumnItem><PropertyName>CpuCount</PropertyName></TableColumnItem>
              <TableColumnItem><PropertyName>MemoryGB</PropertyName></TableColumnItem>
            </TableColumnItems>
          </TableRowEntry>
        </TableRowEntries>
      </TableControl>
    </View>

    <View>
      <Name>MyModule.Deployment</Name>
      <ViewSelectedBy>
        <TypeName>MyModule.Deployment</TypeName>
      </ViewSelectedBy>
      <TableControl>
        <TableHeaders>
          <TableColumnHeader><Label>App</Label><Width>20</Width></TableColumnHeader>
          <TableColumnHeader><Label>Version</Label><Width>10</Width></TableColumnHeader>
          <TableColumnHeader><Label>Env</Label><Width>12</Width></TableColumnHeader>
          <TableColumnHeader><Label>Status</Label><Width>14</Width></TableColumnHeader>
          <TableColumnHeader><Label>Age (h)</Label><Width>8</Width><Alignment>Right</Alignment></TableColumnHeader>
          <TableColumnHeader><Label>By</Label><Width>14</Width></TableColumnHeader>
        </TableHeaders>
        <TableRowEntries>
          <TableRowEntry>
            <TableColumnItems>
              <TableColumnItem><PropertyName>AppName</PropertyName></TableColumnItem>
              <TableColumnItem><PropertyName>Version</PropertyName></TableColumnItem>
              <TableColumnItem><PropertyName>Environment</PropertyName></TableColumnItem>
              <TableColumnItem>
                <ScriptBlock>
                  switch ($_.Status)
                  {
                      'success'      { "$([char]27)[32m$($_.Status)$([char]27)[0m" }
                      'failed'       { "$([char]27)[31m$($_.Status)$([char]27)[0m" }
                      'rolling-back' { "$([char]27)[33m$($_.Status)$([char]27)[0m" }
                      default        { $_.Status }
                  }
                </ScriptBlock>
              </TableColumnItem>
              <TableColumnItem><PropertyName>AgeHours</PropertyName></TableColumnItem>
              <TableColumnItem><PropertyName>DeployedBy</PropertyName></TableColumnItem>
            </TableColumnItems>
          </TableRowEntry>
        </TableRowEntries>
      </TableControl>
    </View>

  </ViewDefinitions>
</Configuration>

Register it in the module manifest:

@{
    FormatsToProcess = @('Formats.ps1xml')
}

The ANSI escape sequences ([char]27 + [32m/[33m/[31m/[0m) render correctly in Windows Terminal, VS Code, and most modern Linux/macOS terminals.

Format script blocks see $_, not $this. The format engine binds the input object to $_. ETS ScriptProperty/ScriptMethod blocks bind to $this. Mix them up and you get silent $null cells.

Serialization Gotchas

Jobs and remoting lose PSTypeName

When a stamped object crosses a runspace boundary (jobs, Invoke-Command, Start-ThreadJob), PSTypeNames gains a Deserialized. prefix. Your ETS members and format views stop firing.

$srv = New-Server -Name 'web-01' -Ip '10.0.0.42' -Status active
$back = Start-Job { $using:srv } | Receive-Job -Wait
$back.PSTypeNames[0]
# Deserialized.MyModule.Server

Restore with a small helper:

function Restore-TypeName
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [psobject]$InputObject
    )
    process
    {
        $prefix = $InputObject.PSTypeNames |
            Where-Object { $_ -like 'Deserialized.MyModule.*' } |
            Select-Object -First 1

        if ($prefix)
        {
            $clean = $prefix.Substring('Deserialized.'.Length)
            $InputObject.PSTypeNames.Insert(0, $clean)
        }
        $InputObject
    }
}

Receive-Job | Restore-TypeName brings the typed objects back.

CliXml survives; JSON does not

Export-Clixml / Import-Clixml preserve PSTypeName cleanly round-trip without a helper. Use them for local persistence.

ConvertTo-Json / ConvertFrom-Json lose all type information. Re-hydrate by piping through the factory:

function ConvertFrom-ServerJson
{
    [CmdletBinding()]
    [OutputType('MyModule.Server')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Json
    )
    process
    {
        $raw = $Json | ConvertFrom-Json
        foreach ($item in @($raw))
        {
            $srvParams = @{
                Name     = $item.Name
                Ip       = $item.Ip
                Os       = ($item.Os ?? '')
                Status   = $item.Status
                CpuCount = ($item.CpuCount ?? 1)
                MemoryGB = ($item.MemoryGB ?? 4)
                Tags     = @($item.Tags)
            }
            New-Server @srvParams
        }
    }
}

Module Structure

MyModule/
├── MyModule.psd1
├── MyModule.psm1
├── Formats.ps1xml
├── Public/
│   ├── New-Server.ps1
│   ├── New-Deployment.ps1
│   ├── Get-Server.ps1
│   ├── ConvertFrom-ServerJson.ps1
│   └── Restore-TypeName.ps1
└── Private/
    └── Register-TypeExtensions.ps1

MyModule.psd1

@{
    RootModule        = 'MyModule.psm1'
    ModuleVersion     = '1.0.0'
    GUID              = 'a4f6c2e8-1b3d-4e7a-9c8b-2f5a8d7e1c4b'
    Author            = 'Ricardo Martin'
    Description       = 'Typed helpers using PSTypeName + ETS.'
    PowerShellVersion = '5.1'

    FormatsToProcess  = @('Formats.ps1xml')

    FunctionsToExport = @(
        'New-Server',
        'New-Deployment',
        'Get-Server',
        'ConvertFrom-ServerJson',
        'Restore-TypeName'
    )
    CmdletsToExport   = @()
    AliasesToExport   = @()
    VariablesToExport = @()
}

MyModule.psm1

#Requires -Version 5.1

$privateFiles = @(Get-ChildItem -Path "$PSScriptRoot/Private" -Filter *.ps1 -ErrorAction SilentlyContinue)
$publicFiles  = @(Get-ChildItem -Path "$PSScriptRoot/Public"  -Filter *.ps1 -ErrorAction SilentlyContinue)

foreach ($file in @($privateFiles) + @($publicFiles))
{
    try
    {
        . $file.FullName
    }
    catch
    {
        Write-Error "Failed to dot-source $($file.FullName): $_"
    }
}

# Register ETS members
. "$PSScriptRoot/Private/Register-TypeExtensions.ps1"

Export-ModuleMember -Function $publicFiles.BaseName

Private/Register-TypeExtensions.ps1

All Update-TypeData calls live here one file, module-load-only:

# Server
$isProd = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptProperty'
    MemberName = 'IsProduction'
    Value      = { $this.Tags -contains 'production' }
    Force      = $true
}
Update-TypeData @isProd

$subnet = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptProperty'
    MemberName = 'Subnet'
    Value      = {
        if ($this.Ip -match '^(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}$')
        {
            $matches[1] + '.0/24'
        }
        else { '' }
    }
    Force      = $true
}
Update-TypeData @subnet

$toJson = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptMethod'
    MemberName = 'ToJson'
    Value      = {
        $this | Select-Object Name, Ip, Os, Status, CpuCount, MemoryGB, Tags |
            ConvertTo-Json -Compress
    }
    Force      = $true
}
Update-TypeData @toJson

$addTag = @{
    TypeName   = 'MyModule.Server'
    MemberType = 'ScriptMethod'
    MemberName = 'AddTag'
    Value      = {
        param([string]$Tag)
        if ($this.Tags -notcontains $Tag)
        {
            $this.Tags = @($this.Tags) + $Tag
        }
        return $this
    }
    Force      = $true
}
Update-TypeData @addTag

$srvDisplay = @{
    TypeName                  = 'MyModule.Server'
    DefaultDisplayPropertySet = 'Name', 'Ip', 'Status', 'CpuCount', 'MemoryGB'
    Force                     = $true
}
Update-TypeData @srvDisplay

# Deployment
$ageHours = @{
    TypeName   = 'MyModule.Deployment'
    MemberType = 'ScriptProperty'
    MemberName = 'AgeHours'
    Value      = {
        [Math]::Round(([datetime]::UtcNow - $this.DeployedAt).TotalHours, 1)
    }
    Force      = $true
}
Update-TypeData @ageHours

$depDisplay = @{
    TypeName                  = 'MyModule.Deployment'
    DefaultDisplayPropertySet = 'AppName', 'Version', 'Environment', 'Status', 'AgeHours'
    Force                     = $true
}
Update-TypeData @depDisplay

Conclusion

The pattern in one sentence: PSCustomObject with a stamped PSTypeName, constructed only via factory functions, decorated via Update-TypeData, and formatted via Format.ps1xml gives you a fully typed PowerShell module without the class keyword.

When you should reach for C# instead:

  • You need real interface implementation (IComparable, IDisposable).
  • You need operator overloading.
  • You need true cross-runspace identity without re-hydration.
  • Per-instance allocation performance matters at millions-per-second scale.

For everything short of that, this pattern is the right answer.