8

Consider the following PowerShell code:

> $null -gt 0
False
> $null -ge 0
False
> $null -eq 0
False
> $null -le 0
True
> $null -lt 0
True

Of course the same is true for a $variable explicitly set to $null or for a non-existent variable.

  1. Why is that? It doesn't make a lot of sense to me. I feel like $null by definition doesn't have a value that could be tested as such, or at the very least, that it would evaluate to zero in such tests. But other than that I guess I don't know what behavior I would actually expect. Googling (or searching SO) for e.g. "Why is null less than zero in Powershell" doesn't seem to yield any results, though I do see relevant questions and answers for several other languages.
  2. Can and should this result be relied on?
  3. Aside from testing the variable with GetType(), or various implementations of "IsNumeric", "IsNullOrEmpty", etc. what is a the best (i.e. most concise, best performing, etc.) way to reliably test for integer values (or other types for that matter) in a variable that might have a value of $null? Or is one of those methods considered pretty standard?

Thanks for your time. Apologies in advance if this is too "squishy" of a question for this venue.

P.S. For what it's worth my usual environment is PowerShell v5.1.

mmseng
  • 735
  • 9
  • 24

2 Answers2

5

Why is that?

The behavior is counterintuitive:

Operators -lt, -le, -gt, -ge, even though they can also have numeric meaning, seemingly treat a $null operand as if it were the empty string (''), i.e. they default to string comparison, as the sample commands in postanote's helpful answer imply.

That is, $null -lt 0 is in effect evaluated the same as '' -lt '0', which explains the $true result, because in lexical comparison the condition is met.
While you can conceive of $null -eq 0 as '' -eq '0' too, the -eq case is special - see below.

Additionally, placing the 0 on the LHS still acts like a string comparison (except with -eq see below) - even though it is normally the type of the LHS that causes the RHS to be coerced to the same type.

That is, 0 -le $null too seems to act like '0' -le '' and therefore returns $false.

While such behavior is to be expected in operators that are exclusively string-based, such as -match and -like, it is surprising for operators that also support numbers, especially given that other such operators - as well as those that are exclusively numeric - default to numeric interpretation of $null, as 0.

  • +, -, and / do force a LHS $null to 0 ([int] by default); e.g. $null + 0 is 0
  • * does not; e.g., $null * 0 is again $null.

Of these, - and / are exclusively numeric, whereas + and * also work in string and array contexts.

There is an additional inconsistency: -eq never performs type coercion on a $null operand:

  • $null -eq <RHS> is only ever $true if <RHS> is also $null (or "automation null" - see below), and is currently the only way to reliably test a value for being $null. (To put it differently: $null -eq '' is not the same as '' -eq '' - no type coercion takes place here.)

    • GitHub PR #10704, which has unfortunately stalled, aims to implement a dedicated syntax for $null tests, such as <LHS> -is $null.
  • Similarly, <LHS> -eq $null also performs no type coercion on $null and returns $true only with $null as the LHS;

    • However, with an array-valued <LHS>, -eq acts as filter (as most operators do), returning the subarray of elements that are $null; e.g., 1, $null, 2, $null, 3 -eq $null returs 2-element array $null, $null.
    • This filtering behavior is the reason that only $null -eq <RHS> - with $null as the scalar LHS - is reliable as a test for (scalar) $null.

Note that the behaviors equally apply to the "automation null" value that PowerShell uses to express the (non-)output from commands (technically, the [System.Management.Automation.Internal.AutomationNull]::Value singleton), because this value is treated the same as $null in expressions; e.g. $(& {}) -lt 0 is also $true - see this answer for more information.

Similarly, the behaviors also apply to instances of nullable value types that happen to contain $null (e.g., [System.Nullable[int]] $x = $null; $x -lt 0 is also $true)Thanks, Dávid Laczkó., though note that their use in PowerShell is rare.


Can and should this result be relied on?

Since the behavior is inconsistent across operators, I wouldn't rely on it, not least because it's also hard to remember which rules apply when - and there's at least a hypothetical chance that the inconsistency will be fixed; given that this would amount to a breaking change, however, that may not happen.

If backward compatibility weren't a concern, the following behavior would remove the inconsistencies and make for rules that are easy to conceptualize and remember:

When a (fundamentally scalar) binary operator is given a $null operand as well as a non-$null operand - irrespective of which is the LHS and which is the RHS:

  • For operators that operate exclusively on numeric / Boolean / string operands (e.g. / / -and / -match): coerce the $null operand to the type implied by the operator.

  • For operators that operate in multiple "domains" - both textual and numeric (e.g. -eq) - coerce the $null operand to the other operand's type.

Note that this would then additionally require a dedicated $null test with different syntax, such as the -is $null from the above-mentioned PR.

Note: The above does not apply to the collection operators, -in and -contains (and their negated variants -notin and -notcontains), because their element-wise equality comparison acts like -eq and therefore never applies type coercion to $null values.


what is the best (i.e. most concise, best performing, etc.) way to reliably test for integer values (or other types for that matter) in a variable that might have a value of $null?

The following solutions force a $null operand to 0:

  • Note: (...) around the LHS of the -lt operations below is used for conceptual clarity, but isn't strictly necessary - see about_Operator_Precedence.

In PowerShell (Core) 7+, use ??, the null-coalescing operator, which works with operands of any type:

# PowerShell 7+ only
($null ?? 0) -lt 0 # -> $false

In Windows PowerShell, where this operator isn't supported, use a dummy calculation:

# Windows PowerShell
(0 + $null) -lt 0  # -> $false

While something like [int] $null -lt 0 works too, it requires you to commit to a specific numeric type, so if the operand happens to be higher than [int]::MaxValue, the expression will fail; [double] $null -lt 0 would minimize that risk, though could at least hypothetically result in loss of accuracy.

The dummy addition (0 +) bypasses this problem and lets PowerShell apply its usual on-demand type-widening.

As an aside: This automatic type-widening can exhibit unexpected behavior too, because an all-integer calculation whose result requires a wider type than either operand's type can fit is always widened to [double], even when a larger integer type would suffice; e.g. ([int]::MaxValue + 1).GetType().Name returns Double, even though a [long] result would have sufficed, resulting in potential loss of accuracy - see this answer for more information.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • @mmseng, unfortunately, I wasn't done yet: `0 -le $null` returning `$false` is another head scratcher, given that the LHS type usually causes the RHS to be coerced to it - but this too behaves like `'0' -le ''`. It's a good question whether string comparison is actually used (I haven't tried to dig into the source code), but in a sense the question is academic, because _de facto_ it behaves like that, in the cases described. I've also overhauled the section about how things _should_ work, if backward compatibility weren't in the picture. The bottom line is: `$null` is special - way too special – mklement0 Feb 27 '21 at 21:47
  • Thanks, @DávidLaczkó, I've added a note to the answer. Yes, nullable value-type instances that happen to contain `$null` behave just like `$null`, in both PowerShell editions. – mklement0 May 09 '21 at 13:11
2

Test $null comparison results

(0).GetType()
('').GetType()
(' ').GetType()
($null).GetType()
# Results
<#
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Int32                                    System.ValueType
True     True     String                                   System.Object
True     True     String                                   System.Object
You cannot call a method on a null-valued expression.
#>

Measure-Object -InputObject (0).GetType()
Measure-Object -InputObject ('').GetType()
Measure-Object -InputObject (' ').GetType()
Measure-Object -InputObject ($null).GetType()
# Results
<#
Count    : 1
Average  : 
Sum      : 
Maximum  : 
Minimum  : 
Property : 

Count    : 1
Average  : 
Sum      : 
Maximum  : 
Minimum  : 
Property : 

Count    : 1
Average  : 
Sum      : 
Maximum  : 
Minimum  : 
Property : 

You cannot call a method on a null-valued expression.
#>


$Null -eq '' 
[string]$Null -eq ''
$Null -eq [string]''
[string]$Null -eq [string]''
# Results
<#
False
True
False
True
#>

$Null -eq '' 
[bool]$Null -eq ''
$Null -eq [bool]''
[bool]$Null -eq [bool]''
# Results
<#
False
True
False
True
#>


$Null -eq '' 
[int]$Null -eq ''
$Null -eq [int]''
[int]$Null -eq [int]''
# Results
<#
False
True
False
True
#>


$Null -eq '' 
[double]$Null -eq ''
$Null -eq [double]''
[double]$Null -eq [double]''
# Results
<#
False
True
False
True
#>



Clear-Host 
0, $null | 
ForEach {
    ('#')*40
    "`nTest `$null as default"
    $null -gt $PSItem
    $null -ge $PSItem
    $null -eq $PSItem
    $null -le $PSItem
    $null -lt $PSItem


    "`n"
    ('#')*40
    "Using $PSItem"
    "`nTest `$null as string"
    "Left Side`tRight Side`tBoth Sides"
    Write-Host ([string]$null -gt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -gt [string]$PSItem) -NoNewline
    Write-Host "`t|`t" ([string]$null -gt [string]$PSItem)

    Write-Host ([string]$null -ge $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -ge [string]$PSItem) -NoNewline
    Write-Host "`t|`t" ([string]$null -ge [string]$PSItem)

    Write-Host ([string]$null -eq $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -eq [string]$PSItem) -NoNewline
    Write-Host "`t|`t" ([string]$null -eq [string]$PSItem)

    Write-Host ([string]$null -le $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -le [string]$PSItem) -NoNewline
    Write-Host "`t|`t" ([string]$null -le [string]$PSItem)


    Write-Host ([string]$null -lt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -lt [string]$PSItem) -NoNewline
    Write-Host "`t|`t" ([string]$null -lt [string]$PSItem)


    "`n"
    ('#')*40
    "Using $PSItem"
    "`nTest `$null as boolean"
    "Left Side`tRight Side`tBoth Sides"
    Write-Host ([boolean]$null -gt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -gt [boolean]$PSItem) -NoNewline
    Write-Host "`t|`t" ([boolean]$null -gt [boolean]$PSItem)

    Write-Host ([boolean]$null -ge $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -ge [boolean]$PSItem) -NoNewline
    Write-Host "`t|`t" ([boolean]$null -ge [boolean]$PSItem)

    Write-Host ([boolean]$null -eq $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -eq [boolean]$PSItem) -NoNewline
    Write-Host "`t|`t" ([boolean]$null -eq [boolean]$PSItem)

    Write-Host ([boolean]$null -le $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -le [boolean]$PSItem) -NoNewline
    Write-Host "`t|`t" ([boolean]$null -le [boolean]$PSItem)

    Write-Host ([boolean]$null -lt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -lt [boolean]$PSItem) -NoNewline
    Write-Host "`t|`t" ([boolean]$null -lt [boolean]$PSItem)


    "`n"
    ('#')*40
    "Using $PSItem"
    "`nTest `$null as int"
    "Left Side`tRight Side`tBoth Sides"
    Write-Host ([int]$null -gt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -gt [int]$PSItem) -NoNewline
    Write-Host "`t|`t" ([int]$null -gt [int]$PSItem)

    Write-Host ([int]$null -ge $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -ge [int]$PSItem) -NoNewline
    Write-Host "`t|`t" ([int]$null -ge [int]$PSItem)

    Write-Host ([int]$null -eq $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -eq [int]$PSItem) -NoNewline
    Write-Host "`t|`t" ([int]$null -eq [int]$PSItem)

    Write-Host ([int]$null -le $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -le [int]$PSItem) -NoNewline
    Write-Host "`t|`t" ([int]$null -le [int]$PSItem)

    Write-Host ([int]$null -lt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -lt [int]$PSItem) -NoNewline
    Write-Host "`t|`t" ([int]$null -lt [int]$PSItem)


    "`n"
    ('#')*40
    "Using $PSItem"
    "`nTest `$null as double"
    "Left Side`tRight Side`tBoth Sides"
    Write-Host ([double]$null -gt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -gt [double]$PSItem) -NoNewline
    Write-Host "`t|`t" ([double]$null -gt [double]$PSItem)

    Write-Host ([double]$null -ge $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -ge [double]$PSItem) -NoNewline
    Write-Host "`t|`t" ([double]$null -ge [double]$PSItem)

    Write-Host ([double]$null -eq $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -eq [double]$PSItem) -NoNewline
    Write-Host "`t|`t" ([double]$null -eq [double]$PSItem)

    Write-Host ([double]$null -le $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -le [double]$PSItem) -NoNewline
    Write-Host "`t|`t" ([double]$null -le [double]$PSItem)

    Write-Host ([double]$null -lt $PSItem) -NoNewline
    Write-Host "`t|`t" ($null -lt [double]$PSItem) -NoNewline
    Write-Host "`t|`t" ([double]$null -lt [double]$PSItem)
}
# Results
<#
########################################

Test $null as default
False
False
False
True
True


########################################
Using 0

Test $null as string
Left Side   Right Side  Both Sides
False   |    False  |    False
False   |    False  |    False
False   |    False  |    False
True    |    True   |    True
True    |    True   |    True


########################################
Using 0

Test $null as boolean
Left Side   Right Side  Both Sides
False   |    False  |    False
True    |    False  |    True
True    |    False  |    True
True    |    True   |    True
False   |    True   |    False


########################################
Using 0

Test $null as int
Left Side   Right Side  Both Sides
False   |    False  |    False
True    |    False  |    True
True    |    False  |    True
True    |    True   |    True
False   |    True   |    False


########################################
Using 0

Test $null as double
Left Side   Right Side  Both Sides
False   |    False  |    False
True    |    False  |    True
True    |    False  |    True
True    |    True   |    True
False   |    True   |    False
########################################

Test $null as default
False
True
True
True
False


########################################
Using 

Test $null as string
Left Side   Right Side  Both Sides
True    |    False  |    False
True    |    False  |    True
False   |    False  |    True
False   |    True   |    True
False   |    True   |    False


########################################
Using 

Test $null as boolean
Left Side   Right Side  Both Sides
True    |    False  |    False
True    |    False  |    True
False   |    False  |    True
False   |    True   |    True
False   |    True   |    False


########################################
Using 

Test $null as int
Left Side   Right Side  Both Sides
True    |    False  |    False
True    |    False  |    True
False   |    False  |    True
False   |    True   |    True
False   |    True   |    False


########################################
Using 

Test $null as double
Left Side   Right Side  Both Sides
True    |    False  |    False
True    |    False  |    True
False   |    False  |    True
False   |    True   |    True
False   |    True   |    False
#>
postanote
  • 15,138
  • 2
  • 14
  • 25
  • 1
    It might point in the right direction, but it is not a founded answer. Does this mean that the the left side defaults to a `string` type if it is `$Null`? (apparently not as this `$Null -eq ''` returns `False` and this `[string]$Null -eq ''` returns `True`). Normally comparison is done based on the type of the left side value, in case that is `$Null` (no left side *type*), I would expect that the comparison is done based on the type of the right side value. – iRon Feb 27 '21 at 10:18
  • 1
    Yet the OP is talking about, `0`, not `''`. If `0` is the use case then, if you do the cast the same on both sides, the result is the same as shown. If you cast only on the right then the results are the as shown in the default or string. Taking you are your point, then `(0).GetType() = Int32, ('').GetType() = String, ($null).GetType() = You cannot call a method on a null-valued expression.` and `$Null -eq '' = False, [string]$Null -eq '' = True, $Null -eq [string]'' = False, [string]$Null -eq [string]'' = True` – postanote Feb 27 '21 at 10:52
  • 1
    That is interesting, that the results mirror the string-casted results. It does raise the question of exactly what type is being used as a basis for the comparison, and how that type is being decided upon. – mmseng Feb 27 '21 at 19:32
  • Yeppers, this `$null`, `''`, `0` comparison thing comes up a lot across multiple Q&A, forum,. blog sites. – postanote Feb 28 '21 at 00:59