🔧 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.