C#: Numeric Data Types In A Nutshell

Shiran Abbasi
11 min readJan 5, 2023

--

In C#, there is full scale availability of all the data types that can be used to represent anything inside a computer.

In this article, we are particularly concerned about numeric data types in C#. So without further ado, lets get started.

Numeric Data Types

Numeric data is of two types:

Integer Types

To represent integers we have 8 data types in C# that represent varying ranges of integers.

First we have byte that allows all positive integers from 0 to 255.
The byte type also has a signed variant named sbyte that represents integers from -128 to positive 127.

byte b = 255 // 0 to 255
b = -45 // gives compile time error

sbyte sb = -78 // -128 to 127

Note: It is a compile time error to assign negative values to unsigned data types.

Next we have short, int and long with unsigned variants named as ushort, uint, ulong.

short is a 16 bit integer type that allows integers from -32768 to 32767. Its unsigned variant named ushort ranges from 0 to 65535.

int is a 32 bit integer type that allows integers from -2147483648 to 2147483647. Its unsigned variant named uint ranges from 0 to 4294967295.

long is a 64 bit integer type that allows integers from -9223372036854775808 to 9223372036854775807. Its unsigned variant named ulong ranges from 0 to 18446744073709551615.

Note: When you declare the variables of long type using var keyword, you must suffix the long literal with l or L. For unsigned long (ulong), you have the suffix ul or UL. Although capital L and UL are preffered for the sake of clarity.

var l = -90L; // long variable
Console.WriteLine(l.GetType()); // prints System.Int64 (alias for long)

var ul = 90UL; // unsigned long (ulong) variable
Console.WriteLine(ul.GetType()); // prints System.UInt64 (alias for ulong)

The table below summarizes the range of values for the above integer types.

Table summarizing the integral data types with aliases, ranges and suffixes (for literals).

By the way, you can use MinValue and MaxValue methods to get the minimum and maximum allowable values for any integer type.

See the example below

// prints 0 to 255
Console.WriteLine($"{byte.MinValue} to {byte.MaxValue}");

// prints -128 to 127
Console.WriteLine($"{sbyte.MinValue} to {sbyte.MaxValue}");

// prints -32768 to 32767
Console.WriteLine($"{short.MinValue} to {short.MaxValue}");

// prints 0 to 65535
Console.WriteLine($"{ushort.MinValue} to {ushort.MaxValue}");

// prints -2147483648 to 2147483647
Console.WriteLine($"{int.MinValue} to {int.MaxValue}");

// prints 0 to 4294967295
Console.WriteLine($"{uint.MinValue} to {uint.MaxValue}");

// prints -9223372036854775808 to 9223372036854775807
Console.WriteLine($"{long.MinValue} to {long.MaxValue}");

// prints 0 to 18446744073709551615
Console.WriteLine($"{ulong.MinValue} to {ulong.MaxValue}");

Integer types allow all the basic arithmetic operations i.e. addition, multiplication, subtraction and division.

Beside standard number literal, integer literals can also be written in other forms.

Digits Separator: You can write integer literals with separator for clarity like

byte b = 1_23;
short s = -123_45;
int i = 123_456_789;
long l = 123_456_789_34;

Console.WriteLine(b); // prints 123
Console.WriteLine(s); // prints -12345
Console.WriteLine(i); // prints 123456789
Console.WriteLine(l); // prints 12345678934

These digits separators can be placed any where in the literal.

Binary Literals: You can write integer literals in binary as well. Binary literals starts with 0b/0B. Beginning with C# 7.2, you can also start the binary literal with an underscore (_) as well like 0b_/0B_.

See the example below

byte b = 0b_101; // 5
short s = -0b111; // -7
int i = 0b_110; // 6
long l = 0B_10001111; // 143

Hexadecimal Literals: You can write integer literals in hexadecimal as well. Hex literals starts with 0x/0X. Beginning with C# 7.2, you can also start the binary literal with an underscore (_) as well like 0x_/0X_.

Hexadecimal literals can have digits:

  • 0 to 9
  • a/A to f/F (a/A = 10, b/B = 11, …, f/F = 15)

See the example below

byte b = 0X_7F; // 127
short s = -0x111A; // -4378
int i = 0X010; // 16
ulong l = 0x_FFFFFFFFFFFFFFFF; // 18446744073709551615

Earlier I mentioned there are 8 data types for representing integers. Sorry that was not the whole truth. There is also a special integer type char that is used to represent characters like a, b, A, *, 9 etc. The char type also store integers internally but outputs the character representation when printed. For example when we store the letter j, it basically stores 106 internally to represent the letter. Character literals are enclosed in single quotation marks. Char literals can also be represented using hex or unicode notation as can be seen in the example below

char ch = 'j'; // stores 106 internally
char ch1 = '\x006A'; // hex notaion for j
char ch2 = '\u006A'; // unicode code point notation for j

Cosole.WriteLine(ch); // prints j
Cosole.WriteLine(ch1); // prints j
Cosole.WriteLine(ch2); // prints j

char type is a 16 bit unsigned type that allows values from 0 to 65535. The underlying .NET type is System.Char. Char is basically capable of representing unicode code points.

Real Number Types

In C#, real numbers having digits after the decimal point, can be represented using 3 data types: float, double and decimal.

float is a 32 bit real number type that allows values from 1.5E-45 to 3.4E+38. The underlying .NET type is System.Single. It is a single precision floating point type that confirms to the IEEE-754 standard for floating point representation and allows up to 7 digits after the decimal point.

double is a 64 bit real number type that allows values from 5E-324 to 1.7E+308. The underlying .NET type is System.Double. It is a double precision floating point type that confirms to the IEEE-754 standard for floating point representation and has maximum precision of 17 digits.

float f = 3.2F;
Console.WriteLine(f); // prints 3.2

f = 7.8923738839F;
Console.WriteLine(f); // prints 7.892374

double d = 2.43403930233209;
Console.WriteLine(d); // prints 2.43403930233209

Note: You must use suffix F or f with float literals because by default the real number literals are considered double by the compiler.

Notice that the last result in above example was rounded to 7.892374. That is the expected behavior when the precision of the number exceeds the maximum allowed digits.

Operations with Float and Double

All the primitive arithmetic operations (addition, multiplication, subtraction and division) that are allowed with integer types are also permitted with real numbers.

But there are some limitations that can be seen with examples below

double d = 0.1;
Console.WriteLine(d * 3); // prints 0.30000000000000004

The example doesn’t make sense right! But why the result is not just 0.3 and instead represented with unnecessary digits after the actual result. This is because float and double types are handled using floating point unit inside the computer hardware and since the binary representation of digits after the decimal point is inaccurate, the result is also inaccurate.

The float and double types are not suitable for monetary calculations (calculations involving money) because of their inaccuracy.

To overcome this limitation, the framework designers came up with the type decimal. The decimal type is a 128 bit real number type that allows values from 1E-28 to 7.9E+28. The underlying .NET type is System.Decimal. It has maximum precision of 29 digits.

Let’s see the above example with decimal type

decimal d = 0.1M;
Console.WriteLine(d * 3); // prints 0.3

Note: You must use suffix M or m with decimal literals because by default the real number literals are considered double by the compiler.

To compare the real numbers (float, double and decimal), there is a method CompareTo that comes handy. This method is preffered because real numbers don’t have exact binary representations. The example below prints false because of this limitation.

For example:

Console.WriteLine(0.1 + 0.1 + 0.1 == 0.3) // false

Using CompareTo can save us here. This method is available on all float, double and decimal types.

It returns:

  • 0 when both the numbers are equal.
  • 1 when first number is greater than the second number
  • -1 when second number is greater than the first number

We will demonstrate it below

float f = 0.1F;
float f1 = 0.1F;
float f2 = 0.1F;
float f3 = 0.1F;
Console.WriteLine(f.CompareTo(f1 + f2 + f3)); // 0

double d1 = 2.3;
double d2 = 2.7;
Console.WriteLine(d2.CompareTo(d1)); // 1

decimal m1 = 7.18M;
decimal m2 = 8.9M;
Console.WriteLine(m1.CompareTo(m2)); // -1

Special Values For Float/Double

While the division by 0 for integer types raises exception, the division by zero for float/double results in infinity. It is a a set of special values defined as:

  • PositiveInfinity
  • NegativeInfinity

These values are available on both float and double types. Consider the following cases:

  • When a positive float/double divides by 0, PositiveInfinity is the result. You can check if a number is positive infinity by using IsPositiveInfinity.
  • When a negative float/double divides by 0, NegativeInfinity is the result. You can check if a number is negative infinity by using IsNegativeInfinity.

See the example below

double d = 20E3;

Console.WriteLine(double.IsPositiveInfinity(d / 0)); // prints true
Console.WriteLine(double.IsNegativeInfinity(d / 0)); // prints false
Console.WriteLine(double.IsInfinity(d / 0)); // prints true

float f = -20E3F;

Console.WriteLine(float.IsPositiveInfinity(f / 0)); // prints false
Console.WriteLine(float.IsNegativeInfinity(f / 0)); // prints true
Console.WriteLine(float.IsInfinity(f / 0)); // prints true

Notice that the method IsInfinity returns true for either positive or negative infinity. If you perform any operation with the variable whose value is infinity, you will get infinity as the result.

double d1 = 20E3;
double d2 = d1 / 0;
Console.WriteLine(double.IsPositiveInfinity(d2 + 90)); // prints true

There is also another special value NaN. When you divide a 0 by 0, you get NaN. It is also available on both float and double types. There is also a method available to check if a value is NaN. This method is IsNaN.

See the example below

double d1 = 0;
double d2 = d1 / 0;
double d3 = d1 / 0;
Console.WriteLine(double.IsNaN(d2)); // prints true
Console.WriteLine(d2 == d3); // prints false

Notice that you can’t compare NaN with equal (==) operator.

Numeric Type Conversions

Type conversions are of two types:

Widening Conversions

A widening conversion happens whenever a type conversion doesn’t make the data loss (but sometimes it can! See below).

Following are the rules regarding widening conversion without any data loss:

  • byte can be converted to ushort, short, uint, int, ulong, long, float, double, decimal
  • sbyte can be converted to short, int, long, float, double, decimal
  • short can be converted to int, long, float, double, decimal
  • ushort can be converted to uint, int, ulong, long, float, double, decimal
  • char can be converted to ushort, uint, int, ulong, long, float, double, decimal
  • int can be converted to long, double, decimal
  • uint can be converted to ulong, long, double, decimal
  • long, ulong can be converted to decimal
  • float can be converted to double

A widening conversion is done automatically and that is why it is also called as implicit conversion.

See the example below

// All assignments below are valid
int i = 27;
long l = i;
byte b = 200;
short s = b;
float f = l;
double d = s;

But there are some widening conversions that can cause loss of precision.

They are:

  • int, uint to float
  • long, ulong to float, double
  • decimal to float, double

Be careful when these happen as they can cause loss of precision.

Narrowing Conversions

Whenever a data of one type is converted to another type and there is a possible data loss, narrowing conversion is required. For example, long can’t be assigned to int because long has much wider range of values in its domain. Narrowing conversion is done explicitly and is also referred to as explicit conversion.

Type casting is a mechanic used to perform explicit conversion. The syntax of type casting is (target type)variable.

See the example below

int i = int.MaxValue;
// int is casted to short but data is lost
short s1 = (short)i;
i = 12;
// type cast needed although the value is within the range of short
s1 = (short)i;

One more caveat: When you perform arithmetic operations on integer type the operands (variables/values involved in arithmetic expression) are automatically promoted to either int or long types signed/unsigned (if not already an int or long type) and then the operation is performed.

byte b1 = 1;
byte b2 = 2;
byte b = b1 + b2; // gives error

In above example the sum of two bytes can’t be assigned back to a byte variable without a cast. So, a type cast will do the work here like

byte b = (byte)(b1 + b2);

This is because for integer type, the arithmetic operators are only defined for int and long types.

Now it is not specifically that all the integer types convert to an int.

In general, the following rules are in place for binary operations involving numeric types:

  • If one of the operand is of decimal type, the other operand is converted to type decimal. It raises exception if the other operand is of type float/double.
  • Else if either operand is of type double, the other operand is converted to type double.
  • Else if either operand is of type float, the other operand is converted to type float.
  • Else if either operand is of type ulong, the other operand is converted to type ulong. It raises exception if the other operand is of type sbyte, short, int, or long.
  • Else if either operand is of type long, the other operand is converted to type long.
  • Else if either operand is of type uint and the other operand is of type sbyte, short, or int, both operands are converted to type long.
  • Else if either operand is of type uint, the other operand is converted to type uint.
  • Else both operands are converted to type int.

Refer to the following links for more in depth look:

The Overflow Problem

Below is the program that attempts to sum two bytes and assign the result back to a byte.

byte b1 = byte.MaxValue;
byte b2 = 50;
byte b = (byte)(b1 + b2);
Console.WriteLine(b); // prints 49

You can enter the above code in the editor and get surprised that the result is not what is expected to be. The byte max value is 255 and we add 50 to it so the expected result is 255 + 50 = 305. But we get 49 here. Think why?

The reason is simple, a byte can only hold 256 total values (from 0 to 255). So, the sum now contains the overflow value 305 – 256 = 49.

This is the default behavior. When there is an overflow/underflow in numeric operations, it goes unnoticed silently. There is fortunately a mechanism provided by C# to handle this situation. It is the keyword checked. Lets see this keyword in action.

Checked Keyword

When there is an arithmetic expression that you suspect can cause overflow/underflow, just enclose it in the scope of checked keyword like

try
{
byte b1 = byte.MaxValue;
byte b2 = 50;
byte b = checked((byte)(b1 + b2)); // this will throw overflow exception
Console.WriteLine(b);
}
catch (OverflowException ex)
{
Console.WriteLine(ex.Message);
}

The checked keyword will throw an exception if overflow/underflow happens.

When you need to check the whole block of code for overflow/underflow, you can wrap the block in the scope of checked keyword like

try
{
checked
{
byte b1 = byte.MaxValue;
byte b2 = 50;
byte b = (byte)(b1 + b2); // this will throw overflow exception
Console.WriteLine(b);
}
}
catch (OverflowException ex)
{
Console.WriteLine(ex.Message);
}

You can set overflow/underflow checking project wide by entering this into your csproj file.

<PropertyGroup>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
</PropertyGroup>

The above setting will enable the overflow/underflow checking project wide. But sometimes the data loss is acceptable. To disable overflow exception for a statement/block, C# provides unchecked keyword.

See the example below (project wide overflow/underflow checking is assumed)

unchecked
{
byte b1 = byte.MaxValue;
byte b2 = 50;
byte b = (byte)(b1 + b2); // this will execute silently
Console.WriteLine(b);
}

So this was my effort to look into the basic numeric data types in C#, I hope you have gained some insights into how numeric types in C# work. But programming is a technical discipline that can’t be mastered without deep study along with practice. So have a habit of coding daily for a few hours to really become the master of the trade.

Make sure to follow me for more interesting posts and leave a clap on this post if you liked it. You can also ask any question you might have in the comment section.

--

--