Skip to content

Function structure#

Always use CmdletBinding#

Practice: Every function must have [CmdletBinding()], [OutputType()], and a typed param() block with validation attributes. Mandatory parameters first. Use [switch] for boolean flags. Add SupportsShouldProcess only on commands that mutate state (create, update, remove, copy, install, publish). Never add it to Get-, Test-, Resolve-, ConvertTo-, ConvertFrom- commands.

Why: CmdletBinding gives -Verbose, -WhatIf, and common parameters for free — features users expect from a well-built product (Product mindset). Validation attributes reject bad input at the boundary, not deep in the call stack (Shift Left). Typed parameters are self-documenting for agents (Human–agent coexistence).

How:

# Good
function Get-UserData {
    [OutputType([PSCustomObject])]
    [CmdletBinding()]
    param(
        # The unique identifier of the user.
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $UserId,

        # Include deleted users in the results.
        [Parameter()]
        [switch] $IncludeDeleted
    )

    process { }
}
# Bad
function Get-UserData($id, $del) {
    # No CmdletBinding, no types, unclear names
}

Parameter attribute order#

Practice: Place parameter attributes in this order, each on its own line: [Parameter()], validation attributes, [ArgumentCompleter()], [Alias()], typed declaration.

Why: Consistent ordering makes parameter blocks scannable across all functions in a codebase. Reviewers know exactly where to look for each concern (4-eyes principle, Clean Code).

How:

# Good
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [ValidateSet('Active', 'Archived')]
    [ArgumentCompleter({ Get-StateCompleter })]
    [Alias('Status')]
    [string] $State
)
# Bad
param(
    [Alias('Status')]
    [string]
    [ValidateSet('Active', 'Archived')]
    [Parameter(Mandatory)]
    $State                                  # Random order, type on wrong line
)

Parameter sets#

Practice: Single mode — no named set, no DefaultParameterSetName. Multiple modes — every set has an intent-revealing name. Never use 'Default', 'ByID', or '__AllParameterSets' as set names. Set DefaultParameterSetName to the most common user intent.

Why: Intent-revealing names document the function's modes directly in the metadata (Write it down). Users can discover modes via Get-Help without reading the implementation (Product mindset).

How:

# Good — multiple parameter sets
function Get-Project {
    [CmdletBinding(DefaultParameterSetName = 'List projects')]
    param(
        [Parameter(ParameterSetName = 'List projects')]
        [string] $Filter,

        [Parameter(Mandatory, ParameterSetName = 'Get a project by name')]
        [string] $Name
    )
}
# Bad
function Get-Project {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(ParameterSetName = 'Default')]
        [string] $Filter,

        [Parameter(Mandatory, ParameterSetName = 'ByName')]
        [string] $Name
    )
}

Pipeline design#

Practice: Functions that process collections should accept pipeline input via ValueFromPipeline and implement begin/process/end blocks.

Why: Pipeline-aware functions compose with the ecosystem — streaming data without loading entire collections into memory. This follows Open/Closed: extend by composing, not modifying. Users expect commands to work naturally in a pipeline (Product mindset).

How:

# Good
function Update-Item {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSCustomObject] $Item
    )

    begin { $count = 0 }

    process {
        $Item.LastModified = Get-Date
        $count++
        Write-Output $Item
    }

    end { Write-Verbose "Processed $count items" }
}

$items | Update-Item

Script structure#

Practice: #Requires → comment-based help → param() → variables → functions → execution in try/catch.

Why: #Requires fails fast on incompatible environments (Shift Left). Consistent ordering means agents can reliably parse and generate scripts (Human–agent coexistence). A top-level try/catch prevents partial runs.

How:

# Good
#Requires -Version 7.4

[CmdletBinding()]
param(
    [string] $ConfigPath = '.\config.json'
)

$ErrorActionPreference = 'Stop'

#region Helper functions
function Get-ConfigData {
    param([string] $Path)
}
#endregion Helper functions

try {
    $config = Get-ConfigData -Path $ConfigPath
} catch {
    Write-Error "Script failed: $_"
    exit 1
}