Search code examples
powershellclasstypespowershell-corepowershell-7.4

How to load the classes in PowerShell module globally?


I've already read this question and even though it has lots of back and forth, I tried my best to follow the suggestions, but still not sure how to work around this.

I have defined multiple classes in the ScriptsToProcess .ps1 file like this:

https://github.com/HotCakeX/Harden-Windows-Security/blob/WDACConfig-v0.3.4/WDACConfig/WDACConfig%20Module%20Files/Preloader.ps1

The problem is that when I install the module in a clean state VM (where the module never existed before) and try to use the cmdlets, I get an error saying the type is not found, but if I close and reopen PowerShell or use Import-Module with -Force parameter then everything is okay.

I need to know how I can work around this problem without introducing too many repetitive code.

I'm using PowerShell 7.5


Solution

  • I don't fully understand the VM-related problem, but perhaps the following approach bypasses the problem:

    Define your classes as part of your script's root module (*.psm1, referenced via the RootModule module-manifest entry) instead of your current approach of using a *.ps1 file that is loaded into the caller's scope via the ScriptsToProcess manifest entry.

    • In other words: you then won't need a ScriptsToProcess entry (at least not for exporting your classes and enums).

    • By itself, this makes your classes only available to callers that import the module with a parse-time using module statement, but note the following:

      • The linked documentation notes (emphasis added):

        [using module] doesn't consistently import classes or enumerations defined in nested modules or in scripts that are dot-sourced into the root module. Define classes and enumerations that you want to be available to users outside of the module directly in the root module.

      • Only the importer and its descendent scopes see the imported module's classes (and enums).

      • Because using module is a parse-time statement, the target module path must not contain variables, but modules discoverable via $env:PSModulePath can be referenced by name only (e.g. using module MyModule); relative paths are resolved against the caller's own file-system location (rather than against the current directory).

      • Once a module is imported with using module in a given session, it cannot be forcefully re-imported from inside a calling script; that is, if you want to modify your class and/or enum definitions and have those changes seen by importers, you'll need to:

        • either: Start a new session.

        • or: Force re-importing with Import-Module -Force from outside the calling script, such as at the command prompt in an interactive session.
          (Placing such a call inside the calling script, in addition to using module, takes effect only after the script has run; due to the parse-time nature of using module, a given run still uses the previous class and enum definitions.)


    However, the Exporting classes with type accelerators section of the about_Classes help topic describes a workaround that makes classes of your choice available session-globally, in all scopes and runspaces, and also works with module auto-loading and Import-Module:

    • The sample code below demonstrates this approach, but comes with the following caveats:

      • As with the using module-based approach, you won't be able to reload modified class definitions in a given session - start a new session instead.

      • Unlike with the using module-based approach (which you may still choose to combine with the suggested approach), the (possibly implicit) importer must not rely on the classes to be available at parse time, i.e. must not try to reference them via type literals in class definitions of its own - see this answer for details.

        • While the type-accelerator approach technically allows you to place the class and enum definitions in a nested module or a *.ps1 file dot-sourced from your root module, this is best avoided if you want your module to also support the using module importing technique for parse-time availability of the classes.
      • Because the suggested approach (potentially) exposes the classes to other runspaces too, they should be decorated with the [NoRunspaceAffinity() attribute, which is available in v7.4+ only. That said, the approach is safe to use in earlier versions as long as only a single runspace (the default runspace) is used.

      • The linked help topic also includes event-based code for attempting to remove exported class definitions when the module is unloaded (Remove-Module), however, this does not work (as of PowerShell 7.5.0), and has therefore been omitted in the code below.[1]

    Note that with either approach above, classes and enums won't be available until after your module has been imported into a given session. That is, unlike functions and cmdlets in auto-loading modules, they aren't discoverable prior to import.

    Example *.psm1 root-module content:

    # Since the quasi-exported class will be available *process-wide*
    # and therefore also in *other runspaces*, be sure to define it with
    # the [NoRunspaceAffinity()] attribute.
    # Caveat: **v7.4+ only**
    [NoRunspaceAffinity()]
    class SomeClass { # Note: 'SomeClass' is both its .Name and .FullName.
      [int] Get() { return 42 }
    }
    
    # Define the types to export with type accelerators.
    # Note: Unlike the `using module` approach, this approach allows
    #       you to *selectively* export `class`es and `enum`s.
    $exportableTypes = @(
      [SomeClass]
    )
    
    # Get the non-public TypeAccelerators class for defining new accelerators.
    $typeAcceleratorsClass = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
    
    # Add type accelerators for every exportable type.
    $existingTypeAccelerators = $typeAcceleratorsClass::Get
    foreach ($type in $exportableTypes) {
      # !! $TypeAcceleratorsClass::Add() quietly ignores attempts to redefine existing
      # !! accelerators with different target types, so we check explicitly.
      $existing = $existingTypeAccelerators[$type.FullName]
      if ($null -ne $existing -and $existing -ne $type) {
        throw "Unable to register type accelerator [$($type.FullName)], because it is already defined with a different type ([$existing])."
      }
      $typeAcceleratorsClass::Add($type.FullName, $type)
    }
    

    Any (possibly implicit) importer of the module associated with this *.psm1 will have the [SomeClass] class available to them at runtime.

    That is, [SomeClass]::new().Get() in the caller's scope and any scope thereafter should yield 42


    [1] The removal of type accelerators is ineffective: even though calling $typeAcceleratorsClass::Remove() ostensibly succeeds and even though $typeAcceleratorsClass::Get no longer lists them, they effectively linger in the current session, as of PowerShell 7.5.0. Similarly, trying to forcefully redefine the type accelerators on import doesn't work either: it fails quietly and previous definitions remain in effect.