Is it an Integer ?

2024-08-15

placeholder
Typescript

In this article I’ll explain the solution the problem to challenge 10969.

The Challenge

How do you determine if a given type is an integer, where an integer is any positive or negative whole number, or zero; ℤ = {x ∈ ℤ | -∞ < x < +∞} ? Let’s check out some test cases -

  Expect<Equal<Integer<1>, 1>>,
  Expect<Equal<Integer<1.1>, never>>,
  Expect<Equal<Integer<1.0>, 1>>,
  Expect<Equal<Integer<1.000000000>, 1>>,

The solution that obviously springs to mind is

type Integer<T> = T extends number ? T : never

Of course the answer isn’t this simple, and the reason is because of how JavaScript treats numbers. Instead of discriminating between integers and floats, js uses the data type Number, which represents all numeric values as a floating point number. Typescript in turn aims to supplement the current EcmaScript standard and adheres to this. And so, test cases like 1.1 will fail, since 1.1 is a valid number according to typescript / javascript.

Let’s take a step back, maybe we could test for integers, just by looking at the shape of the number, i.e

type Integer<T extends number> = `${T}` extends `${infer R}.${infer S}` ? never : T

Here we’re checking if the string matches the shape a.b , where a is the integer part and the b is the decimal part. If it does match this shape, then we know it has the shape of a float and we can return never, else it’s an integer and we can return the integer.

We’re much closer now, floating point numbers like 1.1 and 27.6 will return false, while integers like 10, 20, 30 will return true. However test cases with numbers that evaluate to an integer, such as 1.0, 24.0000 or 0x00 will return false.

So it seems like we need something native in javascript that deals with integers, but is there anything like that ? Yes ! Added in September of 2020 to javascript and included in Typescript 3.2 is BigInt . Typically BigInt is used to deal with, well big integers, numbers that go beyond the scope of the Number primitive, where {x \in \mathbb{R} \mid x < -(2^{53} - 1) \lor x > (2^{53} - 1)}. These two values are defined by Number.MAX_SAFE_INTEGER and Number.MIN_SAFE_INTEGER and values outside this range should be defined BigInt.

A BigInt can be used to instantiate the integer 10, but it needs to be done either via calling the BigInt function BigInt(10) or using the literal 10n. Likewise we could test for a bigint value in typescript via

type Integer<T extends bigint> = T extends bigint ? T : never

If you try to express a float as a bigint and pass in 10.01n , it will error with the message A bigint literal must be an integer.ts(1353) . The only problem remaining is that we can only pass in literal bigints or typeof BigInt() to this type. To pass in numbers, we need to cast the number to bigint. Unfortunately that’s not possible in typescript.

However, we can use the template literal type to cast both the incoming number and the bigint to strings.

type Integer<T extends number> = `${T}` extends `${bigint}` ? T : never

The notation `${bigint}` might look confusing, so let's take a look at a simpler example of

type bools = `${boolean}`
		 //^? = "true" | "false"
		 
type isBool = bools extends boolean ? true : false		 
		//^? false

Here you can see that the string literal template spreads the boolean type and turns each of those subtypes into a string, resulting in the strings “true” and “false”. Now the same occurs with `${bigint}` , where it's expanded to type of "1" | "2" | "3" | "4" | ... . Of course typescript only shows `${bigint}` since the above is type with infinite subtypes.

Now if we look back at the solution, and we pass in the numeric literal 10, it would evaluate into something like this

type Integer<10> = "10" extends "1" | "2" | "3" | ... "10" | ... ? 10 : never

And since the string “10” exists in the set of bigints turned to a string, it returns 10. If you pass in something like 0x00, this is a valid number which is evaluated as 0, converted to “0” and is a valid bigint and thus, 0 is returned. However if you pass in something like 10.01, this is converted to “10.01” which does not exist in the set of string-ified bigints and thus returns never.

And to verify, we can test it against the complete set of test cases

let x = 1
let y = 1 as const

type cases1 = [
  Expect<Equal<Integer<1>, 1>>,
  Expect<Equal<Integer<1.1>, never>>,
  Expect<Equal<Integer<1.0>, 1>>,
  Expect<Equal<Integer<1.000000000>, 1>>,
  Expect<Equal<Integer<0.5>, never>>,
  Expect<Equal<Integer<28.00>, 28>>,
  Expect<Equal<Integer<28.101>, never>>,
  Expect<Equal<Integer<typeof x>, never>>,
  Expect<Equal<Integer<typeof y>, 1>>,
]

and we can see that our solution passes for each of them !