Exposing PowerShell Module Scope
In the previous article, I demonstrated how to take advantage of the module scope. This is what allows you to have functions and variables available to commands in your module, but not exposed to the user. I take advantage of this in almost all of my PowerShell modules. To make it clear in my code, I make sure my internal functions use a non-standard name. I haven't worried too much about the variable name. I typically call it what I need it to be. But, I can see a benefit in adding a prefix or some indication to the variable name to make it clear that it is an internal variable.
[version]$__Version = '1.0.0'
I opted for the double underscore so I wouldn't mistake it for the pipelined variable $_
Then, in my functions, I can reference this variable.
Write-Verbose "Running module version $__version"
If I see a variable in my session with a double underscore, I know it is an internal variable from a module and I've made a mistake somewhere.
Regardless, it wouldn't hurt to add documentation in your commands to make it clear you are referencing an internal variable, including where it is defined.
There is also a technique you can use to expose the internal members of your module from your PowerShell session. That's what I want to show you today.
Module Trickery
This entire discussion thread started with something I saw on Mastodon a while ago. I had never seen this trick and was fascinated. And to be honest, I'm not sure I have the developer skills to adequately explain what is happening. But, I can show you how to use it.
First, you need a module loaded into your session. I'm going to use this test module.
# PSFoo.psm1
#this is a private module scoped variable
$__fooVar = 100
#this is a private function
Function _getSecret {
[cmdletbinding()]
Param([string]$string)
Write-Verbose "Starting $($MyInvocation.MyCommand)"
$n = $string.ToCharArray()
#$__foovar is an internal module variable
for ($i = 0; $i -lt $__fooVar; $i++) {
$n = $n | Get-Random -Count $n.count
}
$n -join ''
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
#this is a public function
Function Get-PSFoo {
[cmdletBinding()]
[alias('psf')]
Param(
[string]$Text = "$env:ComputerName $env:UserName"
)
Write-Verbose "Starting $($MyInvocation.MyCommand)"
$space = ';',':','<','?','-','_' | Get-Random -count 1
#replace all whitespace with a random character
$Text = $Text -replace '\s',$space
Write-Verbose "Running PSFoo on $Text using a value of $__fooVar"
_getSecret -string $Text
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
Export-ModuleMember -Function Get-PSFoo -Alias psf
Here's the magic part.
&(Get-Module PSFoo) { Get-Command -Module PSFoo -All}
Invoking the module this way appears to let you run the script block in the module scope. Look at what I get as a result:

I know that the _getSecret
function is private based on the name. There's no way to tell from looking at the command otherwise. As far as PowerShell is concerned, this is a public function. I suppose I could always do a comparison.
$ModuleFunctions = Get-Command -Module PSFoo -CommandType Function
&(Get-Module PSFoo) { Get-Command -Module PSFoo -CommandType Function} |
Where-Object {$ModuleFunctions.Name -notmatch $_.Name} |
Select-Object Name
This gives me the expected result.
Name
----
_getSecret
You could even invoke the helper function.
PS C:\> &(Get-Module PSFoo) { Get-Command _getsecret -Syntax }
_getSecret [[-string] ] []
Once you know the syntax:
PS C:\> &(Get-Module PSFoo) { _getsecret "Foo-Bar Bunny!"}
aF-yBBoou !rnn
Not every helper function will work as a stand-alone function.
Variables are little more difficult. I know the internal variable name so I can use the module peeking trick.
PS C:\> &(Get-Module PSFoo) { Get-Variable __FooVar | Select-Object *}
Name : __fooVar
Description :
Value : 100
Visibility : Public
Module :
ModuleName :
Options : None
Attributes : {}
If I merely run Get-Variable
, I'll get all variables. Specifying scope helps, but it will include variables defined outside of the module.
PS C:\> &(Get-Module PSFoo) { Get-Variable -scope script }
Name Value
---- -----
__fooVar 100
args {}
Error {}
false False
MyInvocation System.Management.Automation.InvocationInfo
null
PSCommandPath D:\OneDrive\behind\2024\module-scope\psfoo.psm1
PSDefaultParameterValues {}
PSScriptRoot D:\OneDrive\behind\2024\module-scope
true True
I can probably make a good guess,. However, I could compare variable sets.
$globalVar = Get-Variable -Scope Global
&(Get-Module PSFoo) { Get-Variable | Where {$globalVar.name -NotContains $_.Name -AND $_.Name -ne 'globalVar'} }
It is a little tedious because I need to filter out the list variable. But it works.
PS C:\> &(Get-Module PSFoo) { Get-Variable | Where {$globalVar.name -NotContains $_.Name -AND $_.Name -ne 'globalVar'} }
Name Value
---- -----
__fooVar 100
This technique should work for any loaded module.
PS C:\> &(Get-Module PSProjectStatus) { $jsonschema }
https://raw.githubusercontent.com/jdhitsolutions/PSProjectStatus/main/psproject.schema.json
The PSProjectStatus module is already loaded into my session. I know that $jsonschema
is an internal variable.
This module also has internally defined PowerShell classes. I can't use the class definitions or enums outside of the module.
PS C:\> [PSProject]::New()
InvalidOperation: Unable to find type [PSProject].
But this will work.
&(Get-Module PSProjectStatus) { [PSProject]::New() }

Or this:
PS C:\> &(Get-Module PSProjectStatus) { [PSProjectStatus]::GetNames([PSProjectStatus]) }
Development
Updating
Stable
AlphaTesting
BetaTesting
ReleaseCandidate
Patching
UnitTesting
AcceptanceTesting
Other
Archive
Of course, I can also get all commands from the module.
PS C:\> &(Get-Module PSProjectStatus) { Get-Command -CommandType function -Module 'PSProjectStatus' } | Sort Name | Select Name
Name
----
_getLastCommitDate
_getLastPushDate
_getRemote
_getStatusEnum
_verbose
Get-PSProjectGitStatus
Get-PSProjectReport
Get-PSProjectStatus
Get-PSProjectTask
New-PSProjectStatus
New-PSProjectTask
Remove-PSProjectTask
Set-PSProjectStatus
Update-PSProjectStatus
As long as I use a non-standard name for my private helper functions, I should be able to discover them.
PS C:\> &(Get-Module PSProjectStatus) { Get-Command -CommandType function -module PSProjectStatus | where name -NotMatch '\-' }
CommandType Name Version Source
----------- ---- ------- ------
Function _getLastCommitDate 0.14.1 PSProjectStatus
Function _getLastPushDate 0.14.1 PSProjectStatus
Function _getRemote 0.14.1 PSProjectStatus
Function _getStatusEnum 0.14.1 PSProjectStatus
Function _verbose 0.14.1 PSProjectStatus
Show-Module
To wrap up, why not build some PowerShell tooling around this concept. I wrote a function that will write a custom object to the pipeline showing all module details.
#requires -version 5.1
Function Show-Module {
[cmdletbinding()]
Param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'Specify the name of a module. It will be imported if not already loaded.'
)]
[ValidateNotNullOrEmpty()]
[Alias('Name')]
[string]$ModuleName
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
$global:gvGlobal = (Get-Variable -Scope Global).Name
} #begin
Process {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $ModuleName"
#Import the module if not already loaded
If (-Not (Get-Module -Name $ModuleName)) {
Try {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Importing module $ModuleName"
Import-Module $ModuleName -ErrorAction Stop
}
Catch {
Write-Warning "Unable to import module $ModuleName. $($_.Exception.Message)"
}
}
$cmd = "Get-Command -module $ModuleName -All"
$sb = [scriptblock]::Create($cmd)
#define a scriptblock to get the internal function information
$internalCmd = @"
`$exported = (Get-Module $ModuleName).ExportedFunctions.keys
Get-Command -Module $ModuleName -CommandType Function | Where-Object { `$_.Name -NotIn `$exported }
"@
$internalSB = [scriptblock]::Create($internalCmd)
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Getting internal module information"
$cmds = &(Get-Module -Name $ModuleName ) $sb
#create a custom object
#TODO: Detect private classes ?
$tempHash = [ordered]@{
PSTypeName = 'PSModuleInfo'
Module = $ModuleName
Version = $cmds[0].Version
ModulePath = (Get-Module $ModuleName).Path
Aliases = $cmds.Where({ $_.CommandType -eq 'Alias' }) | Sort-Object -Property Name
AllCommands = $cmds.Where({ $_.CommandType -match 'Function|Cmdlet' }) | Sort-Object -Property Name
InternalFunctions = &(Get-Module $ModuleName) $internalSB
Variables = &(Get-Module $ModuleName) { (Get-Variable).where({ ($global:gvGlobal -NotContains $_.name) -AND ($_.name -ne 'gvGlobal') }) } | Sort-Object -Property Name
}
#write the custom object to the pipeline
New-Object -TypeName PSObject -Property $tempHash
} #process
End {
Remove-Variable -Name gvGlobal -Scope Global
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
} #close Show-Module
The function uses the techniques I've been demonstrating to get internal commands and variables.
PS C:\> Show-Module psfoo | tee -Variable m
Module : psfoo
Version : 0.0
ModulePath : D:\OneDrive\behind\2024\module-scope\psfoo.psm1
Aliases : psf
AllCommands : {_getSecret, Get-PSFoo}
InternalFunctions : _getSecret
Variables : System.Management.Automation.PSVariable
PS C:\> $m.Variables
Name Value
---- -----
__fooVar 100
This should work for any script-based module.
PS C:\> Show-Module BurntToast | tee -Variable m
Module : BurntToast
Version : 0.8.5
ModulePath : C:\Program Files\WindowsPowerShell\Modules\burnttoast\0.8.5\BurntToast.psm1
Aliases : {ShoulderTap, Toast}
AllCommands : {Get-BTHistory, New-BTAction, New-BTAppId, New-BTAudio…}
InternalFunctions : {Optimize-BTImageSource, Test-BTAudioPath}
Variables : {System.Management.Automation.PSVariable, System.Management.Automation.PSVariable,
System.Management.Automation.PSVariable, System.Management.Automation.PSVariable…}
PS C:\> $m.InternalFunctions[0].Definition
param (
[Parameter(Mandatory)]
[String] $Source,
[Switch] $ForceRefresh
)
if ([bool]([System.Uri]$Source).IsUnc -or $Source -like 'http?://*') {
$RemoteFileName = $Source -replace '/|:|\\', '-'
$NewFilePath = '{0}\{1}' -f $Env:TEMP, $RemoteFileName
if (!(Test-Path -Path $NewFilePath) -or $ForceRefresh) {
if ([bool]([System.Uri]$Source).IsUnc) {
Copy-Item -Path $Source -Destination $NewFilePath -Force
} else {
Invoke-WebRequest -Uri $Source -OutFile $NewFilePath
}
}
$NewFilePath
} else {
try {
(Get-Item -Path $Source -ErrorAction Stop).FullName
} catch {
Write-Warning -Message "The image source '$Source' doesn't exist, failed back to icon."
}
}
This makes for a fun discover tool from the command prompt.
Summary
Diving this deep into a module is probably going to be an exception rather than the rule. You should be able to test with standard commands like Get-Module
and Get-Command
to ensure your modules are exporting the proper items. However, you might want to build tooling with the ideas from this article if you want to automate the validation process, perhaps in a Pester test.
I'd love to get your feedback on this one.