Chapter 06Lesson 05~130 minutes

Script Blocks, Closures, Delayed Execution, and Reusable Behavior

Treat executable PowerShell behavior as ScriptBlock objects, invoke and parameterize it safely, understand delayed state capture and closures, and keep trusted code separate from untrusted data.

Learning objectives

  • Explain a script block as executable PowerShell code represented by a ScriptBlock object rather than immediately executed text.
  • Invoke script blocks with the call operator, use parameters, and understand when dot invocation changes caller scope.
  • Use delayed execution to separate what behavior should run from when a caller chooses to run it.
  • Recognize script blocks as callbacks used by Where-Object, ForEach-Object, jobs, remoting, events, and functions.
  • Explain closures and GetNewClosure at a practical level while preferring explicit parameters when they are clearer.
  • Prevent code injection by keeping untrusted text as data and avoiding Invoke-Expression command construction.

1. A script block is PowerShell code stored as an object

So far, braces have appeared after if, loops, Where-Object, and ForEach-Object. A script block is a block of executable PowerShell statements represented as a System.Management.Automation.ScriptBlock object. Storing behavior as an object lets you pass that behavior to another command or invoke it later.

$operation = {
    'Health check executed.'
}

$operation.GetType().FullName
$operation

Merely assigning the script block does not execute its contents. That is the key mental model: the code exists as a value until something invokes it.

2. The call operator & invokes a script block without importing its scope

The call operator & executes a command value or script block. It is the normal way to run a stored script block explicitly.

$operation = {
    param([string]$Target)
    [pscustomobject]@{
        Target = $Target
        CheckedAt = Get-Date
    }
}

& $operation 'api-01'

The script block accepts parameters through a param() block, just like a script or function. Chapter 08 will teach script parameters in depth.

3. Dot invocation runs a script block in the current scope, so use it deliberately

The dot invocation operator (.) executes a script or script block in the current scope rather than a child scope. That means variable changes can persist in the caller. This is sometimes useful for loading definitions, but it also increases coupling.

$value = 'before'
$changeValue = { $value = 'after' }

& $changeValue
"After &: $value"

. $changeValue
"After dot invocation: $value"

For reusable behavior that should not unexpectedly rewrite caller state, prefer ordinary invocation and explicit parameters/output. Chapter 08 covers scope rules more fully.

4. Delayed execution separates “what to do” from “when to do it”

A script block can be prepared now and executed later after a decision, after input arrives, or by another subsystem. This is why script blocks appear throughout PowerShell APIs.

$actions = @{
    Validate = { param($Name) "Validate $Name" }
    Deploy   = { param($Name) "Deploy $Name" }
    Verify   = { param($Name) "Verify $Name" }
}

$selected = 'Validate'
& $actions[$selected] 'api-01'

The hashtable stores named strategies. The lookup chooses behavior as data, then & invokes the selected behavior. No command text is constructed.

5. Pipeline cmdlets already use script blocks as callbacks

A callback is behavior supplied to another operation so that operation can call it at the appropriate time. In PowerShell, Where-Object receives a script block that answers “keep this object?” and ForEach-Object receives a script block that says “perform this operation for the current object.”

$targets = @(
    [pscustomobject]@{ Name='api-01'; Enabled=$true }
    [pscustomobject]@{ Name='worker-01'; Enabled=$false }
)

$isEnabled = { $_.Enabled }
$toName = { $_.Name }

$targets | Where-Object $isEnabled | ForEach-Object $toName

This is the same script-block object concept used in loops, filtering, jobs, remoting, and event handling; only the component that invokes the callback changes.

6. Closures preserve selected surrounding values for later execution

A script block can refer to variables from the environment where it was created. When delayed execution happens after those variables change, beginners can be surprised. GetNewClosure() creates a new script block whose local variable references capture the current values.

$prefix = 'staging'
$labeler = { param($name) "$prefix::$name" }
$closedLabeler = $labeler.GetNewClosure()

$prefix = 'production'

& $labeler 'api-01'
& $closedLabeler 'api-01'

The ordinary script block observes the later $prefix value in the applicable scope; the closure preserves the captured value. Use closures only when captured state is genuinely part of the behavior contract. Passing explicit parameters is often easier to test and reason about.

7. Jobs, remoting, and events also accept script blocks, but execution boundaries change what the code can see

The same syntax appears in Start-Job, Invoke-Command, event handlers, and parallel operations. Do not assume a script block automatically has access to every local variable when it executes elsewhere. Jobs and remoting can cross process or machine boundaries, where serialization and scope rules apply.

# Syntax preview only; Chapter 14 covers remoting in depth.
$work = {
    param($Name)
    "Running for $Name"
}

# Local invocation now:
& $work 'api-01'

# Similar script-block shapes are supplied to jobs/remoting later:
# Start-Job -ScriptBlock $work -ArgumentList 'api-01'
# Invoke-Command -ComputerName server01 -ScriptBlock $work -ArgumentList 'api-01'

Treat the execution boundary as part of the design: where does this code run, what variables are available, what object types cross the boundary, and how are failures returned?

8. Never turn untrusted text into executable PowerShell code

A script block is executable code. Data from users, API responses, files, pull requests, issue text, or environment variables should remain data. Invoke-Expression parses a string as PowerShell code, so interpolating untrusted text into that string can create code-injection vulnerabilities.

$target = 'api-01'

# Safe: target remains an argument value.
$probe = { param($Name) "Checking $Name" }
& $probe $target

# Avoid building executable source text from data:
# Invoke-Expression "Write-Output Checking $target"

Use parameters, splatting, arrays, hashtables, and script blocks that you define as code. These keep the boundary between code and data explicit. Chapter 16 returns to injection and untrusted input as a security topic.

9. Strategy/callback design makes interchangeable behavior tangible

A small strategy table is useful when the workflow is stable but one action varies. Each strategy accepts the same parameter shape and emits the same kind of object. That consistent contract matters more than the fact that the implementations are script blocks.

$strategies = @{
    Validate = {
        param($Target)
        [pscustomobject]@{ Target=$Target; Action='Validate'; Success=$true }
    }
    DryRun = {
        param($Target)
        [pscustomobject]@{ Target=$Target; Action='DryRun'; Success=$true }
    }
    Verify = {
        param($Target)
        [pscustomobject]@{ Target=$Target; Action='Verify'; Success=$true }
    }
}

$mode = 'DryRun'
& $strategies[$mode] 'api-01'

Because each strategy emits structured data, the caller can log, test, or export results without parsing host text.

10. Lab: build a reusable strategy runner with explicit code/data boundaries

The lab creates three safe in-memory strategies, validates the requested strategy name, executes it for several targets, and returns structured result objects.

$strategies = [ordered]@{
    Inspect = {
        param($Target)
        [pscustomobject]@{
            Target = $Target
            Strategy = 'Inspect'
            Result = 'Metadata inspected'
            Success = $true
        }
    }
    Validate = {
        param($Target)
        $valid = -not [string]::IsNullOrWhiteSpace($Target)
        [pscustomobject]@{
            Target = $Target
            Strategy = 'Validate'
            Result = if ($valid) { 'Valid target name' } else { 'Invalid target name' }
            Success = $valid
        }
    }
    Plan = {
        param($Target)
        [pscustomobject]@{
            Target = $Target
            Strategy = 'Plan'
            Result = "Would process $Target"
            Success = $true
        }
    }
}

$requestedStrategy = 'Validate'
$targets = 'api-01', 'worker-01', ''

if (-not $strategies.ContainsKey($requestedStrategy)) {
    throw "Unknown strategy: $requestedStrategy"
}

$runner = $strategies[$requestedStrategy]
$results = foreach ($target in $targets) {
    & $runner $target
}

$results | Format-Table Target, Strategy, Success, Result -AutoSize

The requested strategy name is data. The executable strategies are defined by the script itself. No untrusted text is parsed as PowerShell source code.

Verification checklist

11. Common mistakes to avoid

Assuming braces execute immediately. A standalone script block is a value until something invokes it.

Dot-invoking reusable behavior by default. Current-scope execution can unexpectedly modify caller variables; prefer explicit parameters and outputs.

Capturing mutable state accidentally. Delayed execution can observe a later value. Use explicit parameters or an intentional closure.

Converting strings into code. Command construction should use parameters/splatting and predefined script blocks, not Invoke-Expression.

12. Knowledge check

Question 1. What is a PowerShell script block?

Question 2. What does the call operator & do with a script block?

Question 3. Why is dot invocation different?

Question 4. What problem does GetNewClosure() solve?

Question 5. Why should untrusted text not be passed to Invoke-Expression?

13. Summary

A script block is behavior represented as an object. Invoke it with &, use dot invocation only when current-scope effects are intentional, and pass parameters rather than hiding dependencies in ambient variables. Closures can preserve captured state for delayed execution, but explicit inputs remain easier to reason about. Script blocks power filtering, iteration, jobs, remoting, events, and functions; throughout all of those uses, preserve the security boundary between trusted code and untrusted data.

14. Further reading

Next chapter

Apply control flow to providers, paths, files, content, and permissions

Chapter 07 uses conditions, loops, collections, objects, splatting, and script blocks to automate filesystem/provider operations safely across platforms.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.