Mert Özen Further With Every Line
Backend C# Fundamentals — #06

Type Conversions in C#: Cast, Parse, TryParse and Convert

Mert Özen Nov 7, 2026 8 min 2 views
Type Conversions in C#: Cast, Parse, TryParse and Convert

Implicit and explicit conversions in C#, how a cast truncates and overflows, when to use Parse and when TryParse, Convert's rounding and null behavior, culture-dependent parsing that silently multiplies prices by a hundred, and a first look at boxing and unboxing.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

There are two ways to turn a double price into an int: a parenthesized cast and Convert.ToInt32. Do they do the same thing? Guess the four lines:

double price = 34.99;

Console.WriteLine((int)price);
Console.WriteLine(Convert.ToInt32(price));
Console.WriteLine(Convert.ToInt32(34.5));
Console.WriteLine(Convert.ToInt32(35.5));
34
35
34
36

The cast truncates the fractional part: 34.99 → 34. Convert.ToInt32 rounds: 34.99 → 35. The last two lines are even more interesting: 34.5 → 34, but 35.5 → 36. Convert rounds values that sit exactly in the middle to the nearest even number, using the banker's rounding rule we saw with Math.Round in the previous post. Two ways that look like they do the same job showed three different behaviors. In this post we look at how C# moves between types, which conversions are silent and which are explicit, converting strings to numbers, and how culture gets mixed up in all of it.

Implicit Conversion: Safe Widening

If you can move a value to a wider type without losing information, C# does it without asking. This is called an implicit conversion. It's like pouring water from a small glass into a big jug: nothing spills.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

int stock = 120;
long bigStock = stock;
double ratio = stock;
decimal money = stock;

long pagesPrinted = 123_456_789_012;
float lossy = pagesPrinted;
double better = pagesPrinted;

Console.WriteLine($"{bigStock} {ratio} {money}");
Console.WriteLine((long)lossy);
Console.WriteLine((long)better);
120 120 120
123456790528
123456789012

Going from int to long, double and decimal is completely safe. But the second part shows an exception: going from long to float is also implicit, so the compiler says nothing, yet the value got corrupted. float has a precision of about 7 digits; the 12-digit number doesn't fit and shifts to the nearest value it can represent. double holds this number exactly. "Implicit conversion" doesn't always mean "lossless"; it's safe in terms of magnitude, not always in terms of precision.

Explicit Conversion: The Cast

The compiler won't silently perform a conversion that might lose information. When you write double rating = 4.97; int stars = rating;:

Program.cs(1,35): error CS0266: Cannot implicitly convert type 'double' to 'int'. An explicit conversion exists (are you missing a cast?)

The message also tells you what to do: the conversion is possible, but you have to say "I accept the loss". You do that by writing the target type in parentheses:

double rating = 4.97;
int stars = (int)rating;

long soldCount = 3_000_000_000;
int truncated = (int)soldCount;

int quantity = 300;
byte small = (byte)quantity;

Console.WriteLine(stars);
Console.WriteLine(truncated);
Console.WriteLine(small);
Console.WriteLine(checked((byte)quantity));
4
-1294967296
44
Unhandled exception. System.OverflowException: Arithmetic operation resulted in an overflow.

A rating of 4.97 became 4 stars; the cast doesn't round, it truncates. 3 billion doesn't fit in an int, so it turned into a meaningless negative number. 300 doesn't fit in a byte either: a byte holds values from 0 to 255, and the low 8 bits of 300 make 44. In both cases the cast handed you a wrong number without any warning. The checked from the numbers post works here too: if there's an overflow, it throws an exception. If the value is a constant, the compiler catches it at compile time: byte b = (byte)300; gives error CS0221: Constant value '300' cannot be converted to a 'byte' (use 'unchecked' syntax to override).

Between double and decimal there's no implicit conversion in either direction: both decimal price = d; and double d = price; give CS0266. Their representations differ and either direction can lose something; you do the conversion deliberately, with a cast.

Parse and TryParse

Everything a user types, everything read from a file or received from an API is a string first. To turn it into a number, you use the target type's Parse or TryParse method:

int stock = int.Parse("42");
Console.WriteLine(stock + 1);

string input = "12 books";
if (int.TryParse(input, out int quantity))
{
    Console.WriteLine($"Quantity: {quantity}");
}
else
{
    Console.WriteLine($"'{input}' is not a number, quantity is {quantity}");
}

int pages = int.Parse(input);
Console.WriteLine(pages);
43
'12 books' is not a number, quantity is 0
Unhandled exception. System.FormatException: The input string '12 books' was not in a correct format.

Parse returns the number if it succeeds and throws a FormatException if it can't. TryParse doesn't throw: it returns whether it succeeded as a bool and hands over the result through an out parameter. When it fails, the out variable gets the type's default value, 0 here. The out int quantity form declares the variable inside the call; we'll cover out in detail in the post on methods.

Which one to use depends on a simple question: is invalid input an expected case? A value a user typed into a form may well be wrong; that's not an error but a normal flow. Use TryParse there. A value in a settings file your own application wrote, on the other hand, should always be valid; if it's broken, something really went wrong, and Parse's exception tells you so.

The Convert Class

System.Convert is the class that gathers static methods for converting between the basic types. It's similar to Parse but has a few important differences:

string? missing = null;

Console.WriteLine(Convert.ToInt32(missing));
Console.WriteLine(Convert.ToInt32("FF", 16));
Console.WriteLine(Convert.ToBoolean("True"));
Console.WriteLine(Convert.ToInt32(true));
Console.WriteLine(int.Parse(missing));
Program.cs(7,29): warning CS8604: Possible null reference argument for parameter 's' in 'int int.Parse(string s)'.
0
255
True
1
Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 's')

Convert.ToInt32(null) returns 0, while int.Parse(null) throws an exception. That difference is sometimes useful, but more often it silently erases the difference between "no value" and "the value zero". Assuming zero stock when a book's stock information didn't arrive can be the wrong decision. The CS8604 warning from the compiler is worth noticing too: because nullable is enabled, it noticed at compile time that you passed a possibly null value to int.Parse. Convert also has overloads that take a number base (16, 2) and conversions between bool and numbers. And when going from double to an integer, as you saw in Guess First, it rounds instead of truncating.

Culture-Dependent Parsing

Here's the sneakiest trap of this post. In Turkey "34,90" means thirty-four lira and ninety kuruş; in the US the comma is a thousands separator. Let's parse the same string with different cultures:

using System.Globalization;

CultureInfo turkish = new CultureInfo("tr-TR");
CultureInfo invariant = CultureInfo.InvariantCulture;

decimal a = decimal.Parse("34,90", turkish);
decimal b = decimal.Parse("34.90", invariant);
decimal c = decimal.Parse("34,90", invariant);
decimal d = decimal.Parse("34.90", turkish);

Console.WriteLine(a.ToString(invariant));
Console.WriteLine(b.ToString(invariant));
Console.WriteLine(c.ToString(invariant));
Console.WriteLine(d.ToString(invariant));
Console.WriteLine(b.ToString(turkish));
Console.WriteLine(decimal.TryParse("34,90", NumberStyles.AllowDecimalPoint, invariant, out decimal strict));
34.90
34.90
3490
3490
34,90
False

Look at lines c and d: no error, no exception, the price just became a hundred times bigger. In the invariant culture the comma counts as a thousands separator, so "34,90" became 3490. In the Turkish culture the dot is the thousands separator, so "34.90" is 3490 as well. A decimal.Parse("34.90") called without a culture uses the culture of the machine the application runs on: code that works correctly on your development machine silently corrupts prices on a server set to Turkish.

The fix is to know which format the source is in and to say so explicitly. For machine-produced data such as a JSON file, a CSV or an API, use CultureInfo.InvariantCulture; for a form field the user typed in their own language, use the user's culture. The last line goes one step further: when we don't allow a thousands separator at all with NumberStyles.AllowDecimalPoint, "34,90" isn't accepted, and the bad data is rejected instead of growing a hundredfold. Output follows the same rule: b.ToString(turkish) shows "34,90" to a Turkish user.

char, string and Numbers

A few small conversions come up often in everyday code too:

Console.WriteLine((int)'A');
Console.WriteLine((char)66);
Console.WriteLine('7' - '0');
Console.WriteLine(42.ToString() + 1);
Console.WriteLine(42 + 1 + "");
65
B
7
421
43

A char actually carries a numeric code: 'A' is 65, and 66 is 'B'. The classic way to turn a digit character into a number is to subtract the code of '0' from it. Every type has a ToString() method, and when a string meets a number with +, you get concatenation, not addition. The last two lines show this: operations run from left to right, numbers are added until a string is reached, then concatenated. We'll look at this ordering together with operators in the next post.

A First Look at Boxing and Unboxing

In C# every type derives from object. What happens when you assign an int to a variable of type object?

int stock = 42;
object boxed = stock;
stock = 50;

int unboxed = (int)boxed;
Console.WriteLine(boxed);
Console.WriteLine(unboxed);
Console.WriteLine(boxed is int);

if (boxed is int count)
{
    Console.WriteLine($"Pattern match: {count + 1}");
}

long wrong = (long)boxed;
Console.WriteLine(wrong);
42
42
True
Pattern match: 43
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.Int32' to type 'System.Int64'.

On the line object boxed = stock; the runtime copies the int's value, puts it into a "box", and boxed points to that box. This is called boxing. Because the value in the box is a copy, setting stock to 50 didn't affect it. Taking the value back out of the box (unboxing) needs a cast, and that cast is strict: if the box holds an int, you can only take it out as an int. Even though there's an implicit conversion from int to long, you can't take a long straight out of the box; you get an InvalidCastException. First (int), then long if you want.

The safe way is is: boxed is int count both checks the type and puts the value into a new variable. We'll expand on pattern matching in the post on conditions. Boxing also has a cost: every box is a new object in memory. We'll come back to that cost in the collections series when we explain why generics were invented, and we'll look at the difference between value and reference types in detail in a later post in this series.

Common Pitfalls

Thinking a cast rounds. (int)4.97 is 4. If you want rounding, use Math.Round and pass MidpointRounding explicitly; don't forget that Convert.ToInt32 uses banker's rounding.

Parse on user input. Invalid input makes the application crash with an exception. Use TryParse for data coming from users and handle the failure case separately.

Parsing without a culture. decimal.Parse("34.90") gives different results on different machines, and it doesn't throw either. Pass CultureInfo.InvariantCulture explicitly for machine-produced data and the user's culture for user input.

Convert turning null into zero. Where you need to tell missing data from a zero value, Convert.ToInt32 erases the difference. Check for null yourself first.

A Small Experiment

★☆☆ Read an order quantity from the user with Console.ReadLine() and convert it with TryParse; if it's invalid print "Please enter a number", if it's valid show the quantity doubled. The return type of ReadLine is string?; TryParse rejects null without trouble too.

★★☆ Convert the string "1.234,56" to a decimal with the tr-TR culture and print the result with the invariant culture. What happens when you try to parse the same string with the invariant culture?

★★★ Write a loop that checks each value in the array object[] values = { 42, 42L, 4.2, "42" }; with is and converts it to an int: an int directly, long and double with a cast, a string with TryParse. Which one loses information?

You can now move values from one type to another. In the next post we look at the operators and expressions that work with those values: arithmetic and comparison operators, logical operators and short-circuiting, ?:, ??, ??=, ?., and the precedence order of them all.