Data Types in C# Introduction
Table of Contents
- Introduction
- What is a Data Type?
- The importance of Data Types
- What about dynamic types?
- Reference and Value types
- Value types
- Reference types
- List of built-in data types in C#
- Value types
- Reference types
- Nullable value types
- Nullable variable example using nullable type modifier (?)
- Nullable variable example using Nullable type
- Nullable reference types
- Enabling Nullable Reference types
- Understanding the Object type, Boxing, and Unboxing
- Valid boxing and unboxing example
- Invalid boxing and unboxing example
- Converting and Parsing types
- Converting types examples
- Parsing types examples
- Summary
Introduction
In this article we will introduce the concept of data types in C#, explain why they are essential, and how they allow developers to write safe and reliable code. We will also outline how data types define what kind of data your variables can hold, and how the compiler uses types to prevent bugs and optimise performance.
What is a Data Type?
Every value in a C# program has a type, whether it is a number, a string of text, or a choice between yes or no. A data type determines what kind of data something is, how much memory it will use, and what operations can be performed on it. For example, you can add two integers together, but you cannot add an integer to a string without converting one of them to the other first. The data type system in C# helps define those bounds clearly.
Knowing the type of a value also helps the compiler make decisions during code analysis and optimisation. It ensures that the correct methods, operators, and conversions are applied to a value, and it helps avoid mistakes like treating a number as a character, or trying to perform invalid calculations. This is particularly important in larger programs, where type mismatches can cause subtle bugs if not caught early.
Beyond correctness, types may also influence performance. Value types like integers and booleans are stored directly in memory, and can be processed efficiently, whereas reference types like strings and objects are stored on the heap and accessed by reference, which is potentially slower.
The importance of Data Types
In a strongly typed language like C#, every variable and expression must have a known and fixed type at compile time. This means that the C# compiler can catch mistakes early, such as attempting to assign a string to a variable meant to hold a number, as mentioned earlier. This helps reduce the number of bugs, and makes code easier to reason about.
When a method expects a specific type, it can rely on receiving exactly that, with no surprises. For example, if a method is defined to take an int, the compiler enforces that the caller must provide an integer value. This guarantees that the method can safely perform operations on the integer input such as addition, or other arithmetic, without checking the type at runtime or risking a runtime failure.
Types also make your code more readable and self-explanatory. A variable named string userEmail tells future developers (and your future self) exactly what kind of data is expected, without needing comments or documentation. The type acts as part of the documentation.
In larger codebases, a well-defined type system allows you to safely refactor your code. If you change the type of a method parameter or class property, the compiler will highlight all the places where adjustments are needed. This kind of safety net is vital in teams or projects that evolve over time.
Strongly typed APIs also make integration between assemblies, modules, and systems, more reliable. When exposing functionality via classes, using defined types acts as a contract, making it much harder for callers to misuse your code.
What about dynamic types?
Despite the focus and clear advantages of the type system in C#, the language also includes a special type called dynamic, which defers type checking until runtime. In comparison to regular variables, which must have a fixed type at compile time, a dynamic variables can change what it holds on the fly. dynamic can hold text, numbers, even objects with different structures/shapes.
The dynamic type can also be useful in scenarios such as working with JSON data, integrating with Microsoft Office's COM, or working with dynamic languages such as IronPython. The downside however is that you will lose all the compile-time safety C# normally gives you. Furthermore, the compiler will not catch type errors, IDE support will be limited, and you will be more likely to encounter runtime exceptions if you misuse the data.
Reference and Value types
There are two categories of types in C#, which are reference and value types. Understanding the differences between them is crucial, as it affects how the underlying data is stored in memory, how variables of these data types are passed between methods, and how changes to variables behave at runtime.
Value types
Value types in C# represent data directly in memory. When you create a value type variable, it holds its data in its own memory location, rather than referencing another object. This means that when you assign a value type to another variable, it creates a copy of the data, and not a reference to the original.
Furthermore, value types are typically used for small pieces of data such as numbers, booleans, and structs. Because they mostly live on the stack, they are fast to allocate and deallocate, and do not require garbage collection. Common examples include int, float, bool, and char. Structs are also a custom value types, which are defined by the programmer.
Reference types
Reference types in C# store a reference to their data, rather than the data itself. When you assign a reference type variable to another variable, you are copying the reference, not the object. This means both variables will point to the same memory location, and any changes made through one variable, will be reflected in the other variable. This can bring about subtle bugs when changing properties through one reference variable, and not understanding that the other reference variable would be affected as well.
Furthermore, reference types are allocated on the heap, and managed by the garbage collector, introducing possible performance drops when compared to value types stored on the stack.
Common reference types include string, object, dynamic, delegate, arrays, and classes.
List of built-in data types in C#
Below is a list of data types, separated into two tables. One for value types and one for reference types.
Value types
| Type | Description | Example | Min / Max | Size |
|---|---|---|---|---|
bool | Boolean true/false value | bool truthful = true | 1 byte | |
byte | 8-bit unsigned integer | byte level = 255 | 0 / 255 | 1 byte |
char | Single Unicode character | char grade = 'A' | '\u0000' / '\uffff' | 2 bytes |
DateTime | Date and time value | DateTime now = DateTime.Now | DateTime.MinValue / DateTime.MaxValue | 8 bytes |
decimal | 128-bit precise decimal number | decimal price = 19.99m | ±79,228,162,514,264,337,593,543,950,335 | 16 bytes |
double | 64-bit floating-point number | double pi = 3.14159 | ±5.0 × 10−324 / ±1.7 × 10308 | 8 bytes |
enum | User-defined enumeration | DayOfWeek monday = DayOfWeek.Monday | Depends on underlying type | |
float | 32-bit floating-point number | float borgTemperature = 39.1f | ±1.5 × 10−45 / ±3.4 × 1038 | 4 bytes |
Guid | Globally unique identifier | Guid id = Guid.NewGuid() | 16 bytes | |
int | 32-bit signed integer | int count = 42 | −2,147,483,648 / 2,147,483,647 | 4 bytes |
long | 64-bit signed integer | long distance = 1234567890 | −9,223,372,036,854,775,808 / 9,223,372,036,854,775,807 | 8 bytes |
nint / nuint | Native-sized signed/unsigned integer | nint offset = 42 | Depends on platform | 4 or 8 bytes |
sbyte | 8-bit signed integer | sbyte delta = -100 | −128 / 127 | 1 byte |
short | 16-bit signed integer | short score = 30000 | −32,768 / 32,767 | 2 bytes |
struct | User-defined value type | Point p = new Point(1, 2) | ||
TimeSpan | Time interval | TimeSpan duration = TimeSpan.FromMinutes(30) | TimeSpan.MinValue / TimeSpan.MaxValue | 8 bytes |
uint | 32-bit unsigned integer | uint max = 4294967295 | 0 / 4,294,967,295 | 4 bytes |
ulong | 64-bit unsigned integer | ulong big = 9876543210 | 0 / 18,446,744,073,709,551,615 | 8 bytes |
ushort | 16-bit unsigned integer | ushort port = 443 | 0 / 65,535 | 2 bytes |
Reference types
| Type | Description | Example |
|---|---|---|
array | Indexed collection of elements | int[] numbers = { 1, 2, 3 }; |
class | User-defined reference type | Person p = new Person(); |
delegate | Reference to a method or callback | Action helloWorld = () => Console.WriteLine("Hi"); |
dynamic | Runtime-bound type (resolved at runtime) | dynamic helloWorld = "Hello, World!"; |
Exception | Base type for all exceptions | Exception exception = new Exception("Oh noes"); |
interface | Contract for types to implement | IEnumerable<int> list = new List<int>(); |
object | Base type of all other types | object data = 42; |
Stream | Base type for data streams | Stream s = File.OpenRead("file.txt"); |
string | Sequence of characters (text) | string name = "Bungle"; |
Task | Represents an asynchronous operation | Task delay = Task.Delay(1000); |
Nullable value types
In C#, value types such as int, bool, and DateTime, cannot normally be assigned a null value. This makes sense, as they are designed to always hold a value. However, there are many cases where you want to represent the lack of a value, such as when reading from a database column that might be empty, or when delaying the assignment of a value until later. C# allows this via nullable value types. These enable value types to represent an undefined or lack of a value.
To mark a value type as nullable, you add a question mark after the type. For example, int?, which is just shorthand for Nullable<int>. Nullable<T> is the underlying type the compiler uses to support nullable value types. You can use this syntax with any value type, including bool?, double?, or even with struct types such as Guid?.
Once a variable is marked as nullable, you can assign null to it, check if it has a value via the HasValue method, or access its value using the Value property. There is also the null-coalescing operator ??, which allows you to specify a fallback value if the nullable variable has no value.
Nullable variable example using nullable type modifier (?)
int? age = null;
if (age.HasValue)
{
Console.WriteLine($"Age is {age.Value}");
}
else
{
Console.WriteLine("Age is not specified.");
}
int displayedAge = age ?? 0;
Console.WriteLine($"Displayed age: {displayedAge}");In the nullable type modifier example above, int? declares a nullable integer called age. Because it is a value type with the nullable modifier, it can hold either an actual number, or null. We then use HasValue to check if the value is present before accessing age.Value, which would otherwise throw an exception if age was null. The final line uses the null-coalescing operator ?? to provide a fallback value of 0 if age has no value.
Nullable variable example using Nullable type
Nullable<int> age = null;
if (age.HasValue)
{
Console.WriteLine($"Age is {age.Value}");
}
else
{
Console.WriteLine("Age is not specified.");
}
int displayedAge = age ?? 0;
Console.WriteLine($"Displayed age: {displayedAge}");In the Nullable type example above, the code behaves exactly the same as the int? version. The Nullable<T> type wraps any value type, and adds support for null, HasValue, and Value, along with the null-coalescing operator. You can use either syntax in your code, as int? is simply shorthand for Nullable<int>. The compiler treats both forms the same, so it is mostly a matter of readability and preference.
Nullable reference types
Nullable reference types were introduced in C# 8 to help developers write safer code, by making it explicit when a reference type may be null. Before the addition of nullable reference types, all reference types in C# were implicitly nullable, meaning the compiler would not warn you if you forgot to check for null. This often led to the well loathed NullReferenceException at runtime.
With nullable reference types enabled, the compiler treats all reference types in the assembly as non-nullable by default. If on the other hand you want a reference type variable to be able to hold null, you must explicitly declare it using a question mark, such as string?. This change turns potential null related bugs into compile-time warnings, thereby encouraging you to check for null and handle it deliberately in your code.
The nullable reference type feature addition does not change the underlying runtime behavior. A string and a string? are still the same under the hood. What it does change however is how the compiler interprets your code, by adding static analysis that helps detect places where null can happen and possibly cause a runtime exception.
Because nullable reference types have been designed as opt-in, they must be enabled in your project explicitly. Once enabled, you'll start seeing warnings where your code could encounter nullability issues. You can address these new warnings by applying null checks, using the null-forgiving operator (!), or by marking types as nullable where needed.
Enabling Nullable Reference types
Enabling nullable reference types in an assembly is accomplished through a property switch named Nullable.
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>As shown above, adding <Nullable>enable</Nullable> to your assembly .csproj file turns on compiler analysis for nullable reference types. From that point onward, all source files in that assembly will treat reference types as non-nullable by default, unless marked with ?. You can also use disable or omit the <Nullable>enable</Nullable> property entirely to disable nullable reference types in an assembly.
Understanding the Object type, Boxing, and Unboxing
The object type in C# is the top-level base type. Every other type, whether it is a value type such as an integer, or a reference type such as a string, can be assigned to a variable of type object. This allows for code that can operate on values of unknown or mixed types.
When a value type is stored in a variable of type object, it goes through a process called boxing. Boxing wraps the value inside an object reference, and places it on the heap rather than the stack. Furthermore, boxing makes it possible to treat value types as reference types.
To retrieve the value from a boxed object, you use unboxing. This is accomplished through an explicit cast back to the original type. If the cast does not match the boxed value's actual type, a InvalidCastException runtime exception is thrown.
Due to boxing and unboxing involving memory allocations and type checks, those operations carry a performance cost, although features such as generics and pattern matching often provide cleaner, more efficient alternatives.
Valid boxing and unboxing example
object boxed = 42;
int number = (int)boxed; // boxed and unboxed successfullyIn the example above, a value type is boxed into an object, and then unboxed back to its original type. Since the type matches exactly, the cast succeeds without error.
Invalid boxing and unboxing example
object boxed = 42;
string text = (string)boxed; // throws InvalidCastException at runtime due to invalid unboxingIn the second example above, a value type is boxed as an object, but then unboxed to the wrong type. This results in an InvalidCastException at runtime, because the actual value does not match the expected type.
Converting and Parsing types
Converting between types is a common task in C#. Sometimes you are converting between compatible types, such as between int and double, and other times you are converting between completely different types, such as turning a number into its string representation. C# provides built-in methods to do this, such as Convert.ToInt32, .ToString() and the casting syntax.
Parsing on the other hand, is a form of conversion used to interpret a string as a value of another type. For example, converting a string such as "123", to an actual int type value 123. Most built-in system types have a Parse and a TryParse method. Parse throws an exception if the string is not valid, whereas TryParse returns a boolean indicating success or failure.
Choosing between converting and parsing often depends on the context. Parsing is appropriate when dealing with user input or external data, such as JSON, form fields, or query parameters. Conversion should be used however when you already have strongly typed values, but they require transforming into another compatible type.
Converting types examples
// Convert string to int
string answerToEverything = "42";
int answer = Convert.ToInt32(answerToEverything);
// Convert int to string
int percentage = 100;
string percentageText = percentage.ToString();
// Casting from double to int
double doubleBorgTemperature = 39.1;
int borgTemperature = (int)doubleBorgTemperature;In the converting examples above, we are converting values between compatible types using built-in methods and the last one is a cast. Convert.ToInt32 is used to convert a string to an integer, whereas .ToString() converts a number back into a string. The last line however demonstrates a cast from double to int, which removes the decimal portion. These conversions are useful when working with values that you know are compatible, but you need to represent them in a different form.
Parsing types examples
// Parse text to int
string percentageText = "100";
int percentage = int.Parse(percentageText);
// TryParse text to int
string degreesText = "180";
if (int.TryParse(degreesText, out int degrees))
{
Console.WriteLine($"Parsed degrees: {degrees}");
}
else
{
Console.WriteLine("Invalid number format.");
}In the parsing examples above, it demonstrates how to parse a string into an integer using two different approaches. int.Parse directly converts the string, but will throw an exception if the input isn't valid, whereas int.TryParse is an alternative that returns a boolean indicating success or failure, and outputs the parsed value if successful. This is especially useful when dealing with user input, where the input format may not be known for sure.
Summary
Data types are fundamental to how C# works. They define what kind of data your program can work with, how it is stored in memory, and how it can be manipulated. Strong data typing in C# provides a clear structure and helps catch mistakes early, making your code more reliable and easier to reason about.
In this article, we explored the importance of data types and how they relate to concepts like value vs reference types, nullable types, parsing, conversion, and strong typing. Understanding these foundational concepts is vital when learning C#.
In upcoming articles, we will take a closer look at the individual built-in system types such as string, int, bool, and more, and explore how they behave, what you can do with them, and common mistakes to avoid. Stay tuned!