Patterns#

A handful of patterns make PowerShell scripts safer and easier to live with. The defaults are looser than zsh / bash strict mode in a few places worth tightening; the advanced function template is the unit of re-use most operators converge on.

Strict Mode#

PowerShell’s default mode is lenient. Set-StrictMode promotes common silent bugs (typo’d variable names, missing properties, out-of-bounds array access) into errors.

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
  • Set-StrictMode -Version Latest, every check the current PowerShell knows about. Add at the top of every script and module file.

  • $ErrorActionPreference = 'Stop', turn non-terminating errors into terminating ones so try / catch actually catches them.

For pipeline-level control over a single call.

Get-Item missing.txt -ErrorAction Stop
Get-Item missing.txt -ErrorAction SilentlyContinue

Error Handling#

PowerShell exceptions are real .NET Exception objects. try / catch / finally works as expected; catch can target a specific exception type.

try {
    Get-Content config.toml -ErrorAction Stop
}
catch [System.IO.FileNotFoundException] {
    Write-Warning "no config; using defaults"
    $defaults
}
catch {
    Write-Error "unexpected: $_"
    throw                                    # rethrow
}
finally {
    'cleanup runs either way'
}

$_ (or $PSItem) inside catch is the ErrorRecord; $_.Exception is the underlying exception.

Cleanup#

The finally block runs whether the body succeeded or threw, similar to trap in bash. PowerShell also has its own trap keyword for shell-style “run this on any error” handling.

$tmp = New-TemporaryFile
try {
    # work that uses $tmp
}
finally {
    Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}

The same pattern with trap (closer to bash style).

trap { Write-Warning "trapped: $_"; continue }
# rest of the script

Advanced Function Template#

The unit of re-use. [CmdletBinding()] plus typed parameters plus [Parameter()] and [Validate*()] attributes plus begin / process / end blocks. The result behaves like a real cmdlet: parameter binding, -Verbose, -WhatIf, -Confirm, pipeline streaming, and tab-completion all come for free.

function Send-Backup {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory, ValueFromPipeline,
                   ValueFromPipelineByPropertyName)]
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [Alias('Path')]
        [string]$Source,

        [Parameter(Mandatory)]
        [ValidatePattern('^https://')]
        [string]$Endpoint,

        [ValidateRange(1, 10)]
        [int]$RetryCount = 3,

        [switch]$Force
    )

    begin {
        Set-StrictMode -Version Latest
        Write-Verbose "starting; endpoint = $Endpoint"
    }
    process {
        foreach ($s in $Source) {
            if ($PSCmdlet.ShouldProcess($s, "back up to $Endpoint")) {
                # do the work
            }
        }
    }
    end {
        Write-Verbose "done"
    }
}

Parameter Validation#

Push validation into attributes so the bind fails before the function body runs. Less code, better error messages, free Get-Help.

Attribute

Effect

[ValidateNotNullOrEmpty()]

Reject $null and empty

[ValidateLength(min, max)]

String length bounds

[ValidateRange(min, max)]

Numeric range

[ValidatePattern('regex')]

String must match regex

[ValidateScript({ ... })]

Custom predicate

[ValidateSet('a','b','c')]

Enum-like list of accepted values

Logging Through Streams#

Use the right stream for the right audience. The operator running the script sees Success and Error by default; -Verbose turns on Verbose; -Debug turns on Debug.

  • Write-Output, the answer. Pipeline traffic.

  • Write-Verbose, detail; visible only with -Verbose.

  • Write-Warning, caution; always visible, does not stop.

  • Write-Error, non-terminating error; $Error[0] retains it.

  • Write-Information, structured info (7+); paired with -InformationAction.

  • Write-Host, terminal-only; bypasses every stream. Avoid in scripts; the output is unreachable from a 2>&1 capture.

function Get-Thing {
    [CmdletBinding()]
    param([string]$Name)
    Write-Verbose "looking up $Name"
    if (-not $Name) {
        Write-Warning "no name provided; defaulting to 'self'"
        $Name = 'self'
    }
    Write-Output (Resolve-Thing $Name)
}

Quoting#

Single quotes are literal; double quotes expand. Quote the literal, not the variable. Inside double quotes, $() evaluates a subexpression; ${var} disambiguates a variable name.

'literal $HOME'                            # literal
"expanded $HOME"                           # expanded
"now is $(Get-Date)"                       # subexpression
"${env:PATH}"                              # disambiguate env var

For paths and arguments that may contain spaces, quote the whole expression.

Remove-Item "$env:TEMP\my folder"

Avoid Aliases in Scripts#

Aliases are great at the prompt; in committed scripts they slow down readers and obscure intent. PSScriptAnalyzer flags this as PSAvoidUsingCmdletAliases.

# at the prompt
ls | ? Length -gt 1KB | sort Length -desc

# in scripts
Get-ChildItem |
    Where-Object Length -gt 1KB |
    Sort-Object Length -Descending

Avoid Write-Host#

Write-Host writes straight to the terminal; the output never hits the pipeline or any redirection target. Use Write-Information or Write-Output (depending on whether the message is “for the operator” or “for the next stage”).

References#

  • Get-Help about_CommonParameters, Get-Help about_Functions_Advanced_Parameters, Get-Help about_Try_Catch_Finally, Get-Help about_Preference_Variables.

  • Overview for the language-level error-handling surface (trap, try / catch).

  • I/O and Pipelines for the pipeline and stream semantics these patterns build on.

  • Tools for PSScriptAnalyzer and the rest of the toolchain.

  • PSScriptAnalyzer rules.