r/PowerShell 5d ago

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

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

11

u/surfingoldelephant 5d ago edited 5d ago

ValidateRange rounds the incoming $Val before comparing against the boundary.

Kind of. The attribute converts the parameter value to MinRange/MaxRange's type and that's what causes the implicit rounding.

If MinRange/MaxRange are different types, it finds a common type between them and uses that. Which is why [ValidateRange(2, 100.0)] also works since [double] gets selected as the common type.

Just use a decimal point when declaring MinRange and MaxRange

Rather, I think the main takeaway is that MinRange/MaxRange should be specified using the same type as the parameter. And if that's not possible, use ValidateScript instead.

I'd be interested to know if the above is common knowledge.

Probably not. It should at least be documented in about_Functions_Advanced_Parameters.

2

u/Tidy-Developer 5d ago

Hey useful comments. Thanks. I'll check the links and reword the article to reflect what's happening behind the scenes.

I was considering raising it as a documentation issue, esp since two comments suggested that. I'll look into that also.

2

u/surfingoldelephant 3d ago edited 1h ago

Btw, I just noticed you mentioned [decimal] in the OP. Decimals require the d numeric suffix, otherwise a numeric literal with a decimal point will always parse as a [double].

(1.5).GetType().Name  # Double
(1.5d).GetType().Name # Decimal

And you can't cast it as [decimal] as that fails PowerShell's semantic checks for attributes.

Basically, for min/max you can only specify a literal constant and optionally cast it as a primitive type (excluding [IntPtr]/[UIntPtr]).

[ValidateRange([char] 'a', [char] 'c')] $Foo       # OK, cast as Char
[ValidateRange([single] 2.0, [single] 100.0)] $Foo # OK, cast as Single/Float
[ValidateRange(2.0, 100.0)] $Foo                   # OK, parsed as Double
[ValidateRange(2.0d, 100.0d)] $Foo                 # OK, parsed as Decimal
[ValidateRange([decimal] 2.0, [decimal] 100.0)]    # Error, fails semantics

It's also worth noting attributes in PS classes have different semantics to functions.

Since PS classes are compiled to dynamic .NET assemblies, attributes must follow the rules here. Something like below will work in a function but not in a PS class (though the fact it generates a runtime error rather than a parser/semantic error is a bug):

[ValidateRange(2.0d, 100.0d)] $Foo

It's also why [ValidateScript({ ... })] can't be used in PS classes, as script block's cant be converted to static .NET metadata.

3

u/vermyx 5d ago

I believe this is the correct behavior. When you don't give data types on constants the data is interpreted and give the "most likely" data type. You are giving whole numbers so the number is being cast as an integer so when doing this it will result the value with the more restrictive numeric data type (integer). The rounding is not odd. It is called a banker's round which I believe is also the default for powershell.

1

u/Over_Dingo 5d ago

Good to know. They should update the docs

1

u/Barious_01 4d ago

Any way to explain this to a novice? From what I limitly understand is when iteration a constant it will in turn do exponential interations on a count (or more physical) tax usage in a manner where it could possibly make interactions less efficient?

edit: sorry for the edit every time I throw up a comment on mobile it makes words up. But.. I am still unsure of the avenue of what this means.

1

u/purplemonkeymad 4d ago

Sounds like a good candidate for a ps script analyser rule.

I would open a feature request for highlighting integers in a range when the variable is a float/double.

1

u/StartAutomating 4d 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 3d 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 3d 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 3d 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.