Skip to main content
C# beginner Lesson 4 of 25

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, 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);
}

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)

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.