Private/Result.psm1
|
using module .\Enums.psm1 <# .NOTES Ok(value) — successful computation carrying a value Err(error) — failed computation carrying an error descriptor Thread-safety ───────────── Result instances are fully IMMUTABLE after construction (no public setters, all mutation methods return new objects). They are safe to share across PowerShell Runspaces / Thread-jobs without locks. Scriptblock callbacks (Map, AndThen, etc.) are NOT automatically thread-safe — that responsibility lies with the caller. Compatibility ───────────── GetHashCode : Uses a manual XOR combiner compatible with PS 5.1 and PS 7+. Null-safety : Err() rejects $null errors by design — use Err("reason") or Err([ErrorRecord]) rather than Err($null). Known PS class limitations ────────────────────────── • PS classes have no enforced private members at runtime — 'hidden' prevents tab-completion leakage but does not stop determined callers. • No generic type parameters — Ok/Err values are typed [object]; add your own validation in callbacks when strict typing matters. #> class Result { # ── Immutable internal state hidden [ResultKind] $_kind hidden [object] $_value # populated when Ok hidden [object] $_error # populated when Err # Private constructor ─ always go through the static factories hidden Result([ResultKind]$kind, [object]$value, [object]$ErrorRecord) { $this._kind = $kind $this._value = $value $this._error = $ErrorRecord } # Ok carrying a value static [Result] Ok([object]$value) { return [Result]::new([ResultKind]::Ok, $value, $null) } # Ok carrying no value (unit / void) static [Result] Ok() { return [Result]::new([ResultKind]::Ok, $null, $null) } # Err carrying an error descriptor (non-null required) static [Result] Err([object]$ErrorRecord) { # indistinguishable Err and hides programming mistakes. if ($null -eq $error) { throw [System.ArgumentNullException]::new( 'error', "Err() requires a non-null error value. " + "Use a descriptive string, Exception, or ErrorRecord." ) } return [Result]::new([ResultKind]::Err, $null, $ErrorRecord) } # State Queries [bool] IsOk() { return $this._kind -eq [ResultKind]::Ok } [bool] IsErr() { return $this._kind -eq [ResultKind]::Err } [bool] IsOkAnd([scriptblock]$predicate) { if ($null -eq $predicate) { throw [System.ArgumentNullException]::new('predicate') } if ($this.IsErr()) { return $false } try { return [bool](& $predicate $this._value) } catch { return $false } } [bool] IsErrAnd([scriptblock]$predicate) { if ($null -eq $predicate) { throw [System.ArgumentNullException]::new('predicate') } if ($this.IsOk()) { return $false } try { return [bool](& $predicate $this._error) } catch { return $false } } # Extraction # Returns the Ok value; throws on Err with the error's message. [object] Unwrap() { if ($this.IsErr()) { throw [System.InvalidOperationException]::new( "Called Unwrap() on an Err value: $($this._error)" ) } return $this._value } # Returns the Ok value; throws on Err with a caller-supplied message. [object] Expect([string]$message) { if ([string]::IsNullOrWhiteSpace($message)) { throw [System.ArgumentException]::new( 'Expect() message must not be null or blank.', 'message') } if ($this.IsErr()) { throw [System.InvalidOperationException]::new( "${message}: $($this._error)" ) } return $this._value } # Returns the Err value; throws if Ok. [object] UnwrapErr() { if ($this.IsOk()) { throw [System.InvalidOperationException]::new( "Called UnwrapErr() on an Ok value: $($this._value)" ) } return $this._error } # Returns the Err value; throws with caller-supplied message if Ok. [object] ExpectErr([string]$message) { if ([string]::IsNullOrWhiteSpace($message)) { throw [System.ArgumentException]::new( 'ExpectErr() message must not be null or blank.', 'message') } if ($this.IsOk()) { throw [System.InvalidOperationException]::new( "${message}: $($this._value)" ) } return $this._error } # Returns the Ok value or $default when Err. [object] UnwrapOr([object]$default) { if ($this.IsOk()) { return $this._value } return $default } # Returns the Ok value, or the result of $fn called with the error. [object] UnwrapOrElse([scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsOk()) { return $this._value } try { return & $fn $this._error } catch { throw [System.InvalidOperationException]::new( "UnwrapOrElse callback threw: $($_.Exception.Message)", $_.Exception ) } } # Returns Ok value or $null — no arguments needed. [object] UnwrapOrDefault() { if ($this.IsOk()) { return $this._value } return $null } # Mapping # Transform the Ok value; pass Err through unchanged. [Result] Map([scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsErr()) { return $this } # short-circuit, no allocation try { return [Result]::Ok($(& $fn $this._value)) } catch { return [Result]::Err($_.Exception) } } # Map the Ok value through $fn; return $default on Err. [object] MapOr([object]$default, [scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsErr()) { return $default } try { return & $fn $this._value } catch { return $default } } # Map Ok through $fn; compute default via $defaultFn on Err. [object] MapOrElse([scriptblock]$defaultFn, [scriptblock]$fn) { if ($null -eq $defaultFn) { throw [System.ArgumentNullException]::new('defaultFn') } if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsErr()) { try { return & $defaultFn $this._error } catch { throw [System.InvalidOperationException]::new( "MapOrElse defaultFn threw: $($_.Exception.Message)", $_.Exception) } } try { return & $fn $this._value } catch { throw [System.InvalidOperationException]::new( "MapOrElse fn threw: $($_.Exception.Message)", $_.Exception) } } # Transform the Err value; pass Ok through unchanged. [Result] MapErr([scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsOk()) { return $this } try { $mapped = & $fn $this._error if ($null -eq $mapped) { throw [System.InvalidOperationException]::new( "MapErr callback returned null — Err() requires a non-null error.") } return [Result]::Err($mapped) } catch [System.InvalidOperationException] { throw } # re-throw our own catch { return [Result]::Err($_.Exception) } } # If Ok, call $fn with value and return its Result; propagate Err. [Result] AndThen([scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsErr()) { return $this } try { $next = & $fn $this._value if ($next -isnot [Result]) { $typeName = if ($null -ne $next) { $next.GetType().FullName } else { 'null' } return [Result]::Err( [System.InvalidOperationException]::new( "AndThen callback must return [Result]; got [$typeName]" ) ) } return $next } catch { return [Result]::Err($_.Exception) } } # If Ok return $other; otherwise propagate this Err. [Result] And([Result]$other) { if ($null -eq $other) { throw [System.ArgumentNullException]::new('other') } if ($this.IsOk()) { return $other } return $this } # If Ok return self; otherwise return $other. [Result] Or([Result]$other) { if ($null -eq $other) { throw [System.ArgumentNullException]::new('other') } if ($this.IsOk()) { return $this } return $other } # If Ok return self; otherwise call $fn with the error and return its Result. [Result] OrElse([scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsOk()) { return $this } try { $next = & $fn $this._error if ($next -isnot [Result]) { $typeName = if ($null -ne $next) { $next.GetType().FullName } else { 'null' } return [Result]::Err( [System.InvalidOperationException]::new( "OrElse callback must return [Result]; got [$typeName]" ) ) } return $next } catch { return [Result]::Err($_.Exception) } } # Flatten Result<Result<T>> → Result<T>. # If the Ok value is itself a Result, unwrap one layer. [Result] Flatten() { if ($this.IsErr()) { return $this } if ($this._value -is [Result]) { return [Result]$this._value } return $this # already flat } # Inspection (side-effects only — never alter the Result) # Side-effect callbacks must never alter flow — exceptions are silenced. # Log internally if you need to debug a bad inspector. [Result] Inspect([scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsOk()) { try { & $fn $this._value | Out-Null } catch { $null } } return $this } [Result] InspectErr([scriptblock]$fn) { if ($null -eq $fn) { throw [System.ArgumentNullException]::new('fn') } if ($this.IsErr()) { try { & $fn $this._error | Out-Null } catch { $null } } return $this } # Pattern matching # Exhaustive two-armed match — always produces a value, never silently # falls through. Preferred over bare switch($result.IsOk()) blocks. # # Example: # $label = $result.Match( # { param($v) "Success: $v" }, # { param($e) "Failed: $e" } # ) [object] Match([scriptblock]$onOk, [scriptblock]$onErr) { if ($null -eq $onOk) { throw [System.ArgumentNullException]::new('onOk') } if ($null -eq $onErr) { throw [System.ArgumentNullException]::new('onErr') } if ($this.IsOk()) { return & $onOk $this._value } else { return & $onErr $this._error } } # Conversion # Ok → value ; Err → $null [object] ToNullable() { if ($this.IsOk()) { return $this._value } return $null } # Ok → @(value) ; Err → @() [array] ToArray() { if ($this.IsOk()) { return @($this._value) } return @() } # objects that are Equal must have the same hash. That breaks Hashtable / # Dictionary / GroupBy etc.) [bool] Equals([object]$other) { if ($null -eq $other -or -not ($other -is [Result])) { return $false } $r = [Result]$other if ($this._kind -ne $r._kind) { return $false } if ($this.IsOk()) { if ($null -eq $this._value -and $null -eq $r._value) { return $true } if ($null -eq $this._value -or $null -eq $r._value) { return $false } return $this._value.Equals($r._value) } if ($null -eq $this._error -and $null -eq $r._error) { return $true } if ($null -eq $this._error -or $null -eq $r._error) { return $false } return $this._error.Equals($r._error) } # Djb2-style combiner — compatible with PS 5.1 (.NET 4.x) and PS 7+. [int] GetHashCode() { $inner = if ($this.IsOk()) { $this._value } else { $this._error } $innerHash = if ($null -eq $inner) { 0 } else { $inner.GetHashCode() } # Combine kind hash + inner hash $kindHash = $this._kind.GetHashCode() return (($kindHash -shl 5) + $kindHash) -bxor $innerHash } [string] ToString() { if ($this.IsOk()) { $inner = if ($null -eq $this._value) { 'void' } else { $this._value.ToString() } return "Ok($inner)" } # Err side — _error should never be null (factory enforces it) but guard anyway $inner = if ($null -eq $this._error) { '???' } else { $this._error.ToString() } return "Err($inner)" } } class Results { hidden [System.Collections.Generic.List[object]] $_items [double]$ElapsedTime [bool]$IsSuccess [bool]$HasErrors [object[]]$Output [object[]]$Errors [int]$Count Results() { $this._items = [System.Collections.Generic.List[object]]::new() $this.ElapsedTime = 0 $this.IsSuccess = $false $this.HasErrors = $false $this.Output = @() $this.Errors = @() $this.Count = 0 } [void] Add([Result]$result, [double]$elapsedTime) { $this._items.Add([PSCustomObject]@{ Result = $result ElapsedTime = $elapsedTime } ) $this.ElapsedTime = [math]::Round($this.ElapsedTime + $elapsedTime, 2) if ($result.IsOk()) { $this.IsSuccess = $true $val = $result.Unwrap() if ($null -ne $val) { if ($val -is [array]) { $this.Output += $val } else { $this.Output += @($val) } } } else { $this.HasErrors = $true $this.Errors += @($result.UnwrapErr()) } $this.Count = $this._items.Count } } |