Skip to main content
C# · lesson 4 of 26
C# beginner 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, char
  • struct types
  • enum types

Reference Types in C#

  • class types
  • string (special case — immutable and interned)
  • Arrays
  • interface and delegate types
  • record 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

  1. Struct vs class memory layout. Write a program with both a struct Point and a class PointC that 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.

  1. Record value equality. Create two record Person instances with identical data. Test ==, Equals(), and object.ReferenceEquals(). Then use with to 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.

  1. Boxing performance. Why does ArrayList cause more GC pressure than List<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.

Frequently Asked Questions

When should I use a struct instead of a class?
Use a struct for small, immutable, value-like data (a Point, Color, or Money amount) that is frequently allocated. Structs avoid heap allocation and GC pressure. Avoid structs larger than 16 bytes or ones that need inheritance.
What is boxing and why is it a performance problem?
Boxing wraps a value type in a heap-allocated object. It triggers a memory allocation and copy. In hot paths (tight loops, LINQ over value types), repeated boxing can cause significant GC pressure. Use generics to avoid it.
What makes records different from classes?
Records provide value-based equality (two records with the same data are equal), a generated ToString, and non-destructive mutation via 'with' expressions. They are ideal for DTOs, domain events, and configuration objects.