Basic Types and Variables

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.

Every type in C# is either a value type or a reference type, and that single distinction explains assignment, parameter passing, equality defaults, nullability and most performance surprises.

Value Types and Reference Types

A value-type variable holds the data itself. Assigning copies the data. struct, enum and every built-in numeric type are value types.

A reference-type variable holds a reference to an object elsewhere in memory. Assigning copies the reference, so both variables then observe the same object. class, interface, delegate, record class, array types, string and object are reference types.

public struct PointStruct { public int X; public int Y; }
public class  PointClass  { public int X; public int Y; }

public static class CopySemantics
{
    public static void Demo()
    {
        var vs = new PointStruct { X = 1, Y = 1 };
        var vsCopy = vs;                 // copies the two ints
        vsCopy.X = 99;
        Console.WriteLine(vs.X);         // 1  -- the original is untouched

        var rc = new PointClass { X = 1, Y = 1 };
        var rcAlias = rc;                // copies the reference only
        rcAlias.X = 99;
        Console.WriteLine(rc.X);         // 99 -- same object
    }
}
Stack versus heap: a value-type local holds its fields inline in the stack frame so assigning it copies the data and the copy is independent; a reference-type local holds only a reference into the heap so assigning it copies the reference and both variables then point at the same object; a boxed int is shown as a heap object wrapping a copy of the value

"Value types live on the stack" is a useful first approximation but not a rule: a value type that is a field of a class lives inside that object on the heap, a captured local lives in a closure object, and the JIT keeps plenty of values in registers. What is guaranteed is the copy semantics above. See Structs and Value Types.

The Built-in Types

Keyword BCL type Size Range / notes

sbyte

System.SByte

8 bits

-128 … 127

byte

System.Byte

8 bits

0 … 255

short

System.Int16

16 bits

-32 768 … 32 767

ushort

System.UInt16

16 bits

0 … 65 535

int

System.Int32

32 bits

≈ ±2.1 × 109 — the default for integers

uint

System.UInt32

32 bits

0 … ≈ 4.3 × 109

long

System.Int64

64 bits

≈ ±9.2 × 1018

ulong

System.UInt64

64 bits

0 … ≈ 1.8 × 1019

nint

System.IntPtr

32/64

Native-sized signed integer — pointer arithmetic, interop

nuint

System.UIntPtr

32/64

Native-sized unsigned integer

float

System.Single

32 bits

IEEE 754 binary, ~6-9 significant digits

double

System.Double

64 bits

IEEE 754 binary, ~15-17 significant digits — the default for reals

decimal

System.Decimal

128 bits

Base-10, 28-29 significant digits — money and exact decimals

bool

System.Boolean

1 byte

true / false; no implicit conversion to or from numbers

char

System.Char

16 bits

One UTF-16 code unit — not necessarily one character

string

System.String

ref

Immutable UTF-16 sequence; a reference type

object

System.Object

ref

The root of the type hierarchy

Choose int unless you have a reason not to; it is the type arithmetic promotes to and the type the runtime handles fastest. Choose decimal for money and anything where a value written in decimal must round-trip exactly:

Console.WriteLine(0.1 + 0.2 == 0.3);        // False -- binary floating point
Console.WriteLine(0.1m + 0.2m == 0.3m);     // True  -- base-10 decimal

Console.WriteLine(double.PositiveInfinity); // ∞ -- IEEE 754 has infinities and NaN
Console.WriteLine(double.NaN == double.NaN);        // False! use double.IsNaN
Console.WriteLine(double.IsNaN(double.NaN));        // True

var and Implicit Typing

var asks the compiler to infer the type from the initialiser. It is static typing — the variable has one fixed type, which is why an initialiser is required and why var x = null; is an error.

var count = 42;                        // int
var name = "Ada";                      // string
var items = new List<string>();        // List<string>
var pairs = new Dictionary<string, List<int>>();   // the case var was designed for

// var x;            // error CS0818: implicitly-typed variables must be initialized
// var y = null;     // error CS0815: cannot assign <null> to an implicitly-typed variable
object? z = null;    // write the type instead
Console.WriteLine($"{count} {name} {items.Count} {pairs.Count} {z is null}");

The house guidance: use var when the right-hand side already states the type (new, a cast, a literal), and write the type explicitly when it does not (a method call whose return type is not obvious at the call site).

const and readonly

public sealed class Configuration
{
    // const: compile-time constant, implicitly static, baked into callers' IL.
    public const int MaxRetries = 3;
    public const string Scheme = "https";

    // readonly: assignable only in the declaration or a constructor; a run-time value.
    public readonly DateTime CreatedAt;
    public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);

    public Configuration() => CreatedAt = DateTime.UtcNow;   // legal: a constructor
}

The important practical difference: const values are inlined into consuming assemblies, so changing a public const in a library requires recompiling everything that used it. static readonly is read at run time and has no such versioning hazard — prefer it for any public constant that is not a genuinely fixed mathematical or protocol value. const is limited to the built-in value types, string and null enums; anything else must be readonly.

Default Values and default

Every type has a default: numeric zero, false, '\0', null for reference types, and an all-zero instance for structs. Fields get it automatically; locals must be assigned before use (definite assignment).

int i = default;                       // 0
bool b = default;                      // false
string? s = default;                   // null
DateTime dt = default;                 // 0001-01-01T00:00:00
Guid g = default;                      // 00000000-0000-0000-0000-000000000000

T Fallback<T>(T? value) where T : struct => value ?? default;   // target-typed `default`
Console.WriteLine($"{i} {b} {s is null} {dt:O} {g} {Fallback<int>(null)}");

Conversions

Implicit conversions are those that cannot lose information — widening numeric conversions, a derived type to a base type, anything to object. They need no syntax. Explicit conversions may lose information or fail, and require a cast.

int small = 42;
long wide = small;              // implicit widening
double asDouble = small;        // implicit int -> double

double pi = 3.99;
int truncated = (int)pi;        // explicit -- truncates toward zero, giving 3
byte tiny = unchecked((byte)300); // explicit -- wraps to 44

Console.WriteLine($"{wide} {asDouble} {truncated} {tiny}");

intfloat/double is implicit even though it can lose precision for large values; longdouble is implicit for the same reason. Nothing converts implicitly to or from decimal except the integer types.

checked and unchecked

Integer arithmetic overflows silently by default. checked turns overflow into an OverflowException:

int max = int.MaxValue;

int wrapped = unchecked(max + 1);      // -2147483648, the default behaviour
Console.WriteLine(wrapped);

try
{
    int boom = checked(max + 1);
    Console.WriteLine(boom);
}
catch (OverflowException)
{
    Console.WriteLine("overflow detected");
}

// Blocks work too, and `checked` composes with the checked operators of C# 11.
checked
{
    int a = 1000, b = 1000;
    long safe = (long)a * b;           // cast first -- the multiply is then in 64 bits
    Console.WriteLine(safe);
}

Set <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> to make checked the project-wide default; floating-point arithmetic is unaffected (it yields Infinity/NaN rather than throwing).

Parsing and Converting

// Parse: throws on failure.
int a = int.Parse("42");

// TryParse: returns false instead -- the right choice for untrusted input.
if (int.TryParse("not a number", out int b))
{
    Console.WriteLine(b);
}
else
{
    Console.WriteLine("could not parse");
}

// Culture matters. Always be explicit for machine-readable data.
double invariant = double.Parse("3.14", CultureInfo.InvariantCulture);

// Convert handles nulls and does rounding rather than truncation.
Console.WriteLine(Convert.ToInt32(3.5));    // 4 -- banker's rounding, unlike (int)3.5
Console.WriteLine(Convert.ToInt32(2.5));    // 2 -- ties go to even
Console.WriteLine($"{a} {invariant}");

Since C# 11, IParsable<T> and ISpanParsable<T> make parsing available generically — see Interfaces.

Boxing and Unboxing

Assigning a value type to object (or to an interface it implements) boxes it: the runtime allocates a heap object and copies the value into it. Casting back unboxes, copying the value out.

int value = 42;
object boxed = value;            // boxing: heap allocation + copy
int unboxed = (int)boxed;        // unboxing: type check + copy

Console.WriteLine(unboxed);      // 42

// The copy is why this surprises people:
var list = new List<object> { value };
value = 99;
Console.WriteLine(list[0]);      // 42 -- the box holds the old copy

// An invalid unbox throws rather than converting.
try { _ = (long)boxed; } catch (InvalidCastException) { Console.WriteLine("boxed int is not a long"); }

Boxing is the allocation you most often want to remove from a hot path. Generics avoid it entirely — List<int> stores int`s directly, with no boxing — which is the practical payoff of reified generics. Modern APIs avoid it too: string interpolation uses interpolated string handlers, and `Span<T> and generic math work without object.

typeof and sizeof

Type t = typeof(int);
Console.WriteLine(t.FullName);            // System.Int32
Console.WriteLine(typeof(List<>).Name);   // List`1 -- an open generic type

Console.WriteLine(sizeof(int));           // 4
Console.WriteLine(sizeof(decimal));       // 16

object o = 42;
Console.WriteLine(o.GetType() == typeof(int));   // True -- GetType() is the run-time type

sizeof works on the built-in types in safe code, and on any unmanaged struct inside an unsafe context. Use Unsafe.SizeOf<T>() when you need it generically.

See Also