03 · Working with .NET from PowerShell¶
Every PowerShell value is a .NET object under the hood — "hello" is a
System.String, 5 is a System.Int32, and Get-Process returns
System.Diagnostics.Process objects. That means the entire .NET class
library is available directly from your scripts, without a cmdlet
wrapper needing to exist for it first.
Static members: [Type]::Member¶
[Math]::Pow(2, 10)
[Math]::Round(3.14159, 2)
[DateTime]::Now.ToString("yyyy-MM-dd")
[string]::Join("-", @("a", "b", "c"))
Square brackets around a type name ([Math], [DateTime], [string])
access it directly; :: calls a static member — one that belongs to
the type itself, not to any particular instance. There's no New-Math
cmdlet and there doesn't need to be one.
Instantiating .NET objects with ::new()¶
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Start-Sleep -Milliseconds 50
$sw.Stop()
"Elapsed: $($sw.ElapsedMilliseconds)ms"
::new(...) calls a constructor — the modern equivalent of New-Object,
and preferred because it supports overload resolution and IntelliSense
more reliably. Once created, .Property and .Method() work exactly
like they do on any PowerShell object, because it is one.
StringBuilder: when += on strings gets expensive¶
$sb = [System.Text.StringBuilder]::new()
1..5 | ForEach-Object { [void]$sb.AppendLine("Line $_") }
$sb.ToString()
Each $result += "text" on a regular string silently allocates a whole
new string (strings are immutable in .NET) — fine for a handful of
appends, but O(n²) work if you're building a large report in a loop.
StringBuilder mutates an internal buffer instead. [void] in front of
.AppendLine(...) suppresses its return value — AppendLine returns the
StringBuilder itself (to allow chaining in C#), and without [void]
that return value would print to the pipeline on every call.
[regex] directly, for named groups and reuse¶
$regex = [regex]::new('(\d{3})-(\d{4})')
$m = $regex.Match("Call 555-1234 now")
"Match: $($m.Value), area: $($m.Groups[1].Value), rest: $($m.Groups[2].Value)"
-match (covered in Level 2) is fine for a one-off check, but
instantiating [regex] once and reusing it avoids recompiling the
pattern on every call in a hot loop — worth it once you're matching
inside a loop over thousands of lines.
Generic collections: List<T> and Dictionary<K,V>¶
$list = [System.Collections.Generic.List[int]]::new()
$list.Add(1); $list.Add(2); $list.Add(3)
$list.Contains(2) # True
$list.Remove(2) # True
$list -join "," # 1,3
$dict = [System.Collections.Generic.Dictionary[string,int]]::new()
$dict["a"] = 1
$dict["b"] = 2
foreach ($kvp in $dict.GetEnumerator()) { "$($kvp.Key)=$($kvp.Value)" }
PowerShell arrays (@()) and hashtables (@{}) cover most needs, but
generic collections are strongly typed (List[int] rejects a string),
faster for large datasets since there's no boxing/type-checking overhead
per operation, and expose methods (.BinarySearch(), .Sort(),
.TryGetValue()) arrays don't have.
The trap: a void-returning method looks like it "worked" either way¶
$list = [System.Collections.Generic.List[string]]::new()
$result = $list.Add("x") # Add returns void
"Result of Add(): [$result]"
$result is empty, not $null and not an error — Add() genuinely has
no return value in .NET, and PowerShell doesn't complain about capturing
nothing. The trap is assuming a captured variable proves the operation
succeeded or gives you useful information; check the .NET documentation
for a method's actual return type before relying on what you captured
from it. Some methods return $true/$false for success (Remove),
some return the item itself (Dictionary.Add returns void too, but
indexer assignment doesn't), and some return void — there's no single
rule, you have to know the specific method.
Wrapping .NET behavior in a PowerShell class¶
You can't reopen or subclass most .NET types from PowerShell, but you can compose one inside your own class:
class Temperature {
[double]$Celsius
Temperature([double]$c) { $this.Celsius = $c }
[double] ToFahrenheit() { return $this.Celsius * 9 / 5 + 32 }
[string] ToString() { return "$($this.Celsius)C" }
}
$t = [Temperature]::new(100)
"$t is $($t.ToFahrenheit())F"
Overriding ToString() controls how the object renders when interpolated
into a string (as above) or piped to Write-Output — without it, you'd
see the default Temperature type name instead.
Catching .NET exceptions with type-specific catch¶
Caught: RuntimeException: Cannot convert value "abc" to type "System.Int32".
Error: "The input string 'abc' was not in a correct format."
A failed type conversion throws — wrap it, and inspect
$_.Exception.GetType().Name when you need to branch on which .NET
exception occurred (FormatException, ArgumentOutOfRangeException,
IOException) rather than treating every failure identically.
Cheat sheet¶
| Syntax | Meaning |
|---|---|
[Type]::Member |
access a static property/method |
[Type]::new(...) |
construct an instance (preferred over New-Object) |
.Property, .Method() |
instance members, same as any PowerShell object |
[void]$x.Method() |
call a method and discard its return value |
[System.Collections.Generic.List[T]] |
strongly-typed, faster list |
[System.Collections.Generic.Dictionary[K,V]] |
strongly-typed key/value store |
$_.Exception.GetType().Name |
the specific .NET exception type in a catch |
class with a wrapped .NET member |
compose .NET behavior you can't subclass directly |
How It Actually Works¶
When you write [System.IO.File]::ReadAllText($path), PowerShell's parser
recognizes the [Type]::Member syntax as a static member expression
node in the AST and resolves it through .NET reflection at runtime,
looking up the type by name against loaded assemblies (using the type
resolution/using namespace search list you've declared) and then
binding to the best-matching overload of ReadAllText based on your
argument types and count — the same overload-resolution algorithm the C#
compiler uses, just performed dynamically instead of at compile time,
which is why passing the wrong argument type produces a runtime
MethodException rather than a compile error.
Instantiating a .NET type with New-Object System.Collections.Generic.
List[int] or the newer [System.Collections.Generic.List[int]]::new()
both ultimately call the CLR's Activator.CreateInstance-equivalent
constructor-invocation path — the bracket-and-::new() form is generally
preferred because it goes through the same static-member-resolution
machinery as any other :: call (supporting generic type parameters and
constructor overload selection cleanly), while New-Object has to parse
its -ArgumentList array and match it against constructors via a
separate, older binding path.
Exceptions thrown by .NET methods you call directly (not through a
cmdlet) surface as raw CLR exceptions, not ErrorRecord-wrapped
non-terminating errors — there's no Cmdlet.WriteError layer between you
and the .NET call, so catch [System.IO.FileNotFoundException] here is
catching the literal exception type .NET itself throws, with none of
PowerShell's ErrorRecord/CategoryInfo enrichment layered on top; this
is the actual reason .NET interop code needs typed catch blocks that
mirror the target framework's own exception hierarchy rather than
PowerShell-specific error handling idioms.
Exercise¶
Write a function Measure-TextStats that takes a block of text, uses
[System.Text.StringBuilder] internally to normalize it (lowercase, strip
punctuation via [regex]::Replace), then returns a [pscustomobject]
with word count, unique word count (using a
[System.Collections.Generic.HashSet[string]]), and the most common word.
Time it against a naive += string-concatenation version on a
few-thousand-word input using [System.Diagnostics.Stopwatch] and confirm
the StringBuilder version is faster.