Types
The C# type system is large and consequential. Types are partitioned into reference types and value types; the partition governs assignment semantics, parameter passing, identity, and the location of objects in memory. The CLR provides a uniform type system rooted at System.Object; the standard library provides a substantial set of built-in types; the language admits user-defined types through class, struct, record class, record struct, interface, enum, and delegate. Each subsequent revision of the language has refined the type system: C# 2 added generics and nullable value types, C# 7 added tuples, C# 8 added nullable reference types, C# 9 added records, C# 10 added file-scoped types, C# 11 added required members.
This page covers the type-system surface a working programmer encounters; the dedicated pages cover Reference and value types, Nullability, Generics, and Classes, structs, and records.
The reference vs value type partition
Every type in C# is either a reference type or a value type:
| Reference types | Value types |
|---|---|
class | struct |
interface | enum |
delegate | tuples ((int, string)) |
| arrays | numeric types (int, double, etc.) |
string | bool, char |
record class | record struct |
dynamic | nullable value types (int?) |
Reference-type instances live on the managed heap; the variable, parameter, or field holds a reference to the heap object. Value-type instances are stored inline in the variable, parameter, or field that holds them. The full implications — for assignment, equality, parameter passing, and performance — are the subject of Reference and value types.
Built-in types
The built-in numeric types:
| C# keyword | .NET type | Width | Range |
|---|---|---|---|
sbyte | System.SByte | 8 | −128 to 127 |
byte | System.Byte | 8 | 0 to 255 |
short | System.Int16 | 16 | −32 768 to 32 767 |
ushort | System.UInt16 | 16 | 0 to 65 535 |
int | System.Int32 | 32 | −2³¹ to 2³¹−1 |
uint | System.UInt32 | 32 | 0 to 2³²−1 |
long | System.Int64 | 64 | −2⁶³ to 2⁶³−1 |
ulong | System.UInt64 | 64 | 0 to 2⁶⁴−1 |
nint | System.IntPtr | platform | platform-dependent |
nuint | System.UIntPtr | platform | platform-dependent |
float | System.Single | 32 | IEEE 754 binary32 |
double | System.Double | 64 | IEEE 754 binary64 |
decimal | System.Decimal | 128 | base-10 floating-point, 28–29 significant digits |
The numeric types are value types. The C# keyword and the .NET type name are aliases; either may be used:
int a = 0;
Int32 b = 0; // identical; using the .NET type name
The conventional choice is the C# keyword for the well-known types and the .NET type name only when the program already references the namespace.
The non-numeric built-ins:
| C# keyword | .NET type | Notes |
|---|---|---|
bool | System.Boolean | true or false; not implicitly convertible to integer. |
char | System.Char | One UTF-16 code unit. |
string | System.String | Immutable sequence of UTF-16 code units; reference type. |
object | System.Object | The universal base type. |
dynamic | System.Object (compile-time) | Bypasses static type checking. |
The CLR type system
Every type in C# derives — directly or transitively — from System.Object. The result is a unified type system: every value, regardless of whether it is an int or a custom class, may be passed to a method that accepts object. The mechanism is boxing for value types: a value-type instance is wrapped in a heap-allocated reference-type wrapper and a reference to the wrapper is supplied. Unboxing is the reverse: a reference to the wrapper is checked at runtime for the expected type and the contained value is extracted.
int n = 42;
object o = n; // boxing: n is wrapped in a heap object
int m = (int)o; // unboxing: o is checked, the value is extracted
Boxing and unboxing have non-trivial performance cost (allocation and a runtime type check); modern C# rarely uses object as a parameter or container element type. The conventional alternatives are generic types (List<int> rather than ArrayList), constraints, and pattern matching.
Custom value types via struct
A struct declares a value type:
public struct Point {
public double X;
public double Y;
public Point(double x, double y) {
X = x;
Y = y;
}
public double Magnitude => Math.Sqrt(X * X + Y * Y);
}
Point p = new Point(3, 4);
Point q = p; // copies the struct
q.X = 0; // does not affect p
Structs are allocated inline; assigning a struct copies its members. The conventions:
- Structs should be small (typically 16–32 bytes or less); larger structs become expensive to copy.
- Structs should logically represent a single value (a point, a date, a colour).
- Structs should be immutable when possible; mutable structs are a frequent source of subtle bugs.
C# 7.2 introduced readonly structs, which forbid mutation through any path:
public readonly struct Point {
public double X { get; }
public double Y { get; }
public Point(double x, double y) { X = x; Y = y; }
}
C# 7.2 also introduced ref structs — value types that may live only on the stack:
public ref struct Span<T> { /* ... */ }
Ref structs cannot be boxed, cannot be fields of non-ref types, and cannot be captured by lambdas. The principal use is the Span<T> and ReadOnlySpan<T> types, which are stack-only views over contiguous memory.
Custom reference types via class
A class declares a reference type:
public class Document {
public string Title { get; init; } = "";
public string Body { get; init; } = "";
public DateTime Created { get; } = DateTime.Now;
public void Save() { /* ... */ }
}
Document d1 = new Document { Title = "draft", Body = "content" };
Document d2 = d1; // copies the reference; d1 and d2 designate the same object
d2.Title = "final"; // also affects d1.Title
The full treatment of classes is in Classes, structs, and records.
enum types
An enumeration declares a set of named integer constants:
public enum Direction { North, East, South, West }
Direction d = Direction.North;
int i = (int)d; // 0; explicit conversion
Enumerators are integer constants; without =, each is one greater than its predecessor. Explicit values are permitted; the underlying type may be specified:
public enum Status : byte { Idle = 0, Running = 1, Stopped = 2 }
The [Flags] attribute marks an enum whose values are bit-combinable:
[Flags]
public enum FileAccess { None = 0, Read = 1, Write = 2, Execute = 4 }
FileAccess access = FileAccess.Read | FileAccess.Write;
Tuples and ValueTuple
Tuples are anonymous compound value types:
(int X, int Y) point = (3, 4);
Console.WriteLine($"{point.X}, {point.Y}");
(string Name, int Age) GetUser(int id) {
return (Name: "alice", Age: 30);
}
var (name, age) = GetUser(1); // deconstruction
The tuple is a ValueTuple<T1, T2> (or higher arity) under the hood. The C# 7 syntax is the conventional way to return multiple values from a method. Tuples participate in pattern matching and may carry element names (which are syntactic; the underlying ValueTuple is the same type regardless).
dynamic and the DLR
The dynamic type bypasses compile-time type checking; member access on a dynamic value is resolved at runtime through the Dynamic Language Runtime:
dynamic d = SomeComObject();
d.Foo("hello"); // resolved at runtime
dynamic is principally useful for COM interop, dynamic-language interop (Python, Ruby), and JSON-like data where the structure is not statically known. It is rare in modern idiomatic C# code; the conventional alternatives are object plus pattern matching, or strongly-typed wrappers around the dynamic data.
Nullable value types
A value type T becomes nullable by suffixing ?:
int? maybe_int = null;
bool? maybe_bool = true;
if (maybe_int.HasValue) {
int x = maybe_int.Value;
}
int x = maybe_int ?? 0; // null-coalescing
int? is the C# alias for Nullable<int>, a value type that wraps an int and a flag indicating whether the value is present. The mechanism existed since C# 2.
Nullable reference types
C# 8 introduced nullable reference types. By default — when the nullable annotation context is enabled — reference types are non-nullable; ? suffixed marks them as nullable:
string name = "alice"; // non-nullable; cannot be null
string? maybe = null; // nullable; may be null
string s = maybe ?? "default"; // null-coalescing produces non-null
The compiler performs flow analysis and warns about dereferences of potentially-null values. The full treatment is in Nullability.
Type aliases via using
The using directive admits type aliases:
using StringList = System.Collections.Generic.List<string>;
using BookId = System.Guid;
StringList names = new StringList();
BookId id = Guid.NewGuid();
C# 12 admitted using aliases for tuple types, pointer types, and array types in addition to the previously-admitted forms.
var and type inference
var is not a type — it is a keyword that instructs the compiler to infer the variable’s type from its initialiser:
var n = 42; // int
var d = 3.14; // double
var s = "hello"; // string
var v = new List<int>(); // List<int>
var t = (Name: "alice", Age: 30); // (string Name, int Age)
var is the conventional choice when the right-hand side makes the type obvious. The convention is partly stylistic — some style guides require explicit types — and partly substantive: var reduces the noise of Dictionary<string, List<Tuple<int, string>>> map = new Dictionary<string, List<Tuple<int, string>>>();.
C# 9 introduced the target-typed new expression, which allows the new-expression to omit its type when the target type is unambiguous:
List<int> v = new(); // C# 9: type inferred from the target
Dictionary<string, int> m = new();
The combination of var and target-typed new substantially reduces the redundancy of variable declarations.
Anonymous types
An anonymous type has no declared name; it is inferred from a property-initialiser expression:
var person = new { Name = "alice", Age = 30 };
Console.WriteLine($"{person.Name} is {person.Age}");
Anonymous types are reference types, immutable (all members are read-only), and have compiler-generated Equals, GetHashCode, and ToString based on the property values. They are principally useful for LINQ projection, where a query returns a shape that is not worth declaring as a named class.
In contemporary code, records (C# 9) and tuples (C# 7) cover most of the use cases more flexibly; anonymous types remain in the language but are less heavily used than they were in the C# 3 / LINQ era.
Conversions
C# admits four kinds of conversion:
Implicit conversions
Conversions that the compiler performs without an explicit cast. Permitted only when no information is lost — narrowing conversions and lossy conversions require an explicit cast.
int n = 42;
long l = n; // implicit; long is wider
double d = n; // implicit
string s = n.ToString(); // not implicit; explicit method call
Explicit conversions
Conversions that require a cast expression (T)e:
double d = 3.14;
int n = (int)d; // explicit; possibly lossy
object o = "hello";
string s = (string)o; // explicit; checked at runtime
Failed runtime checks throw InvalidCastException. The pattern-matching as operator is the safer alternative:
if (o is string s) { /* use s */ }
User-defined conversions
A class may define implicit and explicit conversion operators:
public struct Celsius {
public double Degrees;
public static implicit operator double(Celsius c) => c.Degrees;
public static explicit operator Celsius(double d) => new Celsius { Degrees = d };
}
Celsius c = (Celsius)3.14; // explicit
double d = c; // implicit
The conventional discipline is to define implicit conversions only when no information is lost and no exceptions are possible; explicit conversions are appropriate otherwise.
The four named cast equivalents
C# has the cast (T)e and several pattern-based forms:
| Form | Purpose |
|---|---|
(T)e | Cast: implicit if the target type admits, explicit otherwise. |
e as T | Reference cast; returns null on failure (only for reference types and nullable value types). |
e is T | Type test; returns bool. |
e is T t | Pattern: type test plus binding. |
object o = "hello";
string s = o as string; // null if not a string
if (o is string str) { /* ... */ } // pattern; binds str
The as and pattern-matching forms are the conventional choice when the cast may fail; they avoid the exception-throwing path of the explicit cast.
Boxing and unboxing
Converting a value-type instance to object (or to an interface that the value type implements) boxes it: a heap allocation wraps the value, and the reference points to the wrapper:
int n = 42;
object o = n; // boxing: heap allocation
int m = (int)o; // unboxing: type-checked extraction
Boxing has non-trivial cost (allocation, GC pressure, an indirection on access). The conventional defences:
- Use generics (
List<int>) rather than non-generic collections (ArrayList). - Avoid passing value-type instances to methods that take
objectparameters. - Use generic constraints when a method must work on a value type without boxing.
Modern C# code avoids boxing in performance-critical paths; the standard library’s generic types make this straightforward.
Object representation, alignment, padding
C# does not expose object representation directly. The CLR controls object layout: every object on the heap has a header (a sync-block index and a method-table pointer) plus the field data. The layout of the field data is implementation-defined unless the type carries a [StructLayout(LayoutKind.Sequential)] or [StructLayout(LayoutKind.Explicit)] attribute, which fixes the layout for interop:
[StructLayout(LayoutKind.Sequential)]
public struct Header {
public int Magic;
public short Version;
public byte Flags;
}
The conventional advice is to leave layout to the runtime unless interop or measurement justifies otherwise. Programs that need detailed control over memory layout can use [StructLayout], [FieldOffset], and Span<byte> operations to achieve C-compatible layouts.
The treatment of memory in detail is in Memory and the CLR.