Search code examples
gogenericstype-assertion

How to compare generic number type


I have a validation function Positive, it's works but looks ugly.

type Positiver interface {
    decimal.Decimal | int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
}

//nolint:cyclop
func Positive[T Positiver](value T, name string, errs *[]error) {
    addError := func() {
        err := fmt.Errorf(`%s %w, but it's %v`, name, failures.ShouldBePositive, value)
        *errs = append(*errs, err)
    }

    const prescision = 8

    switch val := any(value).(type) {
    case decimal.Decimal:
        if val.IsNegative() || val.IsZero() {
            err := fmt.Errorf(`%s %w, but it's %s`, name, failures.ShouldBePositive, val.StringFixedBank(prescision))
            *errs = append(*errs, err)
        }

        return
    case int:
        if val <= 0 {
            addError()
        }
    case int64:
        if val <= 0 {
            addError()
        }
    case int32:
        if val <= 0 {
            addError()
        }
    case int16:
        if val <= 0 {
            addError()
        }
    case int8:
        if val <= 0 {
            addError()
        }
    case uint:
        if val <= 0 {
            addError()
        }
    case uint64:
        if val <= 0 {
            addError()
        }
    case uint32:
        if val <= 0 {
            addError()
        }
    case uint16:
        if val <= 0 {
            addError()
        }
    case uint8:
        if val <= 0 {
            addError()
        }
    case float32:
        if val <= 0 {
            addError()
        }
    case float64:
        if val <= 0 {
            addError()
        }
    default:
        panic(fmt.Sprintf(`%T is not supported type`, val))
    }
}

I know that is bad approach to use []error, it's better just to return a wrapped error. But it's a compatibility issue.

I tried to do like this:

func Positive[T Positiver](value T, name string, errs *[]error) {
    switch val := any(value).(type) {
    case decimal.Decimal:
        if val.IsNegative() || val.IsZero() {
            err := fmt.Errorf(`%s %w, but it's not`, name, failures.ShouldBePositive)
            *errs = append(*errs, err)
        }

        return
    case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
        if val.(int64) <= 0 {
            err := fmt.Errorf(`%s %w, but it's not`, name, failures.ShouldBePositive)
            *errs = append(*errs, err)
        }

        return
    case float32, float64:
        if val.(float64) < 0 {
            err := fmt.Errorf(`%s %w, but it's not`, name, failures.ShouldBePositive)
            *errs = append(*errs, err)
        }

        return
    default:
        panic(fmt.Sprintf(`%T is not supported type`, val))
    }
}

But this approach returns me an error: test panicked: interface conversion: interface {} is int, not int64

What is a better way to compare that value exceeds zero?


Solution

  • Your code doesn't work with "interface conversion: interface {} is int, not int64" because in multiple-type case the type switch variable val keeps its original type. See also: golang multiple case in type switch for details.

    So in this case you have indeed to assert to something in order to use the order operators. That "something" could be a type parameter.

        case int, int8, int16, int32, int64:
            if val.(T) <= 0 {
                // ...
            }
    

    BUT this code still can't use the order operator because the constraint Positive includes decimal.Decimal, which doesn't support ordering.

    Attempting to write a case for decimal.Decimal and a case for other numeric types won't work well either, because you haven't a good way to reduce the type set of a type constraint. You are back writing one case for each type. One day Go might allow using union constraints in type switches.

    What you can do today is to statically handle decimal.Decimal and other numeric types differently. You can use the types in the package constraints to avoid redeclaring everything: Signed, Unsigned and Float. Then a naive function with only numerical types is as simple as this:

    func StrictlyPositive[T Signed | Unsigned | Float](v T) bool {
        return v > 0
    }
    

    BUT with floats, using < is not enough. Float variables could also be NaN or +/-infinity. You have to decide how to order NaNs; infinities have the sign bit, but IMO it's better to use math.IsInf to not hide stuff behind the order operators.

    So in conclusion I think this function is better off with reflection, which might be slower, but the code doesn't totally suck. The following is a simplified version of your example:

    func CheckPositive[T Positive](value T) string {
        switch val := any(value).(type) {
        case decimal.Decimal:
            if val.IsNegative() || val.IsZero() {
                return "non positive decimal"
            }
    
        case int, int8, int16, int32, int64:
            if reflect.ValueOf(val).Int() <= 0 {
                return "non positive signed"
            }
    
        case uint, uint8, uint16, uint32, uint64:
            if reflect.ValueOf(val).Uint() == 0 {
                return "non positive unsigned"
            }
    
        case float32, float64:
            f := reflect.ValueOf(val).Float()
            switch {
            case math.IsNaN(f):
                return "NaN float"
            case math.IsInf(f, -1):
                return "negative infinite"
            case math.IsInf(f, 1):
                // do nothing
            default:
                // not a NaN and not an Infinite
                if f <= 0.0 {
                    return "negative float"
                }
            }
    
        default:
            panic(fmt.Sprintf(`%T is not supported type`, val))
        }
        return "positive"
    }