Why Number.isFinite dont have type guard?

Viewed 684
3 Answers

First some background. On first glance, this seems to be related to the difference in JavaScript between

isFinite

which will first convert its argument to number and

Number.isFinite

which happily accepts all arguments, regardless of type, and simply returns true only for finite numbers (with no implicit conversions). In TypeScript these have signatures

function isFinite(number: number): boolean

(method) NumberConstructor.isFinite(number: unknown): boolean

In the second method, the parameter type is unknown instead of number. The reason is that in JavaScript, Number.isFinite happy returns false for non-numbers (whereas isFinite coerces first).

> Number.isFinite(false)
false
> isFinite(false)
true
> Number.isFinite(Symbol(2))
false
> isFinite(Symbol(2))
Uncaught TypeError: Cannot convert a Symbol value to a number at isFinite (<anonymous>)

Now since Number.isFinite is already expected to accept all arguments from any type and return false no matter how wacky the arguments, it makes sense for this behavior to be preserved in TypeScript. Hence the parameter type properly is unknown. For the global isFinite which is intended to work only on numbers (and does the coercion that TypeScript helps us avoid), it makes sense to restrict the parameter to being a number.

But as your question rightly asks, why, given that Number.isFinite will accept any arguments and return true only for numbers, why can't it be a type guard? The reason is that it returns true for some, but not all numbers! Let's try at least to write our own guard:

function numberIsFinite(n: any): n is number {
    return Number.isFinite(n)
}

and these make sense:

console.log(numberIsFinite(20)) // true
console.log(numberIsFinite(Number.MAX_VALUE)) // true
console.log(numberIsFinite(undefined)) // false
console.log(numberIsFinite("still ok")) // false

But here is where things go wrong-ish:

console.log(numberIsFinite(Infinity)) // false but uh-oh
console.log(numberIsFinite(NaN)) // false but uh-oh

Now it's true that with this type guard that

const x: number|string = 3
if (numberIsFinite(x)) {
  console.log(`A number whose type is ${typeof(x)}`)
} else {
  console.log(`A string whose type is ${typeof(x)}`)
}

will print

"A number whose type is number" 

so far so good but:

const x: number|string = Infinity
if (numberIsFinite(x)) {
  console.log(`A number whose type is ${typeof(x)}`)
} else {
  console.log(`A string whose type is ${typeof(x)}`)
}

will print

"A string whose type is number" 

And this is silly.

If there was a dependent type in TS called "finite number" then (and perhaps only then) would making Number.isFinite a type guard make sense.

Credit to @VLAZ, whose answer this explanation is based on. I added the second part to my original answer after reading theirs.

Number.isFinite() is not a type guard because it can lead to weird and wrong behaviour in edge cases.

Number.isFinite(4) -> true and the value is a number. That's fine.
Number.isFinite("apple") -> false and the value is not a number. That's fine.
Number.isFinite(Infinity) -> false but the value is not not a number. That's not good.

Some things in JavaScript are of type number but are not finite. These are the values Infinity, -Infinity and NaN. Yes, it stands for "not a number" but it's of type number. It's confusing but makes sense.1

In most cases, we don't care about these non-finite values.

However, as they are still numbers, they are valid in mathematical operations. The concept of infinity is also mathematical and sometimes can be useful.

Discarding these values leads to sometimes wrong code. Let's assume that Number.isFinite() was declared as a type guard. Then consider this code:

function atLeast(minValue: string | number, x: number): boolean {
    const min: number = Number.isFinite(x)
        ? minValue
        : Number(minValue.replace(/\D/g, "")); //convert
    
    return num >= min;
}
atLeast(2, 42);            //true
atLeast("$3.50", 42);      //true
atLeast(-Infinity, 42);    //error

This would be valid code. Wrong logic and a bit weird but it's here to just illustrate a point. Still, a more realistic usage might be:

let dynamicMinimum = -Infinity; //allow all
/* ... */
someArray.filter(x => atLeast(dynamicMinimum, x));

The code in the function is accepted by TypeScript and when Number.isFinite() returns false type narrowing would cause minValue would not be considered a number and only allowed to be treated as a string by the compiler.

While the example here is contrived and not too hard to figure out it's wrong, you can easily get into a real world situation where you accidentally narrow a value out of being a number when it's still a number. This would be why Number.isFinite() is not a type guard - it can erroneously assert something about a type that is misleading.


1 Here is what I hope is an intuitive explanation why NaN is a number type. In JavaScript values can be changed and some mathematical operations need a number to work with, otherwise they make no sense. But not everything converts to a number. So when you convert a value to a number, you need to somehow signify that it cannot be converted. This is where NaN comes in.

Since the contextual information for what NaN was before conversion, you cannot definitely say it's equal to anything, hence why NaN == NaN is false. An equivalent code might be Number("apple") == Number("orange") - the two values are clearly not equal before the conversion.

Because there is no way to return a value combined with some guard conditions in typescript. What you can do is a) Add an additional check for not undefined values (but I think that in your case you probably will not need Number.isFinite anymore)

function inc (n?: number) {
    return typeof n === "number" && Number.isFinite(n) ? n + 1 : 1;
}

b) or write your own isFiniteNumber guard.

Related