Chapter 05Lesson 03~120 minutes

PSCustomObject and Designing Useful Pipeline Objects

Design lightweight record-shaped objects with stable properties and types, transform raw inputs before reuse, tag semantic type names, and keep data output separate from host presentation.

Learning objectives

  • Choose between hashtable configuration state and PSCustomObject record-shaped pipeline data.
  • Create custom objects, access and inspect properties, and understand default display as a view rather than the schema.
  • Add or remove properties when transforming objects and recognize when direct construction is cleaner.
  • Use a custom PSTypeName as a lightweight semantic contract without confusing it with a compiled class.
  • Normalize raw data into typed objects before filtering, sorting, or exporting it.
  • Return structured objects from reusable automation instead of replacing data with Write-Host strings.

1. Configuration maps and pipeline records solve different problems

A hashtable is excellent when code asks “what value belongs to this key?” A report row has a different job: it represents one thing with named properties that downstream commands should inspect, filter, sort, and export. PSCustomObject is PowerShell’s lightweight way to create that record-shaped data.

$configuration = @{
    Region = 'eu-west'
    Retries = 3
}

$record = [pscustomobject]@{
    Name = 'api-01'
    Environment = 'staging'
    Healthy = $true
}

$configuration.GetType().FullName
$record.GetType().FullName

The data can look visually similar when printed, but the intended contracts differ: a hashtable is a dictionary; the custom object is a record with properties.

2. [pscustomobject]@{} turns named values into object properties

The [pscustomobject] cast in front of a hashtable literal creates an object whose keys become properties. This gives familiar member access and makes the object cooperate naturally with the object pipeline.

$server = [pscustomobject]@{
    Name = 'api-01'
    Environment = 'staging'
    Port = 8443
    Healthy = $true
}

$server.Name
$server.Port
$server | Get-Member

Property names describe the record schema. Keep them stable when other functions, exports, tests, or dashboards depend on them.

3. Display is a view; the object may contain more properties than the table shows

Chapter 03 established that formatting is not the object itself. The same rule applies to custom objects. Select-Object -Property * creates an object containing selected properties; Format-List * is only a human-facing view at the end of a pipeline.

$server = [pscustomobject]@{
    Name = 'api-01'
    Environment = 'staging'
    Port = 8443
    Healthy = $true
    LastChecked = Get-Date
}

$server | Select-Object -Property *
$server | Format-List *

When you are designing data for reuse, inspect the properties with Get-Member or PSObject.Properties, not only the default terminal columns.

4. Add or remove properties when evolving an in-memory record

Custom objects can be extended after creation. Add-Member adds a note property, and the intrinsic PSObject.Properties collection can remove one. For stable production contracts, prefer constructing the intended shape directly; runtime mutation is most useful during transformation or exploratory tooling.

$server = [pscustomobject]@{
    Name = 'api-01'
    Healthy = $true
}

$server | Add-Member -MemberType NoteProperty -Name Owner -Value 'platform'
$server.Owner

$server.PSObject.Properties.Remove('Owner')
$server | Select-Object -Property *

A NoteProperty is simply a property whose value is stored directly on the PowerShell object wrapper. You do not need to understand the entire Extended Type System to use it productively.

5. A custom PSTypeName gives tooling a stable semantic label

Two custom objects can have the same properties but represent different concepts. PowerShell supports custom type-name tags through its Extended Type System. A PSTypeName can help formatting definitions, validation, documentation, and team tooling identify what a record is meant to represent.

$server = [pscustomobject]@{
    PSTypeName = 'DevOpsAcademy.ServerInventory'
    Name = 'api-01'
    Environment = 'staging'
    Healthy = $true
}

$server.PSTypeNames | Select-Object -First 3

This does not create a compiled .NET class. It adds semantic type information to the PowerShell object. Chapter 15 will cover PowerShell classes when a stronger type definition is justified.

6. Convert raw input into clean records before the rest of the pipeline

A strong automation boundary separates messy input from clean internal objects. Normalize strings, convert types, validate required fields, and then emit one well-shaped object per record. Later pipeline stages can work with properties instead of reparsing text.

$raw = @(
    'api-01,staging,8443,true'
    'worker-01,production,9000,false'
)

$inventory = foreach ($line in $raw) {
    $name, $environment, $portText, $healthyText = $line -split ',', 4

    [pscustomobject]@{
        PSTypeName = 'DevOpsAcademy.ServerInventory'
        Name = $name.Trim()
        Environment = $environment.Trim()
        Port = [int]$portText
        Healthy = [bool]::Parse($healthyText)
    }
}

$inventory | Where-Object Healthy | Select-Object Name, Environment, Port

CSV cmdlets are preferable for real CSV data and arrive in Chapter 11; the small split here exists only to make the transformation boundary visible.

7. Write-Host communicates with a human; emitting objects communicates with automation

Write-Host writes host-oriented information. It is useful for deliberate user-interface messages, but a string printed to the host is not a substitute for returning structured records from reusable code. If a caller needs to sort by port, export to CSV, or test Healthy, emit an object.

function Get-DemoInventoryRecord {
    [pscustomobject]@{
        Name = 'api-01'
        Port = 8443
        Healthy = $true
    }
}

$record = Get-DemoInventoryRecord
$record | Get-Member
$record | Select-Object Name, Port, Healthy

The function is intentionally simple; functions are taught deeply in Chapter 09. The design principle is already useful: reusable commands should return data, while presentation is a caller decision.

8. Stable property names become a lightweight data contract

Once a record is consumed by another command, exported, or tested, property names and value types become part of an implicit contract. A typo such as Enviroment can silently produce missing data later. Inspecting and testing the shape is therefore part of maintainable automation.

$record = [pscustomobject]@{
    Name = 'api-01'
    Environment = 'staging'
    Port = 8443
}

$record.PSObject.Properties.Name
$record.Port.GetType().Name

Later chapters add Pester tests and formal parameter validation. For now, develop the habit of designing object shape intentionally rather than as a side effect of display code.

9. Useful pipeline objects make composition possible

Inventory collectors, deployment planners, API clients, compliance checks, and monitoring scripts all become easier to compose when they return predictable objects. One command can emit inventory records; another filters unhealthy nodes; another exports selected fields; another groups by environment. None of those stages needs to parse terminal text.

10. Diagnose custom-object bugs with Get-Member and PSObject metadata

If a property appears missing or has the wrong type, inspect the object directly. Do not assume the default table is complete.

$record = [pscustomobject]@{
    Name = 'api-01'
    Port = '8443'   # deliberately left as text
}

$record | Get-Member
$record | Format-List *
$record.Port.GetType().FullName

A string port might look identical to an integer port on screen. The type inspection exposes the difference before numeric comparisons or API serialization behave unexpectedly.

11. Lab: build a reusable inventory pipeline

This lab builds typed records entirely in memory, then demonstrates that the same objects can support filtering, grouping, and machine-oriented projection without reparsing display text.

$rawInventory = @(
    @{ Name='api-01'; Environment='staging'; Port='8443'; Healthy='true' }
    @{ Name='worker-01'; Environment='staging'; Port='9000'; Healthy='false' }
    @{ Name='api-02'; Environment='production'; Port='8443'; Healthy='true' }
)

$inventory = foreach ($row in $rawInventory) {
    [pscustomobject]@{
        PSTypeName = 'DevOpsAcademy.ServerInventory'
        Name = [string]$row.Name
        Environment = [string]$row.Environment
        Port = [int]$row.Port
        Healthy = [bool]::Parse([string]$row.Healthy)
    }
}

$inventory | Where-Object Healthy | Sort-Object Environment, Name |
    Select-Object Name, Environment, Port

$inventory | Group-Object Environment |
    Select-Object Name, Count

Every downstream operation works on named properties. That is the central payoff of structured records: the object remains useful after the original producer has finished.

Verification checklist

12. Common mistakes to avoid

Using a hashtable as a report row everywhere. Dictionaries and records are different abstractions; choose the one that matches how downstream code will access the data.

Converting everything to strings for display. Preserve Booleans, integers, dates, and other types until the real text boundary.

Using Write-Host as a data-return mechanism. Return objects from reusable code and let callers choose presentation.

Treating default display as the schema. Inspect properties with Get-Member or PSObject.Properties.

13. Knowledge check

Question 1. When is a PSCustomObject usually a better fit than a hashtable?

Question 2. What does [pscustomobject]@{ Name="api-01" } create?

Question 3. Why should reusable commands emit objects instead of only Write-Host strings?

Question 4. What is the purpose of a custom PSTypeName?

Question 5. How can you remove a property from a PSCustomObject?

14. Summary

PSCustomObject is PowerShell’s lightweight record-building tool. Use it when data should have stable named properties and participate naturally in the object pipeline. Construct clean typed records at input boundaries, inspect their members, treat property names/types as a contract, and keep presentation separate from reusable data output. A custom PSTypeName can add semantic identity without requiring a compiled class.

15. Further reading

Next lesson

Use those same data structures to make command construction readable

Lesson 04 applies hashtables and script blocks to splatting and calculated properties, turning long command invocations and derived fields into inspectable data.

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.