Menu Close

Refresher: Developing PowerShell Functions (as cmdlet)

Introduction

There is a lot to read about cmdlets in the official documentation. To put it simple, a cmdlet (“command-let”) is a command to perform a specific function and is written in C# and compiled as a .NET class. Examples of cmdlets are the usual commands like Get-Verb and Get-ChildItem. A cmdlet has some advantages over a regular PowerShell function, so in behavior it is basically a function with extra features. You can mimic this cmdlet behavior in a PowerShell written function by using CmdletBinding(). In this post I will try to refresh knowledge about cmdlets and functions in PowerShell.

All given examples are for demonstration purposes and all are considered to be *.ps1 files unless Export-ModuleMember is used.

CmdletBinding

You can make a PowerShell function behave as a compiled cmdlet using [CmdletBinding()]. This enhances the function in the following ways:

  • Supports parameters like -Verbose and -ErrorAction automatically.
  • Supports Pipeline input, this allows functions to process input objects like cmdlets.
  • Enables confirmation prompts such as ShouldProcess which adds the parameters -Confirm and -WhatIf
  • Improved parameter handling, like PositionalBinding.

Example

# Finds first directory with provided name, returns full path when found and stops searching immediately
function Find-Directory {
    
    [CmdletBinding(SupportsShouldProcess=$True)]
     param(
        [Parameter(Mandatory=$True)]
        [String]$Name
    )

    Get-ChildItem -Recurse -Directory | Where-Object { $_.Name -eq $Name } | ForEach-Object {
        Write-Output $_.FullName
        break
    }
}

Find-Directory -Name "Pictures" -Verbose
PowerShell

Function Structure (Begin, process, end)

<#

.SYNOPSIS
    Outputs list of found full paths of directories with specified Name

.PARAMETER Name
    The name of the directory to search for
        
#>

function Find-Directory {
    
    [CmdletBinding(SupportsShouldProcess=$True)]
     param(
        [Parameter(Mandatory=$True)]
        [String]$Name
    )

    # Executes once before PROCESS
    BEGIN {
        $List = [System.Collections.ArrayList]@()
    }

    # Executes for each pipeline input
    PROCESS {
    
        Get-ChildItem -Recurse -Directory -ErrorAction SilentlyContinue | 
            Where-Object { $_.Name -eq $Name } | 
            ForEach-Object {
                [void]$List.Add($_.FullName)
            }         
    }

    # Executes once after PROCESS
    END {
        foreach($Item in $List) {
            Write-Output $Item
        }
    }
}

Find-Directory -Name "config" -Verbose

PowerShell

Function Pipeline

You can pass objects from one cmdlet or function to another using the pipe character: |
In this way, the output of one function can become the input of another.
To make this accept pipeline input we can alter the script:

<#

.SYNOPSIS
    Outputs list of found full paths of directories with specified Name

.PARAMETER Name
    The name of the directory to search for
        
#>

function Find-Directory {
    
    [CmdletBinding(SupportsShouldProcess=$True)]
     param(
        [Parameter(Mandatory=$True, ValueFromPipeLine=$True)]
        [String[]]$Name
    )

    # Executes once before PROCESS
    BEGIN {
        $List = [System.Collections.ArrayList]@()
    }

    # Executes for each pipeline input
    PROCESS {
    
        Get-ChildItem -Recurse -Directory -ErrorAction SilentlyContinue | 
            Where-Object { $_.Name -eq $Name } | 
            ForEach-Object {
                [void]$List.Add($_.FullName)
            }         
    }

    # Executes once after PROCESS
    END {
        foreach($Item in $List) {
            Write-Output $Item
        }
    }
}

"logs", "config" | Find-Directory
PowerShell

Function Parameter Options

OptionDescription
MandatoryMakes the parameter mandatory ($true or $false)
PositionGive a parameter a position so you can omit explicit specification of parameters. Defaults to true.
ValueFromPipelineAccepts pipeline variables. Demonstrated in Function Pipeline.
ValueFromPipeLineByPropertyNameAccepts pipeline only when property name is set in the passed object.
ValueFromRemainingArgumentsCollects remaining parameters and stores them in this parameter name.
ParameterSetNameDefine a set of parameter names in a function. Only one set can be active at a time.
HelpMessageAdd a help message to a parameter.
DontShowHide parameter from both help and tab completion. You can still use the parameter. Set with $true or $false.

Positional Parameters

<#
.SYNOPSIS
    Find directory by Name and Path
    Path may be omitted, then default of C:\ is used
    Outputs each full path with directory name
#>

function Find-Directory {
    
    [CmdletBinding()]
     param(
        [Parameter(Mandatory=$True, Position=0)]
        [String]$Name,
        [Parameter(Mandatory=$False, Position=1)]
        [String]$Path
    )

    # Checks if parameter is given
    if (! $PSBoundParameters.ContainsKey('Path')) {
        $Path = "C:\"
    }
    
    Get-ChildItem -Recurse -Directory -Path $Path -ErrorAction SilentlyContinue | 
        Where-Object { $_.Name -eq $Name } | 
        ForEach-Object {
            Write-Host $_.FullName            
        }
}

Find-Directory "config" "C:\Users"
Find-Directory "bin"
PowerShell

Value From Remaining Arguments

# outputs: 1 2 3

function Find-Directory {
    
    [CmdletBinding()]
     param(
        [Parameter(Mandatory=$True)]
        [String]$Name,
        [Parameter(Mandatory=$False)]
        [String]$Path,
        [Parameter(ValueFromRemainingArguments=$True)]
        $Rest
    )
    
    Write-Host $Rest
}

Find-Directory "config" "C:\Users" "1" "2" "3"
PowerShell

PowerShell Modules

A PowerShell module has a different file extension: *.psm1
Each function of a Module needs to be explicitly exported, if you want to be able to call the function.
Modules will auto-load when inside a $env:PSModulePath directory and the *.psm1 filename must be the same as the directory name.
If you have altered the contents of an auto-loaded module, be sure to reload the PowerShell terminal window.

function Find-Directory {
    
    [CmdletBinding()]
     param(
        [Parameter(Mandatory=$True)]
        [String]$Name
    )
    
    # No implementation here
    Write-Host $Name
}

Export-ModuleMember -Function Find-Directory
PowerShell

A module cannot be used without importing it first: Import-Module Find-Directory

Function Documentation

Documentation page

<#
    
.SYNOPSIS	
    A brief description of the cmdlet or function.

.DESCRIPTION	
    A detailed explanation of what the cmdlet does.

.PARAMETER Name
    Describes a specific parameter and its purpose.

.PARAMETER AnotherParameterName
    Describes a specific parameter and its purpose.

.EXAMPLE	
    Provides usage examples with expected output.

.INPUTS	
    Specifies the types of objects that can be piped into the cmdlet.

.OUTPUTS 
    Defines the type of objects the cmdlet returns.

.NOTES	
    Additional information or special considerations.

.LINK	
    References related topics or external documentation.

.COMPONENT	
    Identifies the technology or feature the cmdlet is related to.

.ROLE	
    Specifies the user role relevant to the cmdlet.

.FUNCTIONALITY	
    Describes the cmdlet’s intended functionality.

.EXTERNALHELP	
    Links to an external help file instead of using comment-based help.

#>
PowerShell

Convenient Essentials

Approved Verbs

The official documentation recommends to use approved verbs.

 Get-Verb | Format-Wide -Column 6
PowerShell

Check PowerShell Module Paths

$env:PSModulePath.Split(";")
PowerShell

Global variables

$global:GlobalVarName = "Data"
PowerShell

Switch Case and Enums

Calling Test-Module will output Import chosen followed by Remove chosen.

enum ModuleInvocation {
    Import
    Remove
}

function Test-PS {

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)]
        [ModuleInvocation]$ModuleInvocation
    )

    switch($ModuleInvocation) {
        Import {
            Write-Host "Import chosen"
        }
        Remove {
            Write-Host "Remove chosen"
        }
    }
}

function Test-Module {   
    Test-PS -ModuleInvocation ([ModuleInvocation]::Import) 
    Test-PS -ModuleInvocation ([ModuleInvocation]::Remove) 
}

Export-ModuleMember -Function Test-PS
Export-ModuleMember -Function Test-Module
PowerShell

Check PowerShell version

$PSVersionTable.PSVersion
PowerShell

Load ps1 file using $PSScriptRoot

A *.ps1 file is not a module. You can use/access the contents (e.g. functions) of the ps1 file by just calling it once from another PowerShell file:

.$PSScriptRoot\Install_Winget.ps1
PowerShell

$PSScriptRoot is the absolute path to the current directory.
$PSCommandPath is the absolute path to the current file.

Collect, Check, Overwrite and Pass Function Parameters

function Test-PS {

    [CmdletBinding()]
    param(
        [string]$Path, # could as well be initialized directly offcourse
        [string]$Filter
    )

    $DefaultParams = @{
        Path = "C:\"
        Filter = "*.log"
    }

    if ($PSBoundParameters.ContainsKey('Path')) {
        $DefaultParams['Path'] = $Path
    }

    if ($PSBoundParameters.ContainsKey('Filter')) {
        $DefaultParams['Filter'] = $Filter
    }

    Get-ChildItem @DefaultParams
}

Export-ModuleMember -Function Test-PS
PowerShell

Common Data Types

[string][bool][float]@{ Variable = ”} # hash table
[char][decimal][datetime]@() # array, add with .Add()
[byte][double][<datatype>[]] # typed array[System.Collections.ArrayList]::new()
[int][array][regex]
[long][xml]

Typed Dictionary

$Dict = [System.Collections.Generic.Dictionary[string,int]]::new()
$Dict['x'] = 100
PowerShell

Escaping characters

Write-Host "This is a `"quote`" inside quotes."
# `n  = newline
# `r  = carriage return
# `t  = tab
PowerShell

Subexpression

To evaluate variables inline.

Write-Host "Today it is $((Get-Date).DayOfWeek)."  
PowerShell

ScriptBlock & Call operator

You can store PowerShell in a variable and invoke it later. It can also be passed remotely. A ScriptBlock can capture variables from its parent scope as well.

function Test-PS {

   $ScriptBlock = {
      param($name)
      Write-Host "Hello $($name)"
   }

   & $ScriptBlock "Bob" # Invoke with '&' call operator
   $ScriptBlock.Invoke("Mary") # Invoke
}

Export-ModuleMember -Function Test-PS
PowerShell

Classes

function Test-PS {

    $Person = [Person]::new("Bob", 40)
    Write-Host $Person # Outputs: Bob. age 40
}

Export-ModuleMember -Function Test-PS

class Person {
  [string]$FirstName
  [int]   $Age

  # Constructor
  Person([string]$fn, [int]$age) {
    $this.FirstName = $fn
    $this.Age       = $age
  }

  [string] ToString() {
    return "$($this.FirstName), age $($this.Age)"
  }
}
PowerShell

Custom Object

$Person = [PSCustomObject]@{
  Name = 'T'
  Age = 40
}
PowerShell

Hash Table

$Hata = @{
  Name = 'T'
  Age  = 40
}

$Hata['Name']   # "T"
$Hata.Age       # 40
$Hata['Default'] = 'default'  # add new key
PowerShell

Loosen Execution Policy

To loosen the execution policy:

Set-ExecutionPolicy -ExecutionPolicy Unrestricted
PowerShell

Boilerplate PowerShell Function

This script does nothing, but can be used as a quick boilerplate to write new functions.

<#
.SYNOPSIS
    Write here
.DESCRIPTION	
.PARAMETER Name
.EXAMPLE	
.INPUTS	
.OUTPUTS 
.NOTES	
.LINK	
.COMPONENT	
.ROLE	
.FUNCTIONALITY	
.EXTERNALHELP	
#>

function Select-FunctionName {

    [CmdletBinding(SupportsShouldProcess=$True)]
    param(
        [Parameter(Mandatory=$True)]
        [string]$Name,
        [Parameter(ValueFromRemainingArguments=$True)]
        $Remaining
    )

    BEGIN {

    }

    PROCESS {

        try {
            
        }
        catch {
            Write-Host $_.ScriptStackTrace
        }
        finally {
            
        }
    }

    END {

    }
}

Export-ModuleMember -Function Select-FunctionName
PowerShell

Conclusion

Usually, I don’t use PowerShell that often. Sometimes several months pass and I have to remember a lot of the basic syntax and practices. With this post I hope to give a reference to quickly refresh those basics I tend to forget. When creating functions, we should stick to the best practices and use the approved verbs for creating function names.

Related Posts