Functions
Syntax
Basic Function Structure
PowerShell
function-basics.ps1
function Get-SystemSummary { # Functions are named code blocks. Convention: Verb-Noun naming. $os = Get-CimInstance Win32_OperatingSystem $cpu = Get-CimInstance Win32_Processor # Return a structured object — more reusable than plain Write-Host [PSCustomObject]@{ ComputerName = $env:COMPUTERNAME OS = $os.Caption TotalRAM_GB = [math]::Round($os.TotalVisibleMemorySize/1MB, 1) CPU = $cpu.Name Cores = $cpu.NumberOfCores } } # Call the function — result flows into the pipeline Get-SystemSummary | Format-List
Why return objects? When your function emits a
PSCustomObject instead of
printing text, callers can pipe the result to Where-Object, Export-Csv,
ConvertTo-Json, etc. The function becomes a composable building block.
Pro Pattern
Parameters, Types & Validation
PowerShell
function-params.ps1
function Test-ServerHealth { [CmdletBinding()] # Enables -Verbose, -ErrorAction, -WhatIf support param( # [Parameter()] controls how the parameter behaves [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateNotNullOrEmpty()] # Rejects empty strings [string[]]$ComputerName, # Array type → multiple servers [Parameter()] [ValidateRange(1, 65535)] # Enforces valid port range [int]$Port = 80, # Default value [Parameter()] [ValidateSet("HTTP", "HTTPS", "RDP")] # Enum-style restriction + tab completion [string]$Protocol = "HTTP", [switch]$Detailed # Boolean flag (presence = $true) ) begin { $results = [System.Collections.Generic.List[object]]::new() } process { # 'process' block runs once per piped object foreach ($computer in $ComputerName) { $result = [PSCustomObject]@{ ComputerName = $computer Port = $Port Protocol = $Protocol Online = Test-Connection $computer -Count 1 -Quiet } $results.Add($result) } } end { $results # Emit collected results at end } } # Usage examples: Test-ServerHealth -ComputerName "web01", "db01" -Protocol "HTTPS" "web01", "app01" | Test-ServerHealth # ValueFromPipeline accepts piped input
begin / process / end blocks control pipeline behavior. begin runs once before input,
process runs once per piped object, end runs once after all input.
Without these blocks, the function body acts as an implicit
end block.
ValidateSet Gotcha: Validation runs client-side at parse time. It provides tab completion in ISE/VSCode but
does not prevent a user from calling the function via
Invoke-Expression or from another script
with an invalid value at runtime — always pair with explicit runtime checks in security-sensitive contexts.
🧠 Check Your Understanding
What does the
[CmdletBinding()] attribute enable?
Control Flow
Syntax
If / ElseIf / Else
PowerShell
$freeDiskGB = (Get-PSDrive C).Free / 1GB if ($freeDiskGB -lt 5) { Write-Warning "CRITICAL: C: drive has less than 5GB free!" } elseif ($freeDiskGB -lt 20) { Write-Host "WARNING: Low disk space on C:" -ForegroundColor Yellow } else { Write-Host "OK: Disk has $([math]::Round($freeDiskGB,1))GB free" -ForegroundColor Green } # Ternary-style (PS 7+): $status = $freeDiskGB -gt 20 ? "OK" : "LOW" # Comparison operators (all prefix with dash): # -eq -ne -gt -lt -ge -le (numeric / general) # -like -notlike (wildcards: * and ?) # -match -notmatch (regex) # -contains -notcontains (array membership: $arr -contains $val) # -in -notin (reverse: $val -in $arr)
Pro Pattern
Switch Statement (more powerful than most languages)
PowerShell
$errorCode = 404 switch ($errorCode) { 200 { Write-Host "OK"; break } 401 { Write-Host "Unauthorized"; break } 403 { Write-Host "Forbidden"; break } 404 { Write-Host "Not Found"; break } { $_ -ge 500 } { Write-Host "Server Error: $_"; break } Default { Write-Host "Unknown code: $errorCode" } } # Switch with -Wildcard (pattern matching): $filename = "report_2025.csv" switch -Wildcard ($filename) { "*.csv" { Write-Host "Comma-separated" } "*.json" { Write-Host "JSON format" } "*.xml" { Write-Host "XML format" } } # Switch with -Regex: switch -Regex ($filename) { "\d{4}" { Write-Host "Contains a 4-digit year: $($matches[0])" } }
PowerShell's switch is multi-match by default. Without
break, all matching
cases execute — useful for applying multiple transformations but dangerous if you expect single-match behavior.
Script blocks { $_ -ge 500 } as case values make switch a very powerful dispatcher.
Syntax
Loops: For, While, ForEach, ForEach-Object
PowerShell
# ── foreach statement (fastest for in-memory collections) ────── $servers = @("web01", "db01", "app01") foreach ($server in $servers) { Test-Connection $server -Count 1 -Quiet } # ── ForEach-Object cmdlet (streams through pipeline, lower memory) ─ Get-Process | ForEach-Object { # $_ is the current pipeline object [PSCustomObject]@{ Name = $_.Name; PID = $_.Id } } # PS 7+ shorthand using $_ member access: Get-Process | ForEach-Object -MemberName Name # ── for loop (classic index-based) ───────────────────────────── for ($i = 0; $i -lt 10; $i++) { Write-Host "Attempt $($i + 1) of 10" } # ── while loop (condition-driven) ────────────────────────────── $attempts = 0 while ($attempts -lt 5) { $result = Test-Connection "server" -Count 1 -Quiet if ($result) { break } # Exit loop early on success $attempts++ Start-Sleep -Seconds 5 } # ── do-while (executes at least once) ────────────────────────── do { $input = Read-Host "Enter server name (or 'done')" Write-Host "Processing: $input" } while ($input -ne "done")
foreach statement vs ForEach-Object cmdlet: The
foreach statement loads the
entire collection into memory first — fast, but not pipeline-friendly. ForEach-Object processes
one object at a time in the pipeline — uses less peak memory for large datasets but has per-item overhead.
Rule of thumb: use foreach for in-memory arrays, ForEach-Object for pipeline streams.
🧠 Check Your Understanding
What does this script output?
$arr = @(1, 2, 3, 4, 5) $arr | Where-Object { $_ -gt 3 } | ForEach-Object { $_ * 2 }
Error Handling
Pattern
Try / Catch / Finally
PowerShell
function Set-RegistryValue { [CmdletBinding()] param([string]$Path, [string]$Name, [object]$Value) try { # -ErrorAction Stop forces non-terminating errors to become terminating # (catchable). Without this, many cmdlet errors bypass try/catch! Set-ItemProperty -Path $Path -Name $Name -Value $Value -ErrorAction Stop Write-Verbose "Successfully set $Name in $Path" } catch [System.Security.SecurityException] { # Catch specific exception types first (most specific to most general) Write-Error "Access denied to registry path '$Path'. Run as Administrator." } catch [System.Management.Automation.ItemNotFoundException] { Write-Warning "Registry path '$Path' not found. Creating..." New-Item -Path $Path -Force | Out-Null Set-ItemProperty -Path $Path -Name $Name -Value $Value } catch { # Generic catch — $_ contains the ErrorRecord Write-Error "Unexpected error: $($_.Exception.Message)" Write-Debug "Stack: $($_.ScriptStackTrace)" } finally { # Runs regardless of success or failure — use for cleanup Write-Verbose "Operation complete." } } # $_ inside catch exposes the full ErrorRecord: # $_.Exception → the .NET exception object # $_.Exception.Message → human-readable message # $_.InvocationInfo → where in the script it failed # $_.ScriptStackTrace → full call stack
Critical Gotcha: -ErrorAction Stop is required for try/catch to work.
By default, most PowerShell cmdlets produce "non-terminating" errors that do not trigger a
catch block — they just print a red error and continue. You must add
-ErrorAction Stop
or set $ErrorActionPreference = 'Stop' at the top of your script to make errors catchable.
Gotcha
ErrorAction Preference & the $Error Variable
PowerShell
# ErrorAction values and their behavior: $ErrorActionPreference = "Stop" # All errors become terminating $ErrorActionPreference = "Continue" # Default: print error, continue $ErrorActionPreference = "SilentlyContinue" # Suppress errors, continue $ErrorActionPreference = "Inquire" # Prompt user on each error # The $Error automatic variable stores the last 256 errors: $Error[0] # Most recent error $Error.Clear() # Clear the error list $Error.Count # How many errors in session # Best practice pattern for scripts: Set-StrictMode -Version Latest # Catch undefined vars, uninitialized properties $ErrorActionPreference = "Stop" # Make all errors catchable try { # Your risky operations here Get-Item "\\server\share\file.txt" } catch { Write-Error "Script failed: $($_.Exception.Message)" exit 1 # Non-zero exit code signals failure to calling process }
Set-StrictMode -Version Latest is your best defense against subtle bugs. It enforces:
undefined variables throw errors (instead of returning empty string), uninitialized properties throw errors,
and function calls follow strict argument rules. Always add this to production scripts.
🧠 Check Your Understanding
A cmdlet produces an error. Your try/catch does NOT catch it. What is the most likely reason?
Scope & Modules
Gotcha
Variable Scope
PowerShell
$globalVar = "I am global" function Test-Scope { # Functions create a CHILD scope — they READ parent vars Write-Host $globalVar # Works: reads parent scope $localVar = "I am local" Write-Host $localVar # Works: in current scope } Test-Scope Write-Host $localVar # EMPTY: $localVar died with the function # Scope modifiers to cross scope boundaries: $script:sharedVar = "Shared" # Script scope — accessible within .ps1 file $global:config = @{ Debug = $true } # Global scope — persists entire session $env:MY_VAR = "env" # Environment scope — available to child processes # DOT-SOURCING: run a script in the CURRENT scope (not a child) . .\config.ps1 # Functions and vars from config.ps1 are now available here & .\config.ps1 # Call operator: runs in child scope (no bleed-through)
Dot-sourcing vs. the call operator: This is a frequent confusion. The dot-source operator
(
.) runs a script in the current scope — all functions and variables it defines become
available in your session. The call operator (&) runs in a child scope — nothing bleeds
through. Profiles ($PROFILE) are dot-sourced by PowerShell automatically.
Pro Pattern
Script Modules (.psm1)
PowerShell
MyTools\MyTools.psm1
# MyTools.psm1 — a script module # Everything here is private by default. # Export only the public API with Export-ModuleMember. function Get-PrivateHelper { # Internal function — NOT exported, invisible to callers return "internal result" } function Get-ServerStatus { [CmdletBinding()] param([string]$Name) $helper = Get-PrivateHelper # Internal functions accessible within module [PSCustomObject]@{ Name = $Name; Helper = $helper } } function Restart-AllServices { param([string]$ComputerName) # Public function } # Explicitly export only the public-facing functions: Export-ModuleMember -Function "Get-ServerStatus", "Restart-AllServices" #---------------------------------------------------- # Companion manifest: MyTools\MyTools.psd1 # ModuleVersion = '1.0.0' # Author = 'Your Name' # RootModule = 'MyTools.psm1' # FunctionsToExport = @('Get-ServerStatus','Restart-AllServices') #----------------------------------------------------
PS
Usage
# Import and use the module: Import-Module ".\MyTools\MyTools.psm1" Get-Module MyTools # Verify it loaded Get-Command -Module MyTools # List exported commands Get-ServerStatus -Name "web01" Get-PrivateHelper # ERROR: not exported # Install to module path for auto-loading: $env:PSModulePath # Shows search paths # Copy MyTools\ to: $HOME\Documents\PowerShell\Modules\MyTools\ # Then: Import-Module MyTools (works from any location)
Module structure is the hallmark of professional PowerShell development.
Organize related functions into a
.psm1 module with a .psd1 manifest.
This enables versioning, dependency declarations, proper Get-Help integration,
and publishing to the PowerShell Gallery (Publish-Module).
🧠 Check Your Understanding
You dot-source a script:
. .\tools.ps1. What is TRUE about variables defined in tools.ps1?Quiz Score: 0 / 4
Work through all sections and check your understanding above, then mark this module complete.