Data Types in C#
Understand value types vs reference types, structs, records, and boxing/unboxing in C#.
Value Types vs Reference Types
This is one of the most important distinctions in C#. It affects memory layout, equality semantics, and performance. Getting it wrong can lead to subtle bugs where you expect independent copies but get shared references, or vice versa.
Value types are stored directly where they are declared — on the stack for local variables, or inline in the containing object on the heap. When you assign a value type, you get an independent copy. Changes to the copy do not affect the original.
Reference types are stored on the heap. A variable holds a reference (pointer) to the object. When you assign a reference type, both variables point to the same object in memory.
// Value type — copy on assignment
int a = 10;
int b = a; // b gets a completely independent copy
b = 20;
Console.WriteLine(a); // 10 — a is unchanged, b and a are separate
// Reference type — shared reference on assignment
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1; // list2 points to the SAME list object
list2.Add(4);
Console.WriteLine(list1.Count); // 4 — list1 reflects the change too
Value Types in C#
- All numeric types (
int,double,decimal, etc.) bool,charstructtypesenumtypes
Reference Types in C#
classtypesstring(special case — immutable and interned)- Arrays
interfaceanddelegatetypesrecord class(default)
Structs
A struct is a value type you define yourself. Use it for small, logically primitive values where copy semantics make sense and heap allocation overhead is worth avoiding. A Point, Color, or Money amount are classic examples.
public struct Point
{
public double X { get; init; }
public double Y { get; init; }
public Point(double x, double y) => (X, Y) = (x, y);
public double DistanceTo(Point other)
{
double dx = X - other.X;
double dy = Y - other.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
public override string ToString() => $"({X}, {Y})";
}
// Usage
var p1 = new Point(0, 0);
var p2 = new Point(3, 4);
Console.WriteLine(p1.DistanceTo(p2)); // 5
// Copy semantics — p3 is a fully independent copy of p1
var p3 = p1;
// modifying p3 does not affect p1
readonly struct
Marking a struct readonly prevents any method from accidentally mutating it. It also allows the compiler to skip defensive copies when passing the struct to methods, which is a meaningful performance gain in hot paths.
// readonly prevents mutation and enables compiler optimizations
public readonly struct Temperature
{
public double Celsius { get; }
public double Fahrenheit => Celsius * 9 / 5 + 32; // computed, not stored
public Temperature(double celsius) => Celsius = celsius;
public static readonly Temperature AbsoluteZero = new(-273.15);
}
Records
Records were introduced in C# 9 to solve a common problem: classes that exist purely to hold data require a lot of boilerplate to get value equality right. Records give you equality, ToString, and immutability for free. They come in two flavors: record class (reference type) and record struct (value type).
record class (C# 9)
// Positional record — one line replaces a full class with constructor,
// properties, Equals, GetHashCode, and ToString
public record Person(string FirstName, string LastName, int Age);
var alice = new Person("Alice", "Smith", 30);
// Value equality — unlike classes, two records with the same data are equal
var alice2 = new Person("Alice", "Smith", 30);
Console.WriteLine(alice == alice2); // True (class would give False here)
// Non-destructive mutation with 'with' — creates a new record with one field changed
var olderAlice = alice with { Age = 31 };
Console.WriteLine(alice.Age); // 30 — original is untouched
Console.WriteLine(olderAlice.Age); // 31
// Generated ToString — great for logging and debugging
Console.WriteLine(alice); // Person { FirstName = Alice, LastName = Smith, Age = 30 }
// Deconstruction — works out of the box with positional records
var (first, last, age) = alice;
Console.WriteLine($"{first} {last} is {age}");
record struct (C# 10)
// record struct — value semantics + record features (equality, with, ToString)
public record struct Coordinate(double Latitude, double Longitude);
var loc = new Coordinate(51.5074, -0.1278);
var loc2 = loc with { Longitude = -0.1000 }; // new coord, same latitude
When to use records vs classes
Use a record when:
- The object primarily carries data (DTOs, API responses, domain events)
- Equality should be based on data, not identity
- You want immutability by default
Use a class when:
- The object has meaningful identity (a customer account, a database connection)
- You need inheritance with virtual methods
- Mutability is intentional
Enums
Enums are value types that give names to integer constants. They make code self-documenting — OrderStatus.Shipped is far clearer than the magic number 2, and the compiler prevents you from passing an invalid integer where an OrderStatus is expected.
public enum OrderStatus
{
Pending = 0,
Processing = 1,
Shipped = 2,
Delivered = 3,
Cancelled = 4
}
var status = OrderStatus.Processing;
switch (status)
{
case OrderStatus.Pending:
case OrderStatus.Processing:
Console.WriteLine("Order is in progress");
break;
case OrderStatus.Delivered:
Console.WriteLine("Order complete");
break;
}
// Convert to/from int
int raw = (int)status; // 1
var parsed = (OrderStatus)raw; // OrderStatus.Processing
// Parse from string
var fromString = Enum.Parse<OrderStatus>("Shipped"); // OrderStatus.Shipped
// Flags enum — combine values with bitwise OR to represent sets of options
[Flags]
public enum Permissions
{
None = 0,
Read = 1,
Write = 2,
Execute = 4,
All = Read | Write | Execute
}
var perms = Permissions.Read | Permissions.Write; // user can read and write
Console.WriteLine(perms.HasFlag(Permissions.Read)); // True
Console.WriteLine(perms.HasFlag(Permissions.Execute)); // False
Boxing and Unboxing
Boxing converts a value type to object (a reference type) by allocating a new heap object and copying the value into it. Unboxing does the reverse. Both involve a heap allocation and are much slower than working with the value type directly. In tight loops or large collections, this can become a real bottleneck.
int number = 42;
object boxed = number; // Boxing — allocates on heap, copies value
int unboxed = (int)boxed; // Unboxing — cast required, copies back out
// Classic boxing trap in non-generic legacy code
var arrayList = new System.Collections.ArrayList();
for (int i = 0; i < 1_000_000; i++)
arrayList.Add(i); // Each Add boxes the int — 1 million heap allocations!
// Fix: use generic List<T> — the compiler stores ints directly, no boxing
var list = new List<int>();
for (int i = 0; i < 1_000_000; i++)
list.Add(i); // No boxing — fast and GC-friendly
Boxing also happens when a value type implements an interface and is passed as that interface:
interface IHasArea { double Area(); }
struct Circle : IHasArea
{
public double Radius { get; }
public Circle(double r) => Radius = r;
public double Area() => Math.PI * Radius * Radius;
}
IHasArea shape = new Circle(5); // Boxes the Circle — avoid in hot paths
To avoid this in performance-critical code, use generic constraints (where T : IHasArea) instead of interface variables.
String: The Special Reference Type
string is a reference type but behaves like a value type in many ways. Understanding these quirks prevents confusion when comparing strings or passing them to methods. Its immutability is also the reason why concatenating strings in a loop is expensive — every + creates a new string object.
- It is immutable — every “modification” creates a new string object
- The
==operator compares content, not reference identity - String literals are interned — identical literals share the same reference
string s1 = "hello";
string s2 = "hello";
Console.WriteLine(s1 == s2); // True (content equality)
Console.WriteLine(object.ReferenceEquals(s1, s2)); // True (same interned reference)
string s3 = new string("hello"); // Force a new instance (avoid in real code)
Console.WriteLine(s1 == s3); // True (content)
Console.WriteLine(object.ReferenceEquals(s1, s3)); // False (different references)