TB

MoppleIT Tech Blog

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

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' -NoTypeInformation

Key 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 utf8

Tips:

  • Depth: Default depth is shallow. For nested contracts, set -Depth appropriately.
  • Encoding: Use UTF-8 for interoperability. In newer PowerShell versions, Set-Content with -Encoding utf8 writes 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' -NoTypeInformation

For 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' -NoTypeInformation

Nested 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 5

Hardening, 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]::InvariantCulture where 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 Version in 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-Project function that wraps the ToObject()+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' -NoTypeInformation

What you get