C# vs JavaScript: Numbers

This post belongs to the C# vs JavaScript series.
by Nicklas Envall

That JavaScript is not good with decimal fractions is well-known. However, this regularly used phrase leads many developers to believe that the problem magically only exists in JavaScript. Although admittedly, JavaScript has only one number type that can hold fractions. Thus, the problem is more prominent in JavaScript than in a language with many number types like C#. Yet, the issue itself existed long before JavaScript. So before we compare the two languages' approaches to numbers, let's teleport ourselves back to a time where our ancestors depended on repetitive drawings. A time where to write down that you had ten dogs, you would have to draw a dog ten times.

As you might expect, drawing each object that you want to represent repetitively is inefficient. So our ancestors later realized that it's less effort to use a stick to symbolize one. Consequently, they then only had to draw one dog and ten lines. But as the world evolved, it became essential for trade and communications to invent even better ways of counting and doing computations. Evidently, we needed a way to represent numbers. We needed a number system. As a result, we humans created many throughout different parts of the world, like Egypt, Greece, China, and much more.

With this in mind, let's look to ancient Rome. Because you see, the Romans invented the Roman numeral system, that offered seven symbols:

I = 1
V = 5 
X = 10
L = 50
C = 100 
D = 500 
M = 1000

Their system is base-10 and was originally purely additive. It being additive meant that the symbol's position did not matter. Instead, because each symbol holds a fixed value, you would add all those values together to get the sum. For example, LXX equals 70, and by inspecting it, we see that each X still means 10 even though they have different positions. We can view it as, 50 + 10 + 10 = 70.

The system itself had flaws that made it hard to do more advanced mathematics, like multiplication and division. Yet despite its flaws, it was adapted throughout Europe and continued being used even after the Roman empire's decline. Though, note that it got improved with time. For instance, subtractive notation got introduced where you got abbreviations like IV. Lastly, the Roman numeral system is used to this day, although mainly only for artistic purposes.

The Hindu−Arabic/Indo-Arabic positional number system is what's widely used throughout the world today. It was developed in India and used for a long time in the Middle East before being picked up in Europe around 1200-1600. It provides ten symbols:

{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }

Contrary to the Roman system, it has a symbol for zero, which enables positional notation. For example, as you see in the example below, when we reach the number nine, we reset a digit on the right and add a "1" on the left, and as a result, get ten.

...
8
9
10 <-- reset digit on right and add 1
11
12
...

Because the decimal system is positional, a digit is multiplied by 10^i. Where i equals the digit's position in the number, and 10 is the base of the number system. Take 5641,39 for example:

5 * 10^3 = 5000
6 * 10^2 = 600
4 * 10^1 = 40
1 * 10^0 = 1
3 * 10^-1 = 0.3
9 * 10^-2 = 0.09

Most of us use this system today. But despite all the progress we've made with it, who knew that the same line that our ancestors drew would be what helped bridge the gap between arithmetics and electricity, with its' friend 0.

Binary Numbers

Computers operate on ones and zeros, and their language is binary. The binary numeral system is positional and base-2, and as a result, it provides two symbols, 0 and 1. These binary digits (bits) are used together to forge binary numbers, and the example below shows 8 to 12 in binary:

...
1000
1001
1010
1011
1100
...

We can use the positional notation method to convert binary to decimal. For instance, the binary representation of the decimal number 9 is 1001 as proven below:

1 * 2^3 = 8
0 * 2^2 = 0
0 * 2^1 = 0
1 * 2^0 = 1

Then you can use division to convert decimal into binary because division "undoes" multiplication.

9 / 2 = 4  remainder = 1
4 / 2 = 2  remainder = 0
2 / 2 = 1  remainder = 0
1 / 2 = 0  remainder = 1

With this in mind, we now have a way to represent positive integers. Subsequently, if we utilized two's complement, then we could represent negative integers. But how do we store a ratio between two numbers? For example, 1/2 (0.5 in decimal fraction form). More mathematically speaking, I'm referring to rational numbers and irrational numbers.

  • Rational numbers: either stop or repeats a pattern. So 4.5 is rational because it's finite, while 1.333333... is rational because it repeats a pattern.
  • Irrational numbers: never stop, and do not repeat a pattern. Examples of irrational numbers are π, √2, and e.

One way to store fractions is to use the fixed point system. We divide the bits into three fields:

  1. 1-bit for the sign of the number.
  2. Bits for the binary number before the binary point.
  3. Bits for the binary number after the binary point.

For example, with 32 bits, the ratio 22/4 (5.5) could look like:

0 | 000000000000101 | 1000000000000000

Because:

1 * 2^2 = 4
0 * 2^1 = 0
1 * 2^0 = 1
1 * 2^-1 = 0.5

In addition to the fixed point system, we now have a more common way. That is to say, with the introduction of the IEEE standard 754, we got another method to represent fractions, the floating-point format. It gives us different floating-point formats like Single-Precision (32 bits) and Double-Precision (64 bits), and in short, a standard for floats.

How a floating-point gets stored is similar to scientific notation, which is a way to express numbers. To refresh our memory, we have the scientific notation formula below, where S stands for significand and E for exponent:

x = S * 10^E

So, for instance, 553.2 in scientific notation is 5.532 * 10^2, and as we see, we had to move the decimal point. After the decimal point gets moved, it's in its so-called normalized form. Though, it's moved because the significand is always 1 <= S < 10 by convention. So, the decimal point "floats" to the correct position, and that's why we ended up with the name floating-point.

Recognizing that our computers work with binary, we must now go from using decimal scientific notation to binary scientific notation, where the significand should be 1 <= S < 2, and the base 2. So the ratio 22/4 (5.5) is 101.1 in binary. In binary scientific notation, it would be 1.011 * 2^2. As a result, we now have a binary point instead of a decimal point.

Then to store this value, we end up with the following formula:

(-1)^s * 1.f * 2^e-b

Where:

  • s stands for sign, and indicates if the number is negative or positive.
  • f is the significand fraction.
  • e stands for exponent.
  • b stands for bias.

Then by using the so-called single-precision format, we could divide it into three fields:

  1. A 1-bit field for the sign of the number.
  2. An 8-bit field for the exponent.
  3. A 23-bit field for the significand.

There's more to floats, and I've intentionally left out parts because we could write an entire book on the subject. Instead, let's shift our attention to so-called rounding issues, before we start comparing JavaScript and C#.

Rounding Issues

Our binary numbers can continue forever, while our computers only have a finite amount of bits available. So to make these kinds of binary numbers storable in a field, we need to round the significand by omitting some bits. Consequently, we cannot represent every number 100% accurately, which sadly may lead to unexpected results. Because whether we like it or not, rounding essentially entails making a less precise version of the original number. In addition, many decimal numbers have an infinite representation in binary.

For example, 0.1 in decimal continues forever in its binary representation. Why would 1/10 repeat forever in binary? The answer is that in base-2, rationals that have denominators that are powers of 2 stop. Else, they'll be infinite. To see for yourself, divide 1 with 1010 using long division. Because by doing that, you'll quickly see that a never-ending pattern emerges:

0.0001100110011001100110011001100110011...

Then, in a double-precision floating-point, we would have a 53-bit field for the significand. So we would store the following:

0.0001100110011001100110011001100110011001100110011001101

Subsequently, if we would convert that to decimal, then we would get:

0.10000000000000000555

As we see, our computer has not stored 0.1. Instead, it has stored an approximation of the value, which in this case is higher than 0.1.

C# Numbers

So far in this series, there has been a pattern of first looking at JavaScript and then C#. However, I will now break that pattern because C# has more number types.

Integers

Integers are whole numbers, including negative numbers, without fractions. So { ... −3, −2, −1, 0, 1, 2, 3, ... } are integers, while 1/2 or 0.75 aren't since they're fractions. We have different types of integers in C#, and the more bits they have, the more numbers they can represent.

  • Byte: 8-bit
  • Short: 16-bit
  • Int: 32-bit
  • Long: 64-bit

For example, declaring three different integer variables and then adding a and b together works as you probably would expect:

int a = 1;
int b = 2;
int c = a + b;
c; // 3

But as we know, integers cannot hold fractions. So what would happen if we divide 6 by 4? Since mathematically speaking, that would give us 1.5, and 4 / 6 = 0.66....

6 / 4; // 1, despite being 1.5
4 / 6; // 0, despite being 0.66...

As you see, that's not the case in C#. Because integer division always results in an integer by truncating (removing) the remainder. Thus if we want to get the remainder of 6 / 4, then we could do something like this:

int dividend = 6;
int divisor = 4;
int quotient = dividend / divisor; // automatically truncated
int remainder = dividend - quotient * divisor;
remainder; // 2

But we don't need to do this every time. Instead, luckily, the remainder operator % saves us from this. With which you do dividend % divisor, and you'll get the remainder. It essentially works the same as in JavaScript, and you can read how to use the remainder operator in JavaScript here.

6 % 4; // 2
4 % 6; // 4

Lastly, a note on memory, for example, an int can store 32 bits, and that means it can represent 2^32(4294967296) different numbers. If we do int.MaxValue, we get 2147483647. Secondly, with int.MinValue, we get -2147483648. But, 2147483648 + 2147483647 equals 4294967295 not 4294967296. However, with 0, we have 4294967296 different numbers in the end. There are also so-called unsigned number types, and in short, those types only handle positive numbers and therefore have 1 bit extra.

Floating-Point Types

C# provides the following floating-point types:

  • Float: 32-bit
  • Double: 64-bit
  • Decimal: 128-bit

From the introduction, we know that the IEEE standard provides two key formats, Single-Precision (float) and Double-Precision (double). But, decimal is new. Although it essentially works the same, it gets interpreted as base-10. Consequently, decimal avoids the rounding errors when working with decimal data that the binary floating-points float and double suffers from:

float num1 = 0.22f + 0.7f;
double num2 = 0.1d + 0.2d;
num1; // 0.91999996
num2; // 0.30000000000000004

decimal num3 = 0.22m + 0.7m;
decimal num4 = 0.1m + 0.2m;
num3; // 0.92
num4; // 0.3

For this reason, it's more viable for banking. Still, keep in mind that if it's a fraction that reoccurs in base-10, then neither decimal nor double can handle it:

decimal oneThird = 1m / 3m;
decimal one = oneThird + oneThird + oneThird;
one; // 0.9999999999999999999999999999

JavaScript Numbers

JavaScript's number is a double-precision 64-bit binary format IEEE 754 value. As a consequence, we can get strange results when working with decimal fractions. But, in the introduction, we saw that this is nothing unique to JavaScript. Nevertheless, many other languages do have more built-in solutions, like types, to mitigate those issues.

JavaScript can still represent integers, despite it not having an integer type, since 1 === 1.0. Just remember that it's still a floating-point:

10 / 4;             // 2.5
Math.floor(10 / 4); // 2

Then, we should note that it can only exactly and safely represent all integers from -9007199254740991 (Number.MIN_SAFE_INTEGER) to 9007199254740991 (Number.MAX_SAFE_INTEGER). Because strange things will start to happen if we go outside of that range:

Number.MIN_SAFE_INTEGER - 4;      // -9007199254740996 🤔
Number.MIN_SAFE_INTEGER - 3;      // -9007199254740994
Number.MIN_SAFE_INTEGER - 2;      // -9007199254740992 🤔
Number.MIN_SAFE_INTEGER - 1;      // -9007199254740992
Number.MIN_SAFE_INTEGER;          // -9007199254740991
Number.MIN_SAFE_INTEGER + 10;     // -9007199254740981
Number.MIN_SAFE_INTEGER + 10**12; // -9006199254740991
(-100).toPrecision(10);           // "-100.0000000"
(0).toPrecision(10);              // "0.000000000"
(100).toPrecision(10);            // "100.0000000"
Number.MAX_SAFE_INTEGER - 10**12; // 9006199254740991
Number.MAX_SAFE_INTEGER - 10;     // 9007199254740981
Number.MAX_SAFE_INTEGER;          // 9007199254740991
Number.MAX_SAFE_INTEGER + 1;      // 9007199254740992
Number.MAX_SAFE_INTEGER + 2;      // 9007199254740992 🤔
Number.MAX_SAFE_INTEGER + 3;      // 9007199254740994
Number.MAX_SAFE_INTEGER + 4;      // 9007199254740996 🤔

More practically speaking, we have a Number object that has properties like Number.MAX_SAFE_INTEGER and Number.POSITIVE_INFINITY. It also has static methods like Number.isInteger() and Number.isSafeInteger().

Not to mention the Number.prototype object, which provides instance methods, like Number.prototype.toFixed() that we can use to help us with rounding issues in some cases:

0.1 + 0.2;              // 0.30000000000000004
(0.1 + 0.2).toFixed(1); // "0.3"

Or why not use it to sneak peek at what JavaScript is actually storing for 0.1?

(0.1).toFixed(100); // "01000000000000000055511151231257827021181583404541015625000000000000000000000000000000000000000000000"

Finally, the Number and the Number.prototype objects are sometimes not sufficient, so then you'd probably be using the built-in Math object. But I'll leave that for a future article.

BigInt

In reality, ECMAScript has two built-in numeric types: number and BigInt. It makes sense to use BigInt when you want to use integers, and the safe integer range from -9007199254740991 to 9007199254740991 is not sufficient.

To create a BigInt, suffix your integer with n:

9007199254740993;  // 9007199254740992
9007199254740993n; // 9007199254740993n

A BigInt cannot hold fractions, just like int in C#:

6 / 4;   // 1.5
6n / 4n; // 1

Lastly, most comparisons between number and BigInt are fine. But, be aware that you cannot mix arithmetic operations with number and BigInt. Although, you can explicitly convert them beforehand at your own risk.

1n + 2;   // TypeError: can't convert BigInt to number
3n > 1;   // true
3n < 1;   // false
3n === 3; // false
3n == 3;  // true

The more number types the better?

Douglas Crockford has both in talks and the book "How JavaScript Works," said that it's a good thing that JavaScript only had one numerical type originally. Then going on to say that BigInt was a mistake because it was already possible to achieve the same with libraries. Therefore, it's not justifiable to lose that simplicity in the language by implementing BigInt. Other languages like C# don't have the same simplicity with their byte, short, int, float, and double, which may lead to hard-to-reason code and bugs.

Now, I've seen online that people argue that natively having BigInt helps us avoid libraries and makes it more performant. Still, I'm leaning towards the idea that having one number type is better for simplicity reasons. What do you think?

Nevertheless, Crockford also points out that even though JavaScript's number is a good idea, it's still the wrong type. It should've been a decimal floating-point, not a binary one. The reasoning for this is that binary floats cannot represent all decimal digits correctly. Yet, those digits are the ones we humans care about the most.

I think it's some interesting points that give us some perspective on the whole JavaScript versus C# number mindset.

Converting Numbers

So far, we've covered numbers and different bases. Let's end this post by converting from and to hexadecimal in C# and JavaScript. Because it's easy for computers to work with 1s and 0s, but they get too long for us humans. So sometimes it makes sense to use the base-16 number system, hexadecimal:

0 = 0
1 = 1
2 = 2
3 = 3
4 = 4
5 = 5
6 = 6
7 = 7
8 = 8
9 = 9
A = 10
B = 11
C = 12
D = 13
E = 14
F = 15

In JavaScript, it's easy to convert decimal to hexadecimal. Just do the following with toString(radix), and you'll get a string back:

(15).toString(16);   // "f"
(101).toString(16);  // "65"
(1000).toString(16); // "3e8"

Then to convert from hexadecimal to decimal, you can prefix the value with 0x. Else, if you have a string, then you can use parseInt(string, radix) instead:

0xf;   // 15
0x65;  // 101
0x3e8; // 1000

parseInt('f', 16);   // 15
parseInt('65', 16);  // 101
parseInt('3e8', 16); // 1000

In C#, there are many different methods available, so we'll use one of the many approaches. For example, the System.Convert class "converts a base data type to another base data type" and provides a static ToString method. We can also use Int32.ToString(string).

Convert.ToString(15, 16);   // f
Convert.ToString(101, 16);  // 65
Convert.ToString(1000, 16); // 3e8

15.ToString("x");           // f
101.ToString("x");          // 65
1000.ToString("x");         // 3e8

C# also has a parse method:

0xf;   // 15
0x65;  // 101
0x3e8; // 1000

Convert.ToInt32("f", 16);   // 15
Convert.ToInt32("65", 16);  // 101
Convert.ToInt32("3e8", 16); // 1000