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
}
}
"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 |
|---|---|---|---|
|
|
8 bits |
-128 … 127 |
|
|
8 bits |
0 … 255 |
|
|
16 bits |
-32 768 … 32 767 |
|
|
16 bits |
0 … 65 535 |
|
|
32 bits |
≈ ±2.1 × 109 — the default for integers |
|
|
32 bits |
0 … ≈ 4.3 × 109 |
|
|
64 bits |
≈ ±9.2 × 1018 |
|
|
64 bits |
0 … ≈ 1.8 × 1019 |
|
|
32/64 |
Native-sized signed integer — pointer arithmetic, interop |
|
|
32/64 |
Native-sized unsigned integer |
|
|
32 bits |
IEEE 754 binary, ~6-9 significant digits |
|
|
64 bits |
IEEE 754 binary, ~15-17 significant digits — the default for reals |
|
|
128 bits |
Base-10, 28-29 significant digits — money and exact decimals |
|
|
1 byte |
|
|
|
16 bits |
One UTF-16 code unit — not necessarily one character |
|
|
ref |
Immutable UTF-16 sequence; a reference type |
|
|
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}");
int → float/double is implicit even though it can lose precision for large values; long → double 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
-
Structs and Value Types — defining your own value types.
-
Strings and Text — the one built-in reference type.
-
Nullable Types and Null Safety —
int?and nullable reference types. -
Operators and Expressions — what these types can be combined with.
-
Generics — how to write code over these types without boxing.