Loading
0x100Lesson 17 of 24

Script blocks, scope and closures

Treat code as data with script blocks, predict which variables a function can change, and freeze values into closures.

28 min 7-question quiz 2 code exercises
By the end of this lesson you can
  • 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 switch statements and makes adding a new command a one-line change.

The sanctuary’s old wizard left a spellbook. Let’s make it executable.

spellbook.ps1
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'
Output
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:

  1. Reading looks outward. A function can read any variable from the scopes that called it.
  2. Assigning stays local. $meals = 5 inside a function creates a new local $meals that hides the outer one and vanishes when the function returns. Even $meals++ reads the outer value, then writes a local copy.
  3. 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.

scope.ps1
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]"
Output
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.

0 of 8 sortedScore 0/0
  • “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.

closures.ps1
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 { & $_ }
Output
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.

magic-methods.ps1
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 ','
Output
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.

Exercise 1

The wizard’s spellbook

+25 XP

Each input line is a spell, sometimes with one argument. Use a hashtable of script blocks to cast them:

  • light prints The cave glows
  • warm <name> prints <name> feels toasty
  • grow <n> prints The 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
script.ps1
Loading editor…

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.

Exercise 2

The treat counter

+25 XP

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
script.ps1
Loading editor…

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…

Did you like the lesson? 😆👍
Consider a donation to support our work: