Stable Data Contracts with Classes in PowerShell: Predictable JSON and CSV
You can stop guessing at data shapes in PowerShell. By defining small, typed classes and projecting them into clean PSCustomObject instances, you lock property names, enforce types, and get predictable outputs for both JSON and CSV. The result: stable pipelines, cleaner diffs, safer exports, and easier tests.
In this post, you will build a minimal contract class, expose a ToObject() method that returns a clean PSCustomObject, and serialize once to export anywhere with consistent fields.
Why Stabilize Your Data Shapes
Dynamic objects are convenient until a stray property, a null value, or a type mismatch breaks your pipeline. Common pain points include:
- Unpredictable property sets: Objects with ad-hoc properties cause inconsistent CSV headers and hard-to-diff JSON.
- Type drift: Numbers turning into strings (or vice versa) when merged from multiple sources.
- Single vs. array mismatch: A single object emits as a bare object instead of an array, breaking downstream consumers.
- Hidden formatting differences: Date/time or boolean representations change across environments.
A small class as a data contract solves this by declaring your shape and types up front, and by funneling all serialization through a single, predictable projection.
Define a Small Class as Your Contract
The minimal, practical pattern
Start with a class that fixes property names and types, and add a ToObject() method that returns a clean PSCustomObject. Using an ordered hashtable ensures property order is stable for CSV headers and for consistent diffs (note that JSON consumers should not rely on property order, but stable order still improves human readability and diffs).
class Project {
[string]$Name
[int]$Id
[bool]$Enabled
Project([string]$Name, [int]$Id, [bool]$Enabled) {
$this.Name = $Name
$this.Id = $Id
$this.Enabled = $Enabled
}
[pscustomobject] ToObject() {
# Ordered keys produce consistent CSV column order and cleaner JSON diffs
[pscustomobject]([ordered]@{
Name = $this.Name
Id = $this.Id
Enabled = $this.Enabled
})
}
}
# Build data with explicit types
$items = @(
[Project]::new('alpha', 1, $true),
[Project]::new('beta', 2, $false)
)
# Serialize predictably to JSON (array shape preserved)
$json = $items | ForEach-Object { $_.ToObject() } | ConvertTo-Json -Depth 5
$json | Out-File -FilePath './projects.json' -Encoding utf8
# Export the same shape to CSV
$items | ForEach-Object { $_.ToObject() } |
Export-Csv -Path './projects.csv' -NoTypeInformationKey benefits of this approach:
- Property names are fixed: No accidental typos or ad-hoc additions leak into your exports.
- Types are enforced: If a value doesn’t match, you’ll know early.
- Serialization is centralized:
ToObject()becomes your single source of truth for shape.
Practical enhancements for real systems
As your contract evolves, you can add validation, optional fields, and date handling without leaking internal implementation details.
enum ProjectStatus { Unknown; Planned; Active; Paused; Archived }
class ProjectV2 {
[string]$Name
[Nullable[int]]$Id
[bool]$Enabled
[ProjectStatus]$Status
[datetime]$CreatedUtc
ProjectV2(
[ValidateNotNullOrEmpty()][string]$Name,
[Nullable[int]]$Id,
[bool]$Enabled,
[ProjectStatus]$Status = [ProjectStatus]::Planned,
[datetime]$CreatedUtc = [datetime]::UtcNow
) {
$this.Name = $Name
$this.Id = $Id
$this.Enabled = $Enabled
$this.Status = $Status
$this.CreatedUtc = $CreatedUtc
}
[pscustomobject] ToObject() {
# ISO 8601 for stable, locale-agnostic timestamps
$isoCreated = $this.CreatedUtc.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture)
[pscustomobject]([ordered]@{
Name = $this.Name
Id = $this.Id
Enabled = $this.Enabled
Status = $this.Status.ToString()
CreatedUtc = $isoCreated
})
}
}Notes:
- Enums help constrain values. In
ToObject()you usually emit the string representation. - Nullable types (e.g.,
[Nullable[int]]) are useful for optional numeric fields. - Dates should be normalized to an ISO 8601 UTC string to avoid locale surprises and to stabilize diffs/tests.
Serialize Once, Export Anywhere
Consistent JSON
Project through ToObject() before calling ConvertTo-Json. Set -Depth high enough for nested objects. To guarantee array output even when there’s a single element, either wrap the pipeline or use -AsArray (PowerShell 7+).
# Always an array in the final JSON
$projects = @(
[ProjectV2]::new('alpha', 1, $true, [ProjectStatus]::Active),
[ProjectV2]::new('beta', $null, $false, [ProjectStatus]::Planned)
)
# Option A: array subexpression
$jsonA = @($projects | ForEach-Object { $_.ToObject() }) | ConvertTo-Json -Depth 5
# Option B (PS 7+): -AsArray
$jsonB = ($projects | ForEach-Object { $_.ToObject() }) | ConvertTo-Json -Depth 5 -AsArray
Set-Content -Path './projects.json' -Value $jsonB -Encoding utf8Tips:
- Depth: Default depth is shallow. For nested contracts, set
-Depthappropriately. - Encoding: Use UTF-8 for interoperability. In newer PowerShell versions,
Set-Contentwith-Encoding utf8writes UTF-8 without BOM. - Nulls: JSON will include nulls. Keep them if they matter to consumers; otherwise project defaults in
ToObject().
Predictable CSV
CSV headers are derived from the first object’s properties and their order. Because ToObject() returns a consistently ordered PSCustomObject, you get stable columns every run.
$projects | ForEach-Object { $_.ToObject() } |
Export-Csv -Path './projects.csv' -NoTypeInformationFor single-record exports, still project an array to avoid accidental schema drift when later appending:
@([Project]::new('solo', 42, $true).ToObject()) |
Export-Csv -Path './single.csv' -NoTypeInformationNested contracts and collections
You can embed one contract within another. Keep the ToObject() boundary clean and recurse explicitly.
class Owner {
[string]$User
[string]$Email
Owner([string]$User, [string]$Email) { $this.User = $User; $this.Email = $Email }
[pscustomobject] ToObject() { [pscustomobject]([ordered]@{ User = $this.User; Email = $this.Email }) }
}
class ProjectWithOwner {
[string]$Name
[Owner]$Owner
ProjectWithOwner([string]$Name, [Owner]$Owner) { $this.Name = $Name; $this.Owner = $Owner }
[pscustomobject] ToObject() {
[pscustomobject]([ordered]@{
Name = $this.Name
Owner = $this.Owner.ToObject()
})
}
}
$proj = [ProjectWithOwner]::new('gamma', [Owner]::new('jdoe', 'jdoe@example.com'))
$proj.ToObject() | ConvertTo-Json -Depth 5Hardening, Testing, and CI
Validation and safety
- Input validation: Use attributes like
[ValidateNotNullOrEmpty()],[ValidateRange()], and enums to guard against invalid inputs. - Culture-proofing: Convert numeric and date outputs using
[CultureInfo]::InvariantCulturewhere you emit strings. - Single authority: Ensure every export goes through
ToObject(). Don’t serialize raw class instances directly.
Test the contract with Pester
Pester can verify property names, order, and types. This prevents accidental breaking changes.
Describe 'ProjectV2 contract' {
It 'emits stable keys in order' {
$o = ([ProjectV2]::new('alpha', 1, $true, [ProjectStatus]::Active, [datetime]'2024-01-01Z')).ToObject()
$o.PSObject.Properties.Name | Should -Be @('Name','Id','Enabled','Status','CreatedUtc')
}
It 'serializes as an array when requested' {
$json = @(([ProjectV2]::new('alpha', 1, $true, [ProjectStatus]::Active)).ToObject()) |
ConvertTo-Json -AsArray
($json | ConvertFrom-Json).Count | Should -Be 1
}
It 'normalizes datetime to ISO-8601 UTC' {
$o = ([ProjectV2]::new('alpha', 1, $true, [ProjectStatus]::Active, (Get-Date))).ToObject()
$o.CreatedUtc | Should -Match 'Z$'
}
}Schema evolution and versioning
- Additive changes: Add new properties at the end of the ordered hashtable to preserve CSV column order. Consumers can ignore unknown fields.
- Breaking changes: Introduce
Versionin the contract output, or version the class name (e.g.,ProjectV3), and keep older serializers for backward compatibility. - Deprecations: Keep legacy fields populated until consumers migrate; then remove in a major version.
Pipeline integration
- CI checks: Add a job that regenerates a sample JSON/CSV and diffs it against a golden file. This catches unintended shape changes early.
- Reusable module: Package your classes and a single
Export-Projectfunction that wraps theToObject()+serialize flow. Teams then depend on the module, not ad-hoc scripts.
End-to-End Example
Here is a compact end-to-end script that builds stable data and exports both JSON and CSV with consistent fields.
class Project {
[string]$Name
[int]$Id
[bool]$Enabled
Project([string]$Name, [int]$Id, [bool]$Enabled) {
$this.Name = $Name
$this.Id = $Id
$this.Enabled = $Enabled
}
[pscustomobject] ToObject() {
[pscustomobject]([ordered]@{
Name = $this.Name
Id = $this.Id
Enabled = $this.Enabled
})
}
}
$items = @(
[Project]::new('alpha', 1, $true),
[Project]::new('beta', 2, $false)
)
# Serialize predictably (JSON)
@($items | ForEach-Object { $_.ToObject() }) |
ConvertTo-Json -Depth 5 -AsArray |
Set-Content -Path './projects.json' -Encoding utf8
# Export the same shape (CSV)
$items | ForEach-Object { $_.ToObject() } |
Export-Csv -Path './projects.csv' -NoTypeInformationWhat you get
- Predictable