Polyglot
Languages C# types
C# § types

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 typesValue types
classstruct
interfaceenum
delegatetuples ((int, string))
arraysnumeric types (int, double, etc.)
stringbool, char
record classrecord struct
dynamicnullable 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 typeWidthRange
sbyteSystem.SByte8−128 to 127
byteSystem.Byte80 to 255
shortSystem.Int1616−32 768 to 32 767
ushortSystem.UInt16160 to 65 535
intSystem.Int3232−2³¹ to 2³¹−1
uintSystem.UInt32320 to 2³²−1
longSystem.Int6464−2⁶³ to 2⁶³−1
ulongSystem.UInt64640 to 2⁶⁴−1
nintSystem.IntPtrplatformplatform-dependent
nuintSystem.UIntPtrplatformplatform-dependent
floatSystem.Single32IEEE 754 binary32
doubleSystem.Double64IEEE 754 binary64
decimalSystem.Decimal128base-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 typeNotes
boolSystem.Booleantrue or false; not implicitly convertible to integer.
charSystem.CharOne UTF-16 code unit.
stringSystem.StringImmutable sequence of UTF-16 code units; reference type.
objectSystem.ObjectThe universal base type.
dynamicSystem.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:

FormPurpose
(T)eCast: implicit if the target type admits, explicit otherwise.
e as TReference cast; returns null on failure (only for reference types and nullable value types).
e is TType test; returns bool.
e is T tPattern: 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 object parameters.
  • 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.