The Zen of PowerShell Code
As you might imagine, I have written a lot of PowerShell code in my career. I’ve also seen plenty of code created by others. These experiences have shaped how I approach writing PowerShell. I spend a lot of time in this newsletter covering techniques and concepts. In other words, “what to write.” But there is another intangible part of writing PowerShell code, and that is the idea of elegant or beautiful code.
Because this is an intangible quality, it can’t be easily quantified. This is one of the situations where you know it when you see it.
Elegant code should flow freely not only in execution but also as written in a file. Elegant code should be easy to understand. I believe that elegant code is easier to debug and troubleshoot. It is probably even less prone to bugs from the very beginning.
Even though there is no one way to create beautiful and elegant code, I think there are a few concepts that can improve your chances. I will readily admit that I don’t always write elegant code, but it is something I strive towards.
Flowing Code
In my opinion, beautiful code should flow across your editor. If it flows across your editor, it will probably flow in execution. Think of your code as a stream of water. As the stream encounters rocks and logs, it is disturbed. There is turbulence when we want serenity.
Following PowerShell best practices is an excellent beginning. Use full command and parameter names. Every time your code hits a command alias, there is a brief disturbance as the alias has to be resolved. Elegant code doesn’t mean brief code. I’d rather have a long line of PowerShell code that flows and is easily read. In musical terms, a flowing legato section is calmer and more relaxing than a series of staccato statements.
Write Code Once
When I write code, I am always looking for ways to be concise. I try to avoid writing the same line of code in a script more than once. Conciseness isn’t the same as brevity. If the code flows elegantly, it doesn’t matter how long it is.
Here’s an example of what I’m talking about. Here’s a section of PowerShell code that a beginner might write.
$category = 'graphics'
$Path = 'C:\work'
$recurse = $False
Switch ($Category) {
'graphics' {
if ($recurse) {
$files = Get-ChildItem -Path $Path -File -Recurse |
Where-Object extension -Match '\.(bmp)|(jpg)|(jpeg)|(png)|(gif)$'
}
else {
$files = Get-ChildItem -Path $Path -File |
Where-Object extension -Match '\.(bmp)|(jpg)|(jpeg)|(png)|(gif)$'
}
$file | Measure-Object -Property Length -Sum |
Select-Object -Property @{Name = 'Path'; Expression = { $Path } },
Count, Sum, @{Name = 'Category'; Expression = { $Category } }
}
'powershell' {
if ($recurse) {
$file = Get-ChildItem -Path $Path -File -Recurse |
Where-Object extension -Match '\.ps(d)?1(xml)?$'
}
else {
$files = Get-ChildItem -Path $Path -File |
Where-Object extension -Match '\.ps(d)?1(xml)?$'
}
$files | Measure-Object -Property Length -Sum |
Select-Object -Property @{Name = 'Path'; Expression = { $Path } },
Count, Sum, @{Name = 'Category'; Expression = { $Category } }
}
'office' {
if ($recurse) {
$files = Get-ChildItem -Path $Path -File -Recurse |
Where-Object extension -Match '\.(doc(x?))|(ppt(x)?)|(xls(x)?)$'
}
else {
$files = Get-ChildItem -Path $Path -File |
Where-Object extension -Match '\.(doc(x?))|(ppt(x)?)|(xls(x)?)$'
}
$files | Measure-Object -Property Length -Sum |
Select-Object -Property @{Name = 'Path'; Expression = { $Path } },
Count, Sum, @{Name = 'Category'; Expression = { $Category } }
}
'data' {
if ($recurse) {
$files = Get-ChildItem -Path $Path -File -Recurse |
Where-Object extension -Match '\.(json)|(db)|(xml)|(csv)$'
}
else {
$files = Get-ChildItem -Path $Path -File |
Where-Object extension -Match '\.(json)|(db)|(xml)|(csv)$'
}
$files | Measure-Object -Property Length -Sum |
Select-Object -Property @{Name = 'Path'; Expression = { $Path } },
Count, Sum, @{Name = 'Category'; Expression = { $Category } }
}
}
The good news is that this is using a Switch
statement rather than a more complicated If/ElseIf
set of statements. Using Switch
makes this a little more elegant, but there’s room for improvement.
There is a lot of repetition. Even though Get-ChildItem
is only getting run once, there are multiple instances to maintain. This is a situation where splatting helps.
$dirSplat = @{
Path = $Path
File = $True
}
if ($recurse) {
#update the hashtable
$dirSplat.Add('Recurse', $True)
}
The same is true of the Where-Object
filter. I think this is more elegant.
Switch ($Category) {
'graphics' {
$filter = '\.(bmp)|(jpg)|(jpeg)|(png)|(gif)$'
}
'powershell' {
$filter = '\.ps(d)?1(xml)?$'
}
'office' {
$filter = '\.(doc(x?))|(ppt(x)?)|(xls(x)?)$'
}
'data' {
$filter = '\.(json)|(db)|(xml)|(csv)$'
}
}
The code has been simplified, which ends with a single execution.
$output = @(
@{Name = 'Path'; Expression = { $Path } },
'Count',
'Sum',
@{Name = 'Category'; Expression = { $Category } }
)
Get-ChildItem @dirSplat |
Where-Object extension -Match $filter |
Measure-Object -Property Length -Sum |
Select-Object -Property $output
We could take this even further depending on our needs. Consider this function.
Function Get-FileGroup {
[cmdletbinding()]
[OutputType("psFileGroup")]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
HelpMessage = 'Specify the folder to search'
)]
[ValidateNotNullOrEmpty()]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[switch]$Recurse,
[Parameter(Mandatory)]
[ValidateSet('graphics', 'powershell', 'office', 'data')]
[string]$Category
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Getting $Category files"
#remove category from PSBoundParameters
[void]$PSBoundParameters.Remove('Category')
#add File to PSBoundParameters
$PSBoundParameters.add("File", $True)
Switch ($Category) {
'graphics' {
$filter = '\.(bmp)|(jpg)|(jpeg)|(png)|(gif)$'
}
'powershell' {
$filter = '\.ps(d)?1(xml)?$'
}
'office' {
$filter = '\.(doc(x?))|(ppt(x)?)|(xls(x)?)$'
}
'data' {
$filter = '\.(json)|(db)|(xml)|(csv)$'
}
} #close Switch
} #begin
Process {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Using these PSBoundParameters"
$PSBoundParameters | Out-String | Write-Verbose
$data = Get-ChildItem @PSBoundParameters |
Where-Object {$_.extension -Match $filter} |
Measure-Object -Property Length -Sum
[PSCustomObject]@{
PSTypeName = 'psFileGroup'
Path = Convert-Path -path $Path
Count = $data.Count
Sum = $data.Sum
Category = $Category
}
} #process
End {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
} #close Get-FileGroup
The majority of parameters that I am using with Get-ChildItem
are passed from the function, so why not use the integrated $PSBoundParameters
hashtable? I can splat this to Get-ChildItem
, although I have to remove bound parameters that the command won’t recognize. I can also insert parameters.
#remove category from PSBoundParameters
[void]$PSBoundParameters.Remove('Category')
#add File to PSBoundParameters
$PSBoundParameters.add("File", $True)
The key command now becomes:
Get-ChildItem @PSBoundParameters
Comment Concisely
We recognize the value of code documentation. But I think sometimes people get carried away and the commenting or non-code code makes managing the real code more difficult. There is no reason to have comment blocks like this in your script file
############################################
# #
# File: MyCoolScript.ps1 #
# Version: 1.1.0 #
# Author: Jeff Hicks #
# Date: 2022-04-01 #
# # ############################################
I’ve seen many scripts, and I was once guilty of this as well, that have sections like this.
#################################################
# #
# This script does some cool stuff. #
# #
#################################################
This adds very little value and, in my opinion, interrupts the flow of your script. I find this inelegant.
I would look for ways to layer meaning and functionality. Although a haiku follows a technical layout, words are carefully chosen to layer intention and emotion. We can achieve the same result in PowerShell. I often use Write-Verbose
statements in my functions.
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
...
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
The statement provides verbose feedback to the user, it helps me as I’m writing the code, and it can serve as documentation. I don’t need to add a comment indicating I’m processing the path variable. The verbose statement handles that for me.
Even something like script metadata can do double duty. You might add this to your script file.
<#
File: MyCoolScript.ps1
Version: 1.1.0
Author: Jeff Hicks
Date: 2022-04-01
#>
It is helpful, but only when looking at the code. Since your function should have help, why not incorporate it?
<#
.Synopsis
Get file group data
.Description
...
.Notes
File: MyCoolScript.ps1
Version: 1.1.0
Author: Jeff Hicks
Date: 2022-04-01
#>
Or experiment with including it as information data. Here’s another version of the previous function.
Function Get-FileGroup {
[cmdletbinding()]
[OutputType("psFileGroup")]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
HelpMessage = 'Specify the folder to search'
)]
[ValidateNotNullOrEmpty()]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[switch]$Recurse,
[Parameter(Mandatory)]
[ValidateSet('graphics', 'powershell', 'office', 'data')]
[string]$Category
)
Begin {
$meta = [PSCustomObject]@{
File = $MyInvocation.MyCommand
Version = '1.1.0'
Author = 'Jeff Hicks'
Date = '11/13/2023 10:05 AM'
}
Write-Information -MessageData $meta -Tags meta
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
$meta | Out-String | Write-Verbose
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Getting $Category files"
#remove category from PSBoundParameters
[void]$PSBoundParameters.Remove('Category')
#add File to PSBoundParameters
$PSBoundParameters.add("File", $True)
Switch ($Category) {
'graphics' {
$filter = '\.(bmp)|(jpg)|(jpeg)|(png)|(gif)$'
}
'powershell' {
$filter = '\.ps(d)?1(xml)?$'
}
'office' {
$filter = '\.(doc(x?))|(ppt(x)?)|(xls(x)?)$'
}
'data' {
$filter = '\.(json)|(db)|(xml)|(csv)$'
}
} #close Switch
} #begin
Process {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Using these PSBoundParameters"
$PSBoundParameters | Out-String | Write-Verbose
$data = Get-ChildItem @PSBoundParameters |
Where-Object {$_.extension -Match $filter} |
Measure-Object -Property Length -Sum
[PSCustomObject]@{
PSTypeName = 'psFileGroup'
Path = Convert-Path -path $Path
Count = $data.Count
Sum = $data.Sum
Category = $Category
}
} #process
End {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
} #close Get-FileGroup
The user can run the command.
PS C:\> get-filegroup c:\work -Category graphics -InformationVariable iv -Verbose
VERBOSE: [16:07:03.8144980 BEGIN ] Starting Get-FileGroup
VERBOSE:
File Version Author Date
---- ------- ------ ----
Get-FileGroup 1.1.0 Jeff Hicks 11/13/2023 10:05 AM
VERBOSE: [16:07:03.8246342 BEGIN ] Running under PowerShell version 7.3.9
VERBOSE: [16:07:03.8249582 BEGIN ] Getting graphics files
VERBOSE: [16:07:03.8253028 PROCESS] Processing c:\work
VERBOSE: [16:07:03.8256649 PROCESS] Using these PSBoundParameters
VERBOSE:
Key Value
--- -----
File True
InformationVariable iv
Verbose True
Path c:\work
VERBOSE: [16:07:03.8339042 END ] Ending Get-FileGroup
Path Count Sum Category
---- ----- --- --------
C:\work 31 21506751.00 graphics
If they need the metadata, they can get it.
PS C:\> $iv.MessageData
File Version Author Date
---- ------- ------ ----
Get-FileGroup 1.1.0 Jeff Hicks 11/13/2023 10:05 AM
Code Layout and Formatting
How your code looks in the editor plays a critical role in determining how elegant it is. This isn’t a long script, so maybe you could work with it.
Function Get-FileGroup {
[cmdletbinding()]
[OutputType("psFileGroup")]
Param(
[Parameter(Position = 0,ValueFromPipeline,HelpMessage = 'Specify the folder to search')]
[ValidateNotNullOrEmpty()]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[switch]$Recurse,
[Parameter(Mandatory)]
[ValidateSet('graphics', 'powershell', 'office', 'data')]
[string]$Category
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Getting $Category files"
#remove category from PSBoundParameters
[void]$PSBoundParameters.Remove('Category')
$PSBoundParameters.add("File", $True)
Switch ($Category) {
'graphics' {$f = '\.(bmp)|(jpg)|(jpeg)|(png)|(gif)$'}
'powershell' { $f = '\.ps(d)?1(xml)?$'}
'office' { $f = '\.(doc(x?))|(ppt(x)?)|(xls(x)?)$' }
'data' { $f = '\.(json)|(db)|(xml)|(csv)$'}
}
}
Process {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Using these PSBoundParameters"
$PSBoundParameters | Out-String | Write-Verbose
$data = gci @PSBoundParameters | ? {$_.extension -Match $f} | Measure Length -Sum
[PSCustomObject]@{PSTypeName = 'psFileGroup';Path=(Convert-Path -path $Path);Count=$data.Count;Sum=$data.Sum;Category=$Category}
}
End { Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"}
}
I would not classify this as beautiful code. There is no flow. Compare this to the earlier functions that use white space and indenting. The code visually flows as well as logically. If you had to debug a file that someone else wrote, which file would you rather use?
Separate Data from Code
The last suggestion is one I have made before for other reasons, but you should separate the data you need to execute your code from the code itself. Initializing a set of variables for things like paths, domain names, or directories is like throwing pebbles in the stream. Elegant PowerShell code should be transparent and inherently flexible. A hard-coded domain name means the code can only be used in one place unless the file is edited.
This is not the PowerShell way. Pass the data you need for your code to run using parameters, or consider using data configuration files. Personally, I find the use of configuration data inherently beautiful. I can change the behavior of a command with one simple change. Elegant code should be code that you can share with anyone without having to redact anything.
Summary
Creating elegant PowerShell code is an ongoing and perpetual task. I strongly believe that the more elegant your code, the more secure it will be, the easier it will be to debug and maintain, and the easier it will be for the user to use. But I will make a disclaimer for the PowerShell code I share in this newsletter. The code I write is often inelegant because I am trying to teach or make a point. But in my published modules, I strive to be elegant. I know it isn’t always perfect and probably never will be. But we have to start somewhere.
I’d love to hear your comments and thoughts on this subject.
eof