PowerShell Script Blocks: Use Code as a First-Class Object

PowerShell Script Blocks: Use Code as a First-Class Object

PowerShell Tips Editor 4 min read
PowerShell Script Blocks: Use Code as a First-Class Object

Script blocks are the secret sauce behind Where-Object, ForEach-Object, Invoke-Command, and nearly every dynamic PowerShell pattern — yet many scripters only use them implicitly without understanding them as first-class objects. A PowerShell script block is a reusable chunk of code you can store in a variable, pass as an argument, invoke on demand, and even capture variables from the surrounding scope. Understanding script blocks unlocks higher-order programming patterns that make your code more flexible and composable.

Define and Invoke a Script Block

A script block is defined with curly braces and stored in a variable like any other object. Invoke it with the & call operator or the .Invoke() method.

# Define a script block
$greet = { Write-Output "Hello from a script block!" }

# Invoke with the call operator
& $greet

# Invoke with the .Invoke() method
$greet.Invoke()
Hello from a script block!
Hello from a script block!
# Script block with multiple statements
$summary = {
    $proc  = (Get-Process | Measure-Object).Count
    $svc   = (Get-Service | Where-Object Status -eq Running).Count
    Write-Output "Processes: $proc — Running services: $svc"
}

& $summary

Pass Script Block as Parameter

Script blocks are the mechanism behind -FilterScript, -ScriptBlock, and similar parameters in PowerShell cmdlets. You can also define functions that accept script blocks as parameters.

# Script blocks passed to cmdlets
Get-Process | Where-Object { $_.CPU -gt 10 }
Get-ChildItem "C:\Logs" | ForEach-Object { $_.Name.ToUpper() }

# Function that accepts a script block parameter
function Measure-ScriptBlock {
    param(
        [scriptblock]$ScriptBlock,
        [int]$Iterations = 3
    )

    $times = for ($i = 0; $i -lt $Iterations; $i++) {
        (Measure-Command { & $ScriptBlock }).TotalMilliseconds
    }
    [PSCustomObject]@{
        Iterations = $Iterations
        AvgMs = [math]::Round(($times | Measure-Object -Average).Average, 2)
        MinMs = [math]::Round(($times | Measure-Object -Minimum).Minimum, 2)
    }
}

Measure-ScriptBlock -ScriptBlock { Get-Process | Measure-Object } -Iterations 5

Script Blocks with Arguments

Pass arguments to a script block using the param() declaration inside it, then supply them with the call operator or .Invoke().

# Script block with parameters
$addNumbers = { param($a, $b) $a + $b }

# Call with arguments
& $addNumbers 5 10    # Returns 15
& $addNumbers -a 100 -b 200  # Returns 300

# Script block that processes a path
$getFileCount = {
    param([string]$Path, [string]$Filter = "*")
    (Get-ChildItem -Path $Path -Filter $Filter -File).Count
}

& $getFileCount "C:\Logs" "*.log"

Using .Invoke() Method

The .Invoke() method calls the script block and returns a collection. Note that .Invoke() always returns a collection (System.Collections.ObjectModel.Collection), not a scalar — even for single-value outputs.

# .Invoke() always returns a collection
$double = { param($n) $n * 2 }
$result = $double.Invoke(5)

Write-Output $result          # Outputs: 10
Write-Output $result.GetType().Name  # Collection`1

# Access the first element
$value = $result[0]   # 10

# Contrast with & operator (returns scalar or array naturally)
$value2 = & $double 5   # Returns scalar 10

Closures: Capturing Variable Values

A script block can capture variables from its enclosing scope at the time it’s defined, creating a closure-like behavior. Use GetNewClosure() to explicitly capture the current values — otherwise variable references in the block resolve at invocation time.

# Without GetNewClosure — variable resolves at invocation time
$blocks = @()
for ($i = 0; $i -lt 3; $i++) {
    $blocks += { $i }   # $i is referenced, not captured
}
& $blocks[0]  # Returns 3 (current value of $i after loop)

# With GetNewClosure — value captured at definition time
$closures = @()
for ($i = 0; $i -lt 3; $i++) {
    $closures += { $i }.GetNewClosure()
}
& $closures[0]  # Returns 0
& $closures[1]  # Returns 1
& $closures[2]  # Returns 2

Common Uses: ForEach-Object, Where-Object

Script blocks are the argument type for the most-used pipeline filtering and transformation cmdlets. Understanding this makes pipelines feel natural.

# Store filter logic in a named script block for reuse
$isHighCpu = { $_.CPU -gt 20 }
$largeProcess = { $_.WorkingSet -gt 100MB }

# Reuse the same script block in multiple places
Get-Process | Where-Object $isHighCpu
Get-Process | Where-Object { $isHighCpu.Invoke($_)[0] }  # Alternative

# Dynamic dispatch — choose logic based on parameter
function Get-FilteredProcesses {
    param([ValidateSet("HighCPU","LargeMem","All")][string]$Filter = "All")
    $filters = @{
        "HighCPU"  = { $_.CPU -gt 20 }
        "LargeMem" = { $_.WorkingSet -gt 100MB }
        "All"      = { $true }
    }
    Get-Process | Where-Object $filters[$Filter]
}

Get-FilteredProcesses -Filter HighCPU

Common Errors and Fixes

  • Variable capture at definition time vs execution time: A script block stored in a variable captures variable names, not values, unless you use GetNewClosure(). When you run the script block later, it looks up the variable in the scope where it runs — not where it was defined. This causes confusing behavior in loops. If you need a script block to remember a value from when it was created, call .GetNewClosure() on it immediately after creation.
  • .Invoke() returns collection not scalar: $sb.Invoke() always returns a Collection[PSObject], even if the script block produces one value. If you assign the result to a typed variable like [int]$n = $sb.Invoke(5), the implicit conversion may fail or produce unexpected results. Either use & $sb 5 (call operator returns natural types) or access the first element with $sb.Invoke(5)[0].

Related Cmdlets / See Also

Wrapping Up

Script blocks are one of PowerShell’s most powerful features — once you think of them as first-class objects you can store, pass, and invoke, a whole range of dynamic and reusable code patterns becomes available. As a next step, refactor a complex Where-Object filter in your most-used pipeline into a named script block variable — you’ll be able to reuse the same filter logic in multiple places and make your code considerably more readable.

Send-Item -To