Script blocks, scope and closures
Treat code as data with script blocks, predict which variables a function can change, and freeze values into closures.
- Store code in script blocks, run it with & and pass it parameters
- Predict which scope a variable lives in, and use $script: and dot-sourcing deliberately
- Capture values with GetNewClosure() and filter collections with the .Where() and .ForEach() methods
You’ve been writing script blocks since the first pipeline: everything between { and } in Where-Object { ... }, ForEach-Object { ... } and Start-Job { ... } is one. A script block is a value - a piece of code you can store in a variable, put in a hashtable, pass to a function and run later.
- Run one with the call operator:
& $block. - Give it a
param()block and it takes arguments, exactly like a function. (A function is really just a named script block.) - Put several in a hashtable and you have a dispatch table: look up the code by name, then run it. It replaces long
switchstatements and makes adding a new command a one-line change.
The sanctuary’s old wizard left a spellbook. Let’s make it executable.
1$roar = { 'RAAAWR!' }
2& $roar
3$roar.GetType().Name
4$breathe = {
5 param([string]$Dragon, [int]$Heat = 3)
6 "$Dragon breathes fire" + ('!' * $Heat)
7}
8& $breathe -Dragon Ember
9& $breathe Cinder 1
10$spells = @{
11 light = { 'a warm glow fills the cave' }
12 warm = { param($Name) "$Name feels toasty" }
13}
14& $spells['light']
15& $spells.warm 'Glim'RAAAWR! ScriptBlock Ember breathes fire!!! Cinder breathes fire! a warm glow fills the cave Glim feels toasty
Scope: who can see a variable?
Every script, function and script block gets its own scope - a layer of variables stacked on top of the caller’s. Three rules explain almost everything:
- Reading looks outward. A function can read any variable from the scopes that called it.
- Assigning stays local.
$meals = 5inside a function creates a new local$mealsthat hides the outer one and vanishes when the function returns. Even$meals++reads the outer value, then writes a local copy. - Objects are shared.
$stats.Meals++doesn’t assign$stats- it reads the outer hashtable and changes what’s inside it, so the caller sees the change.
When you really do mean the outer variable, say so with a scope modifier: $script:meals (the script file’s scope) or $global:meals (the whole session - rarely a good idea). Running a block with . instead of & (dot-sourcing) runs it in your scope, which is why . .\helpers.ps1 keeps its functions.
1$meals = 0
2function Add-MealWrong { $meals = $meals + 1; "inside: $meals" }
3function Add-MealRight { $script:meals++; "inside: $script:meals" }
4Add-MealWrong
5Add-MealWrong
6"outside: $meals"
7Add-MealRight
8Add-MealRight
9"outside: $meals"
10& { $treat = 'apple' }
11"after &: [$treat]"
12. { $treat = 'pear' }
13"after .: [$treat]"inside: 1 inside: 1 outside: 0 inside: 1 inside: 2 outside: 2 after &: [] after .: [pear]
Try it
Does the caller see the change?
Before each line runs, $count is 0 and $stats is @{ Meals = 0 } in the script. Sort each line by whether the script sees a changed value afterwards. Watch out for ForEach-Object - it’s a special case.
“function f { $count = 5 }; f”
“function f { $script:count = 5 }; f”
“function f { $count++ }; f”
“function f { $stats.Meals++ }; f”
“& { $count = 5 }”
“. { $count = 5 }”
“1..5 | ForEach-Object { $count++ }”
“Start-Job { $count = 5 } | Wait-Job”
Closures: freeze a value into a script block
A script block looks variables up when it runs, not when it’s created. Build several in a loop and they all see the loop variable’s final value - a classic surprise. .GetNewClosure() makes a copy of the block that remembers the values the variables had right then.
1$greeters = foreach ($name in 'Ember', 'Glim') {
2 { "Good morning, $name" }
3}
4$name = 'nobody'
5$greeters | ForEach-Object { & $_ }
6$greeters = foreach ($name in 'Ember', 'Glim') {
7 { "Good morning, $name" }.GetNewClosure()
8}
9$name = 'nobody'
10$greeters | ForEach-Object { & $_ }Good morning, nobody Good morning, nobody Good morning, Ember Good morning, Glim
The magic methods: .Where() and .ForEach()
Every collection has two built-in methods that take a script block. They work on data already in memory, are faster than the pipeline, and .Where() has extra modes: 'First' stops at the first match, and 'Split' returns the matches and the rest in one go.
1$wingspans = 12, 4, 9, 2, 6, 15
2$wingspans.Where({ $_ -gt 8 }) -join ','
3$wingspans.Where({ $_ -gt 8 }, 'First')
4$big, $small = $wingspans.Where({ $_ -gt 8 }, 'Split')
5"big: $($big -join ',') small: $($small -join ',')"
6('ember', 'glim').ForEach({ $_.ToUpper() }) -join ' '
7('Ember', 'Glim').ForEach('Length') -join ','12,9,15 12 big: 12,9,15 small: 4,2,6 EMBER GLIM 5,4
Key takeaways
Script blocks are values: store them, put them in hashtables, and run them with
&.Functions can read outer variables, but assigning creates a local copy - use
$script:when you mean the outer one.Changing an object’s contents (a hashtable entry, a list) is visible to the caller;
.runs a block in your own scope..GetNewClosure()freezes current values into a block;.Where()and.ForEach()filter and transform in-memory collections fast.
Lesson quiz
7 questions · pass with 5 correct · up to 50 XP
Passing this quiz completes the lesson and keeps your streak going. Questions you miss come back in review sessions later.
Practice: write PowerShell scripts
Write a script in the editor and run it for real against sample input, which is piped into your script as $input. Scripts run on PowerShell 6.2 through Try It Online (tio.run), a free public service, so these exercises avoid PowerShell 7-only syntax; your script and test input are sent there.
The wizard’s spellbook
Each input line is a spell, sometimes with one argument. Use a hashtable of script blocks to cast them:
lightprintsThe cave glowswarm <name>prints<name> feels toastygrow <n>printsThe egg grows to <2n> cm- anything else prints
Unknown spell: <word>
Spell names ignore case, so LIGHT works too. (Hashtable keys already do.)
- Four spells
- Shouting and singing
Lessons teach PowerShell 7; your script runs on PowerShell 6.2 via Try It Online (tio.run), a free public service, so stick to syntax that works there. Test input is piped into your script as $input. Your script and test input are sent to that service.
The treat counter
Each input line is a dragon that gets a treat. The starter’s Add-Treat should number every treat across the whole day (Ember gets treat #3), but the count never moves - fix the scope bug.
Also count treats per dragon in the $treats hashtable, and finish with greedy: <names> - the dragons with 2 or more treats, sorted and comma-separated - or greedy: nobody.
- A busy morning
- One treat
Lessons teach PowerShell 7; your script runs on PowerShell 6.2 via Try It Online (tio.run), a free public service, so stick to syntax that works there. Test input is piped into your script as $input. Your script and test input are sent to that service.
Questions about this lesson
Stuck? Ask. Figured something out? Share it. Explaining is one of the best ways to learn.
Loading posts…