C# · lesson 4 of 26
Data Types in C#
Understand value types vs reference types, structs, records, and boxing/unboxing in C#.
By SkillByExample · Updated
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);
}
var temp = new Temperature(25);
Console.WriteLine($"temp: {temp.Celsius}C = {temp.Fahrenheit:F2}F");
$ dotnet run
temp: 25C = 77.00F
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
$ dotnet run
Order is in progress
True
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
Console.WriteLine($"arrayList count: {arrayList.Count}, list count: {list.Count}");
$ dotnet run
arrayList count: 1000000, list count: 1000000
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)
Practice
- Struct vs class memory layout. Write a program with both a
struct Pointand aclass PointCthat store X and Y coordinates. Assign a copy of each to a second variable and modify the original. Which changes are visible through the second variable, and why?
Solution
public struct Point { public int X, Y; }
public class PointC { public int X, Y; }
var p1 = new Point { X = 1 };
var p2 = p1;
p1.X = 99;
Console.WriteLine(p2.X); // 1 — struct is a value type, p2 is an independent copy
var c1 = new PointC { X = 1 };
var c2 = c1;
c1.X = 99;
Console.WriteLine(c2.X); // 99 — class is a reference type, c2 points to the same object
Structs copy by value; classes copy by reference. Modifying a struct copy doesn’t affect the original, but modifying a class copy does.
- Record value equality. Create two
record Personinstances with identical data. Test==,Equals(), andobject.ReferenceEquals(). Then usewithto create a modified copy. What happens?
Solution
public record Person(string First, string Last);
var p1 = new Person("Alice", "Smith");
var p2 = new Person("Alice", "Smith");
Console.WriteLine(p1 == p2); // True — value equality
Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(ReferenceEquals(p1, p2)); // False — different objects
var older = p1 with { Last = "Johnson" };
Console.WriteLine(p1.Last); // Smith — original unchanged
Console.WriteLine(older.Last); // Johnson — new instance
Records compare by data, not identity. ReferenceEquals is still False because they are separate objects on the heap.
- Boxing performance. Why does
ArrayListcause more GC pressure thanList<int>when storing a million integers? How does the garbage collector respond to each?
Solution
Every ArrayList.Add(int) boxes the integer — converting a 4-byte stack value into an 8-byte heap object (with object header). For a million integers, that’s ~24 MB of heap allocations (each boxed int object is ~24 bytes including overhead), plus a million GC-tracked references.
List<int>.Add(int) stores the integer directly in an internal int[] array — no boxing, no heap allocation per element. The GC only tracks one array object (~4 MB) instead of a million individual objects.
Run with dotnet-counters or System.GC.CollectionCount() to see the difference: ArrayList triggers many more Gen0 collections.