01 · Advanced Functions & Parameter Validation¶
Level 1 and 2 functions took whatever was handed to them and let the body sort it out — a bad value would surface as a confusing error three lines deep, or worse, silently produce garbage. Advanced functions push validation up to the parameter itself: bad input is rejected before your code ever runs, with a message that names the exact problem.
[CmdletBinding()]: the switch that turns a function "advanced"¶
function Test-EvenNumber {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[int]$Number
)
process {
$Number % 2 -eq 0
}
}
[CmdletBinding()] gives a plain function the same behaviors as a real
cmdlet: common parameters (-Verbose, -ErrorAction, -WhatIf with
SupportsShouldProcess), strict parameter binding, and support for
begin/process/end blocks. Without it, param() still works, but
pipeline input and -Verbose do not.
Validation attributes¶
Each one rejects bad input at the parameter, before the function body runs at all:
function New-UserAccount {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidatePattern('^[a-zA-Z][a-zA-Z0-9._-]{2,19}$')]
[string]$UserName,
[Parameter(Mandatory)]
[ValidateSet('Standard', 'Admin', 'Guest')]
[string]$Role,
[ValidateRange(13, 120)]
[int]$Age = 18,
[ValidateScript({
if ($_ -match '^\S+@\S+\.\S+$') { $true }
else { throw "'$_' is not a valid email address" }
})]
[string]$Email,
[ValidateNotNullOrEmpty()]
[string]$Department = "General"
)
process {
[pscustomobject]@{
UserName = $UserName
Role = $Role
Age = $Age
Email = $Email
Department = $Department
}
}
}
Now feed it bad input:
Caught: Cannot validate argument on parameter 'UserName'. The argument
"jd" does not match the "^[a-zA-Z][a-zA-Z0-9._-]{2,19}$" pattern. Supply
an argument that matches "^[a-zA-Z][a-zA-Z0-9._-]{2,19}$" and try the
command again.
try { New-UserAccount -UserName "jdoe2" -Role SuperAdmin }
catch { "Caught: $($_.Exception.Message)" }
Caught: Cannot validate argument on parameter 'Role'. The argument
"SuperAdmin" does not belong to the set "Standard,Admin,Guest" specified
by the ValidateSet attribute.
try { New-UserAccount -UserName "jdoe3" -Role Standard -Email "not-an-email" }
catch { "Caught: $($_.Exception.Message)" }
That last message is the custom one from inside ValidateScript — when
the script block throws, its message becomes the validation failure
message instead of PowerShell's generic wording. ValidateScript runs
once per value with $_ bound to it, and must return $true/$false
or throw; returning $false produces PowerShell's generic (less useful)
error, so throwing your own message is almost always better.
| Attribute | Rejects |
|---|---|
ValidateNotNullOrEmpty |
$null, empty string, empty collection |
ValidateSet(...) |
anything not in the fixed list |
ValidateRange(min, max) |
numbers outside the range |
ValidatePattern('regex') |
strings that don't match the regex |
ValidateScript({...}) |
anything the script block rejects or throws on |
ValidateCount(min, max) |
arrays with too few/many elements |
begin / process / end: the trap of pipeline scoping¶
A function that accepts pipeline input runs its process block once
per item, but begin and end each run exactly once — a distinction
that trips people up constantly:
function Get-RunningTotal {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[int]$Value
)
begin {
$total = 0
Write-Verbose "Starting total at 0"
}
process {
$total += $Value
[pscustomobject]@{ Value = $Value; RunningTotal = $total }
}
end {
Write-Output "Final total: $total"
}
}
1..4 | Get-RunningTotal
The trap: if you write $total = 0 inside process instead of begin,
it resets to zero on every piped item, and your "running" total never
accumulates. begin is for one-time setup (opening a connection,
initializing an accumulator); process is for per-item work; end is
for one-time cleanup or a final summary — put logic in the wrong block
and it silently runs the wrong number of times.
If a function has no begin/process/end blocks at all, its entire
body behaves as an implicit end block — meaning it only runs once,
after all pipeline input has already arrived, not once per item. That's
why a function using $Number from ValueFromPipeline but written as flat
top-level code will only ever see the last item piped to it.
$_ and $PSItem inside a function¶
Inside ValidateScript and inside pipeline-aware scriptblocks generally,
$_ (and its more readable alias $PSItem) refers to the current
pipeline object being evaluated. They're interchangeable — $PSItem was
added later purely for readability in longer scripts:
The trap: $_/$PSItem are only bound inside blocks PowerShell is
actively iterating (ForEach-Object, Where-Object, process via
pipeline, validation scriptblocks). Reference them outside such a
context — say, in a plain function body — and you get $null, not an
error, which can silently produce wrong results instead of failing loudly.
Dynamic help with comment-based help¶
function New-UserAccount {
<#
.SYNOPSIS
Creates a validated user account object.
.PARAMETER UserName
3-20 characters, must start with a letter.
.EXAMPLE
New-UserAccount -UserName jdoe -Role Admin
#>
[CmdletBinding()]
param(...)
}
Get-Help New-UserAccount -Full picks this up automatically — no extra
registration needed. It costs a few lines and turns your function into
something a teammate (or future you) can discover without reading the
source.
Cheat sheet¶
| Feature | Purpose |
|---|---|
[CmdletBinding()] |
opt into common parameters, strict binding, pipeline blocks |
[Parameter(Mandatory)] |
require a value; prompts interactively if missing |
ValueFromPipeline |
bind the whole pipeline object to this parameter |
ValueFromPipelineByPropertyName |
bind a same-named property of the pipeline object |
ValidateSet, ValidateRange, ValidatePattern, ValidateScript |
reject bad values before the body runs |
begin |
runs once, before any pipeline item |
process |
runs once per pipeline item |
end |
runs once, after all pipeline items |
$_ / $PSItem |
current item inside an active pipeline context only |
Comment-based help (<# .SYNOPSIS ... #>) |
powers Get-Help for your own functions |
How It Actually Works¶
DynamicParam blocks exist because PowerShell's parameter binder needs
the complete parameter set before it can bind anything, but some
parameters (like a tab-completable list of Azure resource groups) can
only be known by querying something at runtime. The engine handles this
by calling your DynamicParam block before the normal binding pass,
collecting whatever RuntimeDefinedParameter objects it returns, and
merging them into the parameter set for this specific invocation —
which is why dynamic parameters can vary in name and validation rules
from call to call in a way param()-declared ones never can, and also
why they're invisible to static tooling (like Get-Help -Parameter *)
that doesn't actually execute your function to discover them.
Comment-based help isn't parsed by a lightweight regex scanning for .SYNOPSIS-style
tags at display time — Get-Help locates the comment block by walking the
function's AST for a comment token positioned immediately before (or, for
some layouts, inside) the function body, then runs a dedicated
help-comment parser that recognizes the .KEYWORD tag grammar. This
AST-based approach is why comment-based help must be contiguous and
correctly positioned relative to the function keyword — a blank line or
unrelated comment breaking the block can cause Get-Help to silently
fall back to auto-generated (parameter-list-only) help instead of your
authored content.
Custom [ValidateScript()] blocks run inside the parameter binder's
validation phase with $_ bound to the candidate value — critically,
before the parameter's default or coerced value is assigned to the
actual variable in your function's scope, which is why a ValidateScript
that throws prevents the function body from running at all, exactly like
a type-mismatch error would, rather than merely logging a warning and
proceeding with a bad value.
Exercise¶
Write an advanced function New-InventoryItem with [CmdletBinding()],
pipeline support (ValueFromPipelineByPropertyName), and validation on at
least three parameters: Sku (pattern like SKU-####), Quantity
(range 0–10000), and Category (a fixed ValidateSet of your choosing).
Add a begin block that initializes a running count of items processed
and an end block that reports the total — then pipe an array of
hashtables/objects through it and confirm both the per-item validation
and the final count behave correctly.