TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Typed, Discoverable Output in PowerShell: PSTypeName and Default Views That Make Tools Work for You

You can make PowerShell work harder for you by emitting typed, discoverable objects whose default views are clean and consistent. Instead of guessing which properties to show or fighting with wide Format-Table outputs, you add a PSTypeName, keep your field names stable, and register a default display so tooling and reviewers immediately see what matters. In this post, you’ll learn why that matters and exactly how to implement it in your scripts and modules.

Why Typed, Discoverable Output Matters

PowerShell loves objects. But if your functions return anonymous PSCustomObject shapes without a recognizable type, downstream tools can’t easily pick the right view, and humans won’t know which fields to look at. Adding a PSTypeName makes your objects first-class citizens in PowerShell’s formatting and discovery system:

  • Discoverable: Get-Member shows a clear type name, and other tools can key off it.
  • Cleaner tables by default: A default display set turns noisy outputs into readable tables without manual formatting.
  • Consistent reviews: Code reviews and PR checks see stable, predictable fields and ordering.
  • Better automation: Your CI logs, reports, and JSON exports become structured and machine-friendly.

Implementing PSTypeName and Default Views

Give your objects a type

You can assign a type name at creation time by adding the special PSTypeName property to a PSCustomObject. This is the simplest and most portable way to stamp your output:

$tn = 'Acme.Report'

function Get-Report {
  [CmdletBinding()]
  param([string]$Name = 'Daily')

  $start = Get-Date
  Start-Sleep -Milliseconds 80

  [pscustomobject]@{
    PSTypeName  = $tn
    Name        = $Name
    Items       = 42
    Started     = $start.ToUniversalTime().ToString('o')  # ISO 8601 UTC
    DurationSec = [int]((Get-Date) - $start).TotalSeconds
  }
}

# The object will carry Acme.Report as its primary type
$r = Get-Report -Name 'Inventory'
$r.PSObject.TypeNames[0]  # 'Acme.Report'

Alternatively, you can insert the type name after creation if you need to retroactively tag an object:

$o = [pscustomobject]@{ Name='Daily'; Items=42 }
$o.PSObject.TypeNames.Insert(0, 'Acme.Report')

Tip: Use a vendor or team prefix (e.g., Acme.Report) to avoid collisions with other modules. If you anticipate versioned shapes, insert a versioned type name first but keep the stable type in the list so older tools still recognize it:

$o.PSObject.TypeNames.Insert(0, 'Acme.Report.v2')
$o.PSObject.TypeNames.Add('Acme.Report')    # backward compatibility

Register a default display property set

With a unique type name, you can register formatting metadata so tables are clean by default. The quickest approach in scripts is Update-TypeData:

$tn = 'Acme.Report'
Update-TypeData -TypeName $tn -DefaultDisplayPropertySet Name,Items,DurationSec -Force

Get-Report -Name 'Inventory' | Format-Table -AutoSize

Now, when you pipe your object into a table without specifying properties, PowerShell chooses Name, Items, and DurationSec. That keeps output readable even in narrow terminals or CI logs.

Package it in a module (Types.ps1xml)

For reusable modules, declare the default view in a Types.ps1xml file and ship it with your module so the view loads automatically on import. Here’s the minimal shape:

<?xml version='1.0' encoding='utf-8'?>
<Configuration>
  <Types>
    <Type>
      <Name>Acme.Report</Name>
      <Members>
        <MemberSet>
          <Name>PSStandardMembers</Name>
          <Members>
            <PropertySet>
              <Name>DefaultDisplayPropertySet</Name>
              <ReferencedProperties>
                <Name>Name</Name>
                <Name>Items</Name>
                <Name>DurationSec</Name>
              </ReferencedProperties>
            </PropertySet>
          </Members>
        </MemberSet>
      </Members>
    </Type>
  </Types>
</Configuration>

In your module manifest (.psd1):

@{
  RootModule        = 'Acme.Reporting.psm1'
  TypesToProcess    = @('Acme.Reporting.Types.ps1xml')
  FormatsToProcess  = @()  # optional if you add custom Format.ps1xml views too
}

If you need highly customized table or list views (alignment, labels, conditional expressions), add a companion Format.ps1xml and list it in FormatsToProcess. For many scenarios, DefaultDisplayPropertySet is enough.

Keep Outputs Stable and Tool-Friendly

Treat output as a contract

Once other scripts or pipelines consume your objects, changes to property names or types can break them. Follow these practices:

  • Keep field names stable: Don’t rename public-facing properties. If you must, keep the old property and add a new one instead.
  • Use machine-friendly values: Emit unobstructed values (e.g., numeric durations, ISO 8601 UTC timestamps, booleans) and let views handle prettiness.
  • Prefer additive changes: Add new properties at the end; avoid changing existing semantics.
  • Version via type names: Insert a versioned type first but keep a stable base type in the TypeNames array for backward compatibility.

Here’s an updated Get-Report that illustrates these conventions end-to-end:

$tn = 'Acme.Report'
Update-TypeData -TypeName $tn -DefaultDisplayPropertySet Name,Items,DurationSec -Force

function Get-Report {
  [CmdletBinding()]
  param([Parameter()][ValidateNotNullOrEmpty()][string]$Name = 'Daily')

  $start = Get-Date
  Start-Sleep -Milliseconds 80

  [pscustomobject]@{
    PSTypeName   = $tn
    Name         = $Name
    Items        = 42
    Started      = $start.ToUniversalTime().ToString('o')
    DurationSec  = [int]((Get-Date) - $start).TotalSeconds
    # If you add properties later, append them here and keep the defaults stable
  }
}

# Human-friendly by default; no need to specify properties
Get-Report -Name 'Inventory' | Format-Table -AutoSize

# Machine-friendly when exporting
Get-Report | ConvertTo-Json -Depth 3

Formatting discipline: objects in, formatting at the edge

  • Do not call Format-Table in your functions. Emitting formatting directives breaks downstream consumers that expect objects. Return raw objects; format only in interactive sessions or just before display.
  • Use DefaultDisplayPropertySet to keep command-line output clean without forcing formatting on callers.
  • Discoverability aids: Get-Member should show your custom type first; Get-FormatData -TypeName Acme.Report reveals the active view; Format-List -Property * remains your debugging friend.

Automated tests to lock the contract

Add Pester tests that verify types, properties, and views. This keeps your output stable across refactors and PowerShell versions:

# Acme.Reporting.Tests.ps1
Describe 'Get-Report output contract' {
  It 'emits the expected type name first' {
    $r = Get-Report
    $r.PSObject.TypeNames[0] | Should -Be 'Acme.Report'
  }

  It 'provides stable properties with expected types' {
    $r = Get-Report -Name 'Daily'
    $r | Should -HaveProperty 'Name'
    $r | Should -HaveProperty 'Items'
    $r | Should -HaveProperty 'Started'
    $r | Should -HaveProperty 'DurationSec'

    $r.Items.GetType().Name     | Should -Be 'Int32'
    $r.DurationSec.GetType().Name | Should -Be 'Int32'
  }

  It 'has a default display set for clean tables' {
    $td = Get-TypeData -TypeName 'Acme.Report'
    $td.DefaultDisplayPropertySet | Should -Contain 'Name'
    $td.DefaultDisplayPropertySet | Should -Contain 'Items'
    $td.DefaultDisplayPropertySet | Should -Contain 'DurationSec'
  }
}

Real-world tips and gotchas

  • Be explicit with types: Cast numbers to [int]/[double] and times to ISO 8601 strings or [datetime] for consistency. Avoid localized strings in data fields.
  • Separate data from presentation: Store raw seconds or bytes; add FriendlyDuration or SizeMB later if needed, but don’t replace the raw fields.
  • Avoid type name churn: Renaming the primary PSTypeName breaks views and tools. If unavoidable, insert the new type first and keep the old one as a fallback in TypeNames.
  • Bundle type data in modules: Update-TypeData is session-scoped. For reusable tooling, ship Types.ps1xml/Format.ps1xml and load them via the module manifest so all users get consistent views.
  • Security best practice: Don’t embed secrets or tokens in object properties that could show up in default views. Emit identifiers and look up sensitive values on demand.
  • Performance: Favor fast, stable properties in the default view. If a property is expensive to compute, keep it off the default set so casual listing stays snappy.

By adding a PSTypeName, registering a DefaultDisplayPropertySet, and treating your output as a contract, you get discoverable objects, cleaner tables, consistent views, and easier reviews—without sacrificing machine-friendliness. Make your outputs work for you, not against you.

If you want to go deeper into patterns like these—typed objects, formatting data, and production-ready scripting—explore advanced PowerShell resources and cookbooks to level up your automation.

← All Posts Home →