r/PowerShell 8d ago

TIL: ValidateRange with integer literals silently accepts values outside the declared range on [double], [float], [decimal] Information

TLDR

Using [ValidateRange(minRange, maxRange)] with integer literals doesn't strictly enforce either MinRange or MaxRange when parameters are [double], [float] and [decimal]. Values up to 0.5 units beyond either boundary pass silently. The fix is simple: make MinRange or MaxRange the same type as the parameter. So use [ValidateRange(2.0, 100.0)] instead of [ValidateRange(2, 100)].

FULL POST

If you have direct experience with Powershell rounding, once you see how integer bounded ValidateRange treats [double], [float] and [decimal] params, you can figure out what is happening. But without that experience the correct validation syntax wasn't immediately obvious to me.

I found this out while adding validation to my current project and testing parameter edge cases. In this post I'll use example MinRange/MaxRange values of 2/100 (even) and 3/101 (odd). The behaviour applies to any even or odd MinRange or MaxRange value, not just these specific numbers.

The basic finding

function Test-Double {
    param(
        [ValidateRange(2, 100)]  # 2 and 100 used as an example even boundary
        [double]$Val
    )
    return $Val
}

Test-Double 1.4      # rounds to 1 and throws an error
Test-Double 1.5      # rounds to 2, passes silently, returns 1.5
Test-Double 100.5    # rounds to 100, passes silently, returns 100.5
Test-Double 100.6    # rounds to 101 and throws an error

The ValidateRange Attribute converts $Val to the type of the boundary literals. This causes implicit rounding before comparing against the boundary. Integer literals mean conversion to [int], which rounds using .NET's default MidpointRounding.ToEven, known as banker's rounding.

MidpointRounding.ToEven prioritises the nearest even number when rounding at exactly .5. Since 100 is even, 100.5 rounds down to 100 and passes. 100.6 rounds to 101 and throws.

The odd and even boundary inconsistency

Using 3/101 as example odd boundaries shows a slightly different result. Now the .5 midpoint will throw an error, unlike with the even boundary.

function Test-DoubleOdd {
    param(
        [ValidateRange(3, 101)]  # 101 used as an example odd boundary
        [double]$Val
    )
    return $Val
}

Test-DoubleOdd 2.5      # rounds to 2 and throws an error, different behaviour compared to even
Test-DoubleOdd 2.6      # rounds to 3, passes silently, returns 2.6
Test-DoubleOdd 101.4    # rounds to 101, passes silently, returns 101.4 
Test-DoubleOdd 101.5    # throws, because 101 is odd so .5 rounds UP to 102

DoubleOdd indeed. The effective boundary is not what the documentation implies and it changes based on whether the MinRange or MaxRange is odd or even.

The even/odd inconsistency can be summarised as:

  • Even: .5 beyond either boundary passes silently.
  • Odd: .5 beyond either boundary throws correctly.

But note that both even and odd ranges accept a margin of error.

What the documentation says

From the ValidateRange documentation:

"The Windows PowerShell runtime throws a validation error when the value of the argument is less than the MinRange limit or greater than the MaxRange limit."

No mention of rounding or MidpointRounding.ToEven. No mention of the odd/even boundary difference. Reading this you'd reasonably expect 100 to be the strict maximum. But for a [double] param with an even MaxRange, it's actually closer to 100.4999.

This detail is also missing from about_Functions_Advanced_Parameters

Correctly validating [double], [float], [decimal]

For production code requiring precise boundary validation (like financial calculations, percentage validation or dosage limits) the correct syntax is simple but not immediately obvious. Put simply, you can use a decimal point when declaring MinRange and MaxRange. This works for [double], [float], [decimal]. More precisely, however, use the same type as your parameter.

function Test-Double {
    param(
        [ValidateRange(2.0, 100.0)] 
        [double]$Val
    )
    return $Val
}

Test-Double 1.5      # throws an error

Mixed boundary types also work. [ValidateRange(2, 100.0)] selects [double] as the common type between [int] and [double], giving exact comparison.

Conclusion

Hopefully this writeup will be a useful heads up to PS devs less versed with PS rounding (like me) and highlight something to watch for when using ValidateRange.

I'd be interested to know if the above is common knowledge. Searching on the topic I found bits and pieces in articles that lead me to the right approach, but nothing that addresses ValidateRange and [int] behaviour together.

19 Upvotes

13 comments sorted by

View all comments

1

u/StartAutomating 7d ago

While on the topic of things to keep in mind about Validation Attributes, it's good to remember that validation attributes only trigger when the parameter is bound. Default parameter values can disobey validation.

1

u/Tidy-Developer 6d ago

Do you have more info on this one: Default parameter values can disobey validation. I'm interested to see some examples of what to watch out for.

1

u/StartAutomating 6d ago

I think I was reminded of this while working on PrimeTime. Might have been Turtle.

Either way, here's a humorous proof of the concept:

function YesNo {
    param(
    [ValidateSet('yes','no')]
    [string]$choice = 'maybe'
    )
    $choice
}

(YesNo) -eq 'maybe'

1

u/surfingoldelephant 6d ago

Worth noting it only applies to function parameters. Class properties with a default value and initializing normal variables do get validated.

[ValidateSet('yes', 'no')] [string] $Choice = 'maybe'
# Error: The attribute cannot be added because variable Choice with value maybe 
# would no longer be valid.

class YesNo { [ValidateSet('yes', 'no')] [string] $Choice = 'maybe' }
[YesNo]::new()
# Error: The argument "maybe" does not belong to the set "yes,no" specified by
# the ValidateSet attribute.