Chapter 15Lesson 04~195 minutes

PowerShell Classes, Enums, using Statements, and .NET Interoperability

Choose between PSCustomObject, enums, PowerShell classes, using statements, direct .NET calls, and Add-Type while keeping type/version/security boundaries explicit.

Classes.NETusingInterop

Learning objectives

  • Choose when functions/PSCustomObject are sufficient and when a class or enum helps.
  • Define PowerShell enums, properties, constructors, and methods.
  • Understand using module/namespace/assembly and their parse-time implications.
  • Call .NET static and instance members from PowerShell.
  • Use type accelerators without hiding important type meaning.
  • Treat Add-Type as a trusted interoperability boundary with maintenance/security costs.

1. Functions and PSCustomObject remain the default for much automation

PowerShell already gives you excellent tools for automation: functions define behavior and PSCustomObject defines lightweight structured data. Do not introduce a class merely because classes exist.

A class becomes useful when a domain concept benefits from a named type, constructors, methods, stronger property types, or invariants that should travel with the object. An enum is useful when a value must come from a small named set rather than arbitrary strings.

2. Start with a lightweight object and feel the tradeoff

$result = [pscustomobject]@{
    PSTypeName = 'DevOpsAcademy.EndpointResult'
    Name       = 'local-health'
    Uri        = [uri]'https://localhost:8443/health'
    State      = 'Healthy'
    StatusCode = 200
}

$result | Get-Member
$result

This is flexible and pipeline-friendly. But nothing prevents State='banana' or StatusCode='oops' unless the function that creates the object validates those values.

3. Enums replace magic strings with a finite vocabulary

An enum gives names to a fixed set of values. This improves autocomplete, comparison, and validation when the domain really is finite.

enum DaHealthState {
    Unknown
    Healthy
    Degraded
    Failed
}

$state = [DaHealthState]::Healthy
$state
$state -eq [DaHealthState]::Healthy
[enum]::GetNames([DaHealthState])

4. A PowerShell class combines typed state with behavior

class DaEndpointResult {
    [string]$Name
    [uri]$Uri
    [DaHealthState]$State = [DaHealthState]::Unknown
    [int]$StatusCode
    [datetime]$CheckedUtc

    DaEndpointResult([string]$name,[uri]$uri) {
        if ([string]::IsNullOrWhiteSpace($name)) {
            throw 'Name must not be empty.'
        }
        $this.Name = $name
        $this.Uri = $uri
        $this.CheckedUtc = [datetime]::UtcNow
    }

    [string] Summary() {
        return '{0}: {1} ({2})' -f $this.Name,$this.State,$this.StatusCode
    }
}

$typed = [DaEndpointResult]::new('local-health',[uri]'https://localhost/health')
$typed.State = [DaHealthState]::Healthy
$typed.StatusCode = 200
$typed.Summary()

$this refers to the current instance. The constructor establishes required state at creation time, and the typed properties reject values that cannot be converted to their declared types.

5. Inheritance is available, but composition is often simpler

PowerShell classes can inherit from another class, but automation modules rarely need deep inheritance trees. Use inheritance when there is a genuine “is-a” relationship and the base contract is stable; otherwise prefer plain objects or composition.

class DaTimedEndpointResult : DaEndpointResult {
    [long]$DurationMs

    DaTimedEndpointResult([string]$name,[uri]$uri) : base($name,$uri) {
        $this.DurationMs = 0
    }
}

The goal is a clearer domain model, not an object-oriented architecture for its own sake.

6. using statements affect parsing, not just runtime import

The using statement is processed when PowerShell parses a script. It must appear before normal executable statements in the script. This timing matters especially for classes: Import-Module imports functions/aliases/variables, but it does not make module-defined classes available to the parser in the same way. Use using module when a script needs classes or enums defined by a module.

# Example at the top of a .ps1 script file:
using namespace System.Net
# using module './DevOpsAcademy.Tools/DevOpsAcademy.Tools.psd1'
# using assembly './lib/Contoso.Interop.dll'

$address = [IPAddress]::Parse('127.0.0.1')
$address.AddressFamily

using namespace shortens type names; using module can expose module-defined classes/enums at parse time; using assembly loads an assembly for types the script needs. Keep these dependencies explicit and reviewable.

7. Classes exported by modules have a different consumption model

Microsoft's current class guidance is explicit: classes and enums defined in a module are not imported by a normal Import-Module in the same way as functions. If consumers need those types in script syntax, define the public types directly in the root module and use using module.

This creates tighter version coupling than a simple object contract. A module that emits ordinary objects with stable properties can often evolve more easily across remoting, jobs, serialization, and CI boundaries.

8. PowerShell can call .NET static members directly

PowerShell runs on .NET, so you can call framework types when a native cmdlet does not provide the capability you need. A static member belongs to the type itself and uses the :: operator.

$temp = [System.IO.Path]::GetTempPath()
$machine = [System.Environment]::MachineName
$runtime = [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription

[pscustomobject]@{
    TempPath = $temp
    Machine  = $machine
    Runtime  = $runtime
}

Prefer PowerShell cmdlets when they express the task clearly because cmdlets integrate with streams, common parameters, providers, and discoverability. Reach into .NET when it adds a capability or precision you actually need.

9. Instance methods operate on one object

After you create or receive a .NET object, dot notation accesses its properties and instance methods.

$uri = [uri]'https://example.com:443/api/health?verbose=true'

[pscustomobject]@{
    Scheme = $uri.Scheme
    Host   = $uri.Host
    Port   = $uri.Port
    Path   = $uri.AbsolutePath
    IsDefaultPort = $uri.IsDefaultPort
}

$uri.GetLeftPart([System.UriPartial]::Authority)

10. Type accelerators are convenient aliases for common .NET types

PowerShell provides short forms such as [uri], [xml], [regex], [datetime], and [hashtable]. They improve readability when the shortened type is familiar to PowerShell users.

[uri]'https://example.com'
[datetime]'2026-08-11T12:00:00Z'
[regex]'^[a-z][a-z0-9-]+$'

# When clarity matters, the full type name is always valid.
[System.Text.RegularExpressions.Regex]'^[a-z][a-z0-9-]+$' 

Do not invent undocumented accelerators in public code. If a reader cannot tell what a type is, the full .NET name is often clearer.

11. Add-Type is powerful because it compiles or loads code into your process

Add-Type can compile source or load an assembly so PowerShell can call types that are not otherwise available. Treat it as an advanced interop boundary: loaded code executes in your PowerShell process and can access everything that process can access.

# Safe, fixed training source. Never compile untrusted text this way.
if (-not ('DevOpsAcademy.Interop.Clamp' -as [type])) {
    Add-Type -TypeDefinition @'
namespace DevOpsAcademy.Interop {
    public static class Clamp {
        public static int ToRange(int value, int min, int max) {
            if (value < min) return min;
            if (value > max) return max;
            return value;
        }
    }
}
'@
}

[DevOpsAcademy.Interop.Clamp]::ToRange(150,0,100)

Do not build Add-Type source from user input, downloaded snippets, or configuration text. For substantial interop code, a separately built, tested, signed/versioned assembly is usually easier to review and maintain.

12. Choose the lightest model that protects the contract

ModelGood fitTradeoff
PSCustomObjectPipeline/report records, API-shaped data, serialization-friendly outputValidation/invariants live outside the object unless you add custom logic.
Functions + objectsMost DevOps commands and reusable automationBehavior and data are separate, which is often desirable.
EnumFinite named states such as Healthy/FailedChanging enum values can be a breaking contract.
PowerShell classDomain model with constructors, methods, typed stateTighter type/version/parse-time coupling.
Direct .NETMissing cmdlet capability or precise framework APILess PowerShell-native discoverability; platform/API availability matters.
Add-Type / custom assemblySpecialized interop or performance/native library boundaryHighest build/security/maintenance burden in this set.

13. Lab: compare class and object output side by side

$plain = [pscustomobject]@{
    Name='api'; Uri=[uri]'https://localhost/api'; State='Healthy'; StatusCode=200
}

$typed = [DaEndpointResult]::new('api',[uri]'https://localhost/api')
$typed.State = [DaHealthState]::Healthy
$typed.StatusCode = 200

[pscustomobject]@{
    PlainType  = $plain.GetType().FullName
    TypedType  = $typed.GetType().FullName
    PlainState = $plain.State
    TypedState = $typed.State.ToString()
    Summary    = $typed.Summary()
}

Then serialize both with ConvertTo-Json or export them through remoting in a lab environment. Observe that serialization emphasizes data properties; live methods are process/type behavior and do not magically remain available across every boundary.

14. Verification checklist

  • You can justify when a PSCustomObject/function is enough.
  • You can define and use a small enum and class.
  • You understand constructor, property, method, and $this semantics.
  • You know using statements are processed at parse time.
  • You know module classes require a different consumption pattern from ordinary exported functions.
  • You can call .NET static and instance members intentionally.
  • You treat Add-Type as trusted code loading/compilation, not a text-evaluation shortcut.

15. Knowledge check

Question 1. When is PSCustomObject often preferable to a class?

Question 2. What problem does an enum solve?

Question 3. Why must using statements be near the top of a script?

Question 4. Does Import-Module alone make module-defined classes available to parser type syntax?

Question 5. Why is Add-Type security-sensitive?

16. Summary and next bridge

PowerShell's object model scales from flexible PSCustomObject records to enums, classes, and direct .NET types. The strongest design is usually the lightest model that protects your real contract. Classes and using module add parse-time/type coupling, while .NET and Add-Type add runtime capability plus maintenance/security responsibilities.

Lesson 5 completes the chapter by turning the module into a package-management problem: where resources come from, how versions are pinned and installed, and how supply-chain trust is separated from convenience.

17. Authoritative references

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.