Classes and Objects

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A class is a reference type that bundles state and behaviour. It is the default choice for a type in C#; reach for a struct only when value semantics and size make it worth it, and for a record when the type is primarily data.

Declaring a Class

namespace Sample.Ordering;

public class Order
{
    // Fields -- private by default, `_camelCase` by convention.
    private readonly List<OrderLine> _lines = [];
    private decimal _discount;

    // Constants -- implicitly static, compile-time values.
    public const decimal MaxDiscount = 0.5m;

    // Static field -- shared by every instance, `s_` prefix by convention.
    private static int s_created;

    // Constructor
    public Order(string customer)
    {
        Customer = customer;
        s_created++;
    }

    // Properties
    public string Customer { get; }
    public IReadOnlyList<OrderLine> Lines => _lines;
    public decimal Total => _lines.Sum(line => line.Amount) * (1 - _discount);

    // Methods
    public void Add(OrderLine line) => _lines.Add(line);

    public static int CreatedCount => s_created;
}

public sealed class OrderLine
{
    public OrderLine(string sku, decimal amount) => (Sku, Amount) = (sku, amount);

    public string Sku { get; }
    public decimal Amount { get; }
}
Anatomy of a class: the declaration line carries access and inheritance modifiers; the body holds constants and static fields shared across all instances alongside instance fields that each object owns; constructors initialise instance state while a static constructor runs once per type; properties wrap a backing field with get and set accessors; methods and indexers provide behaviour; nested types and a finalizer close the body. Members are marked public

Instance and Static Members

An instance member belongs to each object; a static member belongs to the type itself.

public class Counter
{
    private int _instanceCount;          // one per object
    private static int s_totalCount;     // one for the whole type

    public void Increment()
    {
        _instanceCount++;
        s_totalCount++;
    }

    public int InstanceCount => _instanceCount;
    public static int TotalCount => s_totalCount;
}

// A static class: no instances, no constructor, implicitly sealed and abstract.
public static class Geometry
{
    public const double Tau = Math.PI * 2;

    public static double CircleArea(double radius) => Math.PI * radius * radius;
}

A static class may contain only static members and is the usual home for extension methods and stateless helpers.

Constructors

public class Connection
{
    private static readonly TimeSpan s_defaultTimeout;

    // Static constructor: runs once, before first use of the type. No access modifier,
    // no parameters, and you cannot call it.
    static Connection() => s_defaultTimeout = TimeSpan.FromSeconds(30);

    // Instance constructors, chained with `this(...)`.
    public Connection() : this("localhost", 5432) { }

    public Connection(string host) : this(host, 5432) { }

    public Connection(string host, int port)
    {
        Host = host;
        Port = port;
        Timeout = s_defaultTimeout;
    }

    // A private constructor plus a factory method controls how instances are made.
    private Connection(Connection template, TimeSpan timeout)
    {
        Host = template.Host;
        Port = template.Port;
        Timeout = timeout;
    }

    public Connection WithTimeout(TimeSpan timeout) => new(this, timeout);

    public string Host { get; }
    public int Port { get; }
    public TimeSpan Timeout { get; }
}

If you declare no constructor at all, the compiler supplies a public parameterless one. Declaring any constructor removes it.

Base-class constructors are chained with base(…​); see Inheritance and Polymorphism.

Primary Constructors

C# 12 lets a class or struct declare constructor parameters on the declaration line. They are in scope throughout the body:

public class Repository(IReadOnlyList<string> items, ILoggerLike logger)
{
    // The parameters are captured into hidden fields when used in a member body.
    public int Count => items.Count;

    public string Get(int index)
    {
        logger.Log($"reading {index}");
        return items[index];
    }

    // Other constructors must chain to the primary one.
    public Repository(IReadOnlyList<string> items) : this(items, NullLogger.Instance) { }
}

public interface ILoggerLike { void Log(string message); }

public sealed class NullLogger : ILoggerLike
{
    public static readonly NullLogger Instance = new();
    public void Log(string message) { }
}

The parameters are not properties on a class (unlike on a record, where positional parameters become public properties). They are mutable captured state, so if you want them exposed, declare a property explicitly:

public class Point3D(double x, double y, double z)
{
    public double X { get; } = x;        // initialised from the primary constructor parameter
    public double Y { get; } = y;
    public double Z { get; } = z;
}

Partial Constructors (C# 14)

C# 14 extends partial to constructors (and events), so a source generator can supply the body of a constructor whose signature you declare:

public partial class Generated
{
    // The defining declaration -- signature only, no body.
    public partial Generated(string name);

    public string Name { get; private set; } = "";
}

public partial class Generated
{
    // The implementing declaration -- exactly one, with the body.
    public partial Generated(string name) => Name = name;
}

As with partial methods and properties, exactly one declaration has the body, and the two must agree on signature and accessibility.

Object and Collection Initializers

public class Customer
{
    public string Name { get; set; } = "";
    public string? Email { get; set; }
    public Address? Address { get; set; }
    public List<string> Tags { get; } = [];
}

public class Address
{
    public string City { get; set; } = "";
    public string Country { get; set; } = "";
}

public static class InitializerDemo
{
    public static void Run()
    {
        // Object initializer: runs after the constructor.
        var customer = new Customer
        {
            Name = "Ada",
            Email = "ada@example.com",
            Address = new Address { City = "London", Country = "UK" },

            // Collection initializer on a read-only property -- calls Add, does not assign.
            Tags = { "vip", "beta" },
        };

        // Collection initializers and collection expressions.
        List<int> numbers = [1, 2, 3];
        var lookup = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };

        Console.WriteLine($"{customer.Name} {customer.Tags.Count} {numbers.Count} {lookup.Count}");
    }
}

required Members

required forces the caller to set a member in an object initializer — initialisation guarantees without a constructor parameter for every property:

public class Person
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
    public int Age { get; init; }
}

public static class RequiredDemo
{
    public static void Run()
    {
        var person = new Person { FirstName = "Ada", LastName = "Lovelace", Age = 36 };
        Console.WriteLine($"{person.FirstName} {person.LastName}");

        // var incomplete = new Person { FirstName = "Ada" };
        // error CS9035: Required member 'Person.LastName' must be set
    }
}

A constructor that does set every required member can opt out with [SetsRequiredMembers].

Properties

public class Product
{
    // Auto-implemented: the compiler supplies the backing field.
    public string Name { get; set; } = "";

    // Read-only: settable in the constructor or an initialiser only.
    public Guid Id { get; } = Guid.NewGuid();

    // init-only: settable in an object initializer, then frozen.
    public decimal ListPrice { get; init; }

    // Different accessibility per accessor.
    public int StockLevel { get; private set; }

    // Computed, expression-bodied.
    public bool InStock => StockLevel > 0;

    // Full property with an explicit backing field.
    private string _sku = "";
    public string Sku
    {
        get => _sku;
        set => _sku = value.Trim().ToUpperInvariant();
    }

    // static property
    public static int TotalProducts { get; private set; }

    public void Restock(int quantity) => StockLevel += quantity;
}

The field Keyword (C# 14)

C# 14 makes field a contextual keyword inside an accessor, referring to the compiler-synthesised backing field. A property that needs logic in just one accessor no longer needs a hand-written field:

public class Temperature
{
    // C# 14: validation in the setter, no explicit backing field needed.
    public double Celsius
    {
        get => field;
        set => field = value < -273.15
            ? throw new ArgumentOutOfRangeException(nameof(value), "Below absolute zero.")
            : value;
    }

    // Only one accessor needs a body; the other stays auto-implemented.
    public string Name
    {
        get;
        set => field = value?.Trim() ?? "";
    } = "unnamed";

    // Lazy initialisation reads very cleanly now.
    public IReadOnlyList<string> History => field ??= new List<string>();
}

Because field is now a keyword in accessor bodies, an existing variable named field changes meaning inside an accessor. Rename it, or write @field. The compiler emits a warning where the interpretation changed.

Property Patterns

Properties integrate with pattern matching:

public static class PropertyPatterns
{
    public static string Describe(Product product) => product switch
    {
        { StockLevel: 0 } => "out of stock",
        { ListPrice: > 1000, InStock: true } => "premium, available",
        { Name.Length: > 50 } => "long name",       // extended property pattern
        _ => "ordinary",
    };
}

Indexers

An indexer lets an instance be used with []:

public class Matrix
{
    private readonly double[,] _cells;

    public Matrix(int rows, int columns) => _cells = new double[rows, columns];

    public double this[int row, int column]
    {
        get => _cells[row, column];
        set => _cells[row, column] = value;
    }

    // Indexers overload, including on Index and Range.
    public double this[string cell]
    {
        get
        {
            int row = cell[0] - 'A';
            int column = int.Parse(cell[1..], CultureInfo.InvariantCulture) - 1;
            return _cells[row, column];
        }
    }

    public int Rows => _cells.GetLength(0);
}

public static class IndexerDemo
{
    public static void Run()
    {
        var m = new Matrix(3, 3);
        m[0, 0] = 1.5;
        Console.WriteLine(m[0, 0]);    // 1.5
        Console.WriteLine(m["A1"]);    // 1.5
    }
}

this

public class Builder
{
    private readonly StringBuilder _text = new();

    public Builder Append(string value)
    {
        _text.Append(value);
        return this;                  // fluent chaining
    }

    public Builder Append(string value, bool condition) =>
        condition ? this.Append(value) : this;    // explicit `this` for clarity

    public override string ToString() => _text.ToString();
}

this is also how a constructor chains (: this(…​)), how an extension method declares its receiver, and how you disambiguate a field from a parameter of the same name.

Nested Types

public class Cache
{
    private readonly Dictionary<string, Entry> _entries = [];

    public void Put(string key, string value)
        => _entries[key] = new Entry(value, DateTimeOffset.UtcNow);

    public string? Get(string key)
        => _entries.TryGetValue(key, out Entry? entry) ? entry.Value : null;

    // A nested type can see the outer type's private members; the reverse is not true,
    // and a nested type has no implicit reference to an outer instance.
    private sealed class Entry(string value, DateTimeOffset storedAt)
    {
        public string Value { get; } = value;
        public DateTimeOffset StoredAt { get; } = storedAt;
    }

    // A public nested type is part of your API: Cache.Options.
    public sealed class Options
    {
        public TimeSpan Ttl { get; init; } = TimeSpan.FromMinutes(5);
    }
}

Nested types default to private. Keep them private unless they are genuinely part of the outer type’s contract.

partial Classes and Members

partial splits a declaration across files — essential for designer- and generator-produced code:

// Written by hand
public partial class ViewModel
{
    public string Title => BuildTitle();
}

// Generated
public partial class ViewModel
{
    private string BuildTitle() => "generated";
}

Methods, properties, indexers, events and (from C# 14) constructors may also be partial: a defining declaration with no body and exactly one implementing declaration with one. A partial method with no implementation is simply erased along with its call sites — provided it returns void, has no out parameters and is not accessible outside the type.

Access Modifiers

Modifier Visible from

public

Anywhere.

private

The containing type only. The default for members.

protected

The containing type and types derived from it.

internal

The containing assembly. The default for top-level types.

protected internal

The containing assembly, or derived types in any assembly (the union).

private protected

Derived types within the containing assembly (the intersection).

file

The containing file only (C# 11) — for source-generated types that must not collide.

internal class Service            // internal: the default for a top-level type
{
    private int _state;                          // private: the default for a member
    protected virtual void OnChanged() { }
    internal void UsedByTests() { }
    private protected void ForDerivedTypesHere() { }
}

file sealed class GeneratorHelper  // invisible outside this file
{
    public static int Value => 42;
}

[assembly: InternalsVisibleTo("MyProject.Tests")] opens internal members to a named assembly — the usual way to unit-test internals. See Namespaces, Assemblies and Projects.

Finalizers

public class NativeBuffer : IDisposable
{
    private IntPtr _handle = Marshal.AllocHGlobal(1024);
    private bool _disposed;

    // A finalizer runs on the GC's finalizer thread, at an unpredictable time.
    ~NativeBuffer() => Release();

    public void Dispose()
    {
        Release();
        GC.SuppressFinalize(this);      // no need to finalize; we cleaned up already
    }

    private void Release()
    {
        if (_disposed)
        {
            return;
        }

        if (_handle != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_handle);
            _handle = IntPtr.Zero;
        }

        _disposed = true;
    }
}

Almost no type should have a finalizer. Wrap the native resource in a SafeHandle and implement only IDisposable; see Memory Management and Disposal.

The object Base Type

Every type derives from System.Object, directly or not, and therefore has:

public sealed class Money
{
    public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency);

    public decimal Amount { get; }
    public string Currency { get; }

    public override string ToString() => $"{Amount:N2} {Currency}";

    public override bool Equals(object? obj)
        => obj is Money other && other.Amount == Amount && other.Currency == Currency;

    public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}

ToString, Equals, GetHashCode and GetType are the four you will meet constantly. Overriding Equals without GetHashCode is a bug the compiler warns about; see Equality and Operator Overloading.

See Also