C# Types and Variables
The Type System
The divide between value types and reference types affects performance, equality semantics, and how data flows through your application.
C# is a statically-typed language where every variable and expression has a type known at compile time. The type system divides into two fundamental categories: value types (stored on the stack or inline) and reference types (stored on the heap with stack-based references).
Understanding this distinction matters because it affects performance, equality semantics, and how data flows through your application.
Value Types
Value types hold their data directly. When you assign a value type to another variable or pass it to a method, you create a copy of the data.
Built-in Value Types
| Type | .NET Type | Size | Range |
|---|---|---|---|
bool |
Boolean | 1 byte | true/false |
byte |
Byte | 1 byte | 0 to 255 |
sbyte |
SByte | 1 byte | -128 to 127 |
short |
Int16 | 2 bytes | -32,768 to 32,767 |
ushort |
UInt16 | 2 bytes | 0 to 65,535 |
int |
Int32 | 4 bytes | -2.1B to 2.1B |
uint |
UInt32 | 4 bytes | 0 to 4.3B |
long |
Int64 | 8 bytes | ±9.2 quintillion |
ulong |
UInt64 | 8 bytes | 0 to 18.4 quintillion |
float |
Single | 4 bytes | ~6-9 digits precision |
double |
Double | 8 bytes | ~15-17 digits precision |
decimal |
Decimal | 16 bytes | 28-29 digits precision |
char |
Char | 2 bytes | Unicode character |
When to use each numeric type:
// int: General-purpose integers (loop counters, counts, IDs)
int count = 42;
int userId = 12345;
// long: Large numbers, timestamps, file sizes
long fileSize = 1_073_741_824; // 1 GB in bytes
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// double: Scientific calculations, general floating-point math
double distance = 384_400.5; // km to the moon
double velocity = 299_792.458; // km/s speed of light
// decimal: Financial calculations where precision matters
decimal price = 19.99m;
decimal taxRate = 0.0825m;
decimal total = price * (1 + taxRate); // 21.6389175m - exact
Why decimal Matters for Money
The decimal type exists specifically because float and double use binary floating-point representation, which cannot precisely represent base-10 fractions. Financial applications require exact decimal arithmetic.
// Why decimal matters for money
double priceDouble = 0.1 + 0.2; // 0.30000000000000004 (unexpected)
decimal priceDecimal = 0.1m + 0.2m; // 0.3 (exact)
Structs
Structs are custom value types. Use them for small, data-centric types that represent a single value or a small group of related values.
public struct Point
{
public double X { get; init; }
public double Y { get; init; }
public Point(double x, double y)
{
X = x;
Y = y;
}
public double DistanceTo(Point other)
{
double dx = X - other.X;
double dy = Y - other.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
}
// Usage - value semantics mean copies are independent
var p1 = new Point(0, 0);
var p2 = p1; // Creates a copy
// Modifying p2 would not affect p1 (if Point were mutable)
Struct guidelines:
- Keep structs small (16 bytes or less for best performance)
- Make structs immutable when possible (use
initorreadonly) - Implement
EqualsandGetHashCodeif used in collections - Don’t inherit from structs (they’re implicitly sealed)
Enums
Enums define a set of named constants. By default, the underlying type is int.
public enum OrderStatus
{
Pending, // 0
Processing, // 1
Shipped, // 2
Delivered, // 3
Cancelled // 4
}
// Explicit values when persistence or interop matters
public enum HttpStatusCode : short
{
OK = 200,
Created = 201,
BadRequest = 400,
NotFound = 404,
InternalServerError = 500
}
// Flags for combinable options
[Flags]
public enum FilePermissions
{
None = 0,
Read = 1,
Write = 2,
Execute = 4,
ReadWrite = Read | Write,
All = Read | Write | Execute
}
// Using flags
var permissions = FilePermissions.Read | FilePermissions.Write;
bool canWrite = permissions.HasFlag(FilePermissions.Write); // true
Reference Types
Reference types store a reference (memory address) to their data. Multiple variables can reference the same object.
Classes
Classes are the primary reference type for modeling complex entities and behaviors.
public class Customer
{
public int Id { get; init; }
public string Name { get; set; }
public string Email { get; set; }
public Customer(int id, string name)
{
Id = id;
Name = name;
}
}
// Reference semantics - both variables point to the same object
var customer1 = new Customer(1, "Alice");
var customer2 = customer1;
customer2.Name = "Bob";
Console.WriteLine(customer1.Name); // "Bob" - same object
Strings
Strings are reference types but behave like value types due to immutability.
string greeting = "Hello";
string modified = greeting + " World"; // Creates a new string
// greeting is still "Hello"
// String interning - identical literals share memory
string a = "hello";
string b = "hello";
bool same = ReferenceEquals(a, b); // true - interned
// For building strings in loops, use StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sb.Append(i).Append(", ");
}
string result = sb.ToString();
Arrays
Arrays are fixed-size collections of elements of the same type.
// Array creation
int[] numbers = new int[5]; // 5 zeros
int[] primes = { 2, 3, 5, 7, 11 }; // Initialized
int[] squares = new int[] { 1, 4, 9 }; // Explicit type
// Multi-dimensional arrays
int[,] matrix = new int[3, 3]; // 3x3 grid
int[,] identity = { { 1, 0 }, { 0, 1 } };
// Jagged arrays (array of arrays)
int[][] jagged = new int[3][];
jagged[0] = new int[] { 1, 2 };
jagged[1] = new int[] { 3, 4, 5 };
Type Inference with var
The var keyword lets the compiler infer the type from the right-hand side expression. The variable is still statically typed.
var count = 42; // int
var name = "Alice"; // string
var prices = new List<decimal>(); // List<decimal>
var lookup = new Dictionary<string, int>(); // Dictionary<string, int>
// Required for anonymous types
var anon = new { Name = "Alice", Age = 30 };
// var makes complex generic types readable
var customersByCity = customers
.GroupBy(c => c.City)
.ToDictionary(g => g.Key, g => g.ToList());
// Type: Dictionary<string, List<Customer>>
When to use var:
- When the type is obvious from the right side (
var list = new List<string>()) - With LINQ queries that return complex types
- With anonymous types
- To reduce noise when the type is clear from context
When to avoid var:
- When the type isn’t obvious (
var result = GetResult()- what type?) - For simple types where explicit naming aids readability
Constants and Read-Only
const
Compile-time constants. The value must be known at compile time and is embedded directly into the IL.
public class MathConstants
{
public const double Pi = 3.14159265358979;
public const int MaxRetries = 3;
public const string DefaultCurrency = "USD";
}
// Usage - value is substituted at compile time
double area = MathConstants.Pi * radius * radius;
const Limitations
- Only primitive types, string, and null
- Value embedded in consuming assemblies (recompilation needed if changed)
- Cannot be computed at runtime
readonly
Runtime constants. Value set at declaration or in constructor.
public class Configuration
{
public readonly string ConnectionString;
public readonly DateTime StartTime = DateTime.UtcNow;
public Configuration(string connectionString)
{
ConnectionString = connectionString;
}
}
// Static readonly for runtime-computed constants
public static class AppSettings
{
public static readonly string MachineName = Environment.MachineName;
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
}
readonly vs const:
| Aspect | const | readonly |
|---|---|---|
| Evaluation | Compile-time | Runtime |
| Types | Primitives, string, null | Any type |
| Storage | Embedded in IL | Field in memory |
| Change propagation | Requires recompilation | Automatic |
| Instance vs static | Always static | Either |
Nullable Value Types
Value types cannot normally be null. The ? suffix creates a nullable value type that can represent the absence of a value.
int? maybeAge = null;
int? definitelyAge = 25;
// Checking for value
if (maybeAge.HasValue)
{
int actualAge = maybeAge.Value;
}
// Null-coalescing operator
int displayAge = maybeAge ?? 0; // 0 if null
// Null-conditional with coalescing
int length = someString?.Length ?? 0;
// Pattern matching
if (maybeAge is int age)
{
Console.WriteLine($"Age is {age}");
}
Nullable value types are implemented as Nullable<T> - a generic struct that wraps the underlying value type.
Default Values
All types have a default value. For value types, it’s typically zero or equivalent. For reference types, it’s null.
default(int) // 0
default(bool) // false
default(double) // 0.0
default(string) // null
default(DateTime) // DateTime.MinValue (0001-01-01)
// default literal (C# 7.1+)
int count = default; // 0
string name = default; // null
List<int> list = default; // null
// Useful in generics
public T GetOrDefault<T>(string key) =>
cache.TryGetValue(key, out T value) ? value : default;
Type Conversions
Implicit Conversions
Safe conversions that cannot lose data happen automatically.
int i = 100;
long l = i; // int to long - safe
double d = i; // int to double - safe
decimal m = i; // int to decimal - safe
// Base class assignment
object obj = "hello"; // string to object
IEnumerable<int> seq = new List<int>(); // List to interface
Explicit Conversions (Casts)
Conversions that might lose data or fail require explicit casting.
double d = 3.14;
int i = (int)d; // 3 - truncates decimal
long l = 100;
int j = (int)l; // Safe here, but could overflow
// Reference type casts can fail
object obj = "hello";
string s = (string)obj; // Works
int n = (int)obj; // InvalidCastException
Safe Casting with as and is
object obj = GetSomething();
// 'as' returns null if cast fails
string s = obj as string;
if (s != null)
{
Console.WriteLine(s.Length);
}
// 'is' with pattern matching (preferred)
if (obj is string str)
{
Console.WriteLine(str.Length);
}
// Negated pattern
if (obj is not string)
{
Console.WriteLine("Not a string");
}
Conversion Methods
// Convert class - handles null and type conversions
string input = "42";
int value = Convert.ToInt32(input);
double d = Convert.ToDouble(input);
// Parse - for strings, throws on failure
int parsed = int.Parse("42");
DateTime date = DateTime.Parse("2024-01-15");
// TryParse - safe parsing, returns success bool
if (int.TryParse(userInput, out int result))
{
Console.WriteLine($"Parsed: {result}");
}
else
{
Console.WriteLine("Invalid input");
}
// Culture-aware parsing
decimal price = decimal.Parse("1,234.56", CultureInfo.InvariantCulture);
Boxing and Unboxing
Boxing converts a value type to object (or interface it implements). Unboxing extracts the value type from the object. Both have performance costs.
int value = 42;
object boxed = value; // Boxing - allocates on heap
int unboxed = (int)boxed; // Unboxing - copies back to stack
// Common boxing scenarios to avoid
ArrayList oldList = new ArrayList();
oldList.Add(42); // Boxing occurs
oldList.Add(99); // Boxing again
// Use generic collections instead
List<int> newList = new List<int>();
newList.Add(42); // No boxing
newList.Add(99); // No boxing
Namespaces and Using Directives
File-Scoped Namespaces (C# 10)
Traditional namespace declarations require an extra level of indentation for all code within the file. File-scoped namespaces eliminate this nesting when a file contains only one namespace.
// Traditional (still valid)
namespace MyApp.Services
{
public class UserService
{
// Code indented inside namespace
}
}
// File-scoped (C# 10+) - no extra indentation
namespace MyApp.Services;
public class UserService
{
// Code at file root level
}
public class OrderService
{
// Also in MyApp.Services namespace
}
File-scoped namespaces reduce visual noise and save horizontal space. Most modern C# projects use this style by default. You can enforce a project-wide preference through .editorconfig:
[*.cs]
csharp_style_namespace_declarations = file_scoped
Global Using Directives (C# 10)
Instead of repeating common using statements in every file, global usings declare them once for the entire project.
// In any file (commonly GlobalUsings.cs or at top of Program.cs)
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
// Global using static for extension methods and static members
global using static System.Console;
global using static System.Math;
// After declaring these, all files in the project can use
// List<T>, LINQ methods, Task, and WriteLine() without imports
You can also declare global usings in the project file:
<ItemGroup>
<Using Include="System.Collections.Generic" />
<Using Include="System.Console" Static="true" />
<Using Include="MyApp.Common" Alias="Common" />
</ItemGroup>
.NET 6+ projects enable implicit usings by default, which automatically includes common namespaces based on project type:
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
For console and class library projects, implicit usings include System, System.Collections.Generic, System.IO, System.Linq, System.Threading.Tasks, and others.
Type Aliases (C# 12)
The using directive can create aliases for any type, including tuples, arrays, and nullable types:
// Alias for tuple types
using Point = (int X, int Y);
using Person = (string Name, int Age);
Point origin = (0, 0);
Point target = (100, 200);
Person alice = ("Alice", 30);
// Alias for generic types
using IntList = System.Collections.Generic.List<int>;
using StringDict = System.Collections.Generic.Dictionary<string, string>;
IntList numbers = [1, 2, 3, 4, 5];
StringDict headers = new() { ["Content-Type"] = "application/json" };
// Alias for arrays and nullable types
using Matrix = int[][];
using OptionalInt = int?;
Matrix grid = [[1, 2], [3, 4]];
OptionalInt maybeValue = null;
Type aliases reduce repetition when working with complex generic types and provide semantic names for tuple structures that would otherwise be anonymous.
Tuples
Tuples group multiple values without defining a formal type. C# 7.0 introduced value tuples with named elements.
// Value tuples (C# 7.0+) - recommended
(string Name, int Age) person = ("Alice", 30);
Console.WriteLine(person.Name); // "Alice"
// Returning multiple values
public (bool Success, string Message, int Code) ValidateUser(string input)
{
if (string.IsNullOrEmpty(input))
return (false, "Input required", 400);
return (true, "Valid", 200);
}
var result = ValidateUser("test");
if (result.Success)
{
Console.WriteLine(result.Message);
}
// Deconstruction
var (success, message, _) = ValidateUser("test"); // _ discards Code
// Tuple comparison
var t1 = (1, "hello");
var t2 = (1, "hello");
bool equal = t1 == t2; // true - value comparison
Version History
| Feature | Version | Significance |
|---|---|---|
| Nullable value types | C# 2.0 | Represented absence of value types |
| var keyword | C# 3.0 | Enabled LINQ and reduced verbosity |
| Value tuples | C# 7.0 | Lightweight multiple return values |
| Tuples with names | C# 7.0 | Readable tuple members |
| Default literal | C# 7.1 | Simplified default value syntax |
| Readonly structs | C# 7.2 | Guaranteed immutable value types |
| in parameter | C# 7.2 | Pass value types by reference without copying |
| Nullable reference types | C# 8.0 | Compiler warnings for null reference issues |
| Init-only setters | C# 9.0 | Immutable property initialization |
| Record types | C# 9.0 | Value semantics for reference types |
| File-scoped namespaces | C# 10 | Reduced nesting in source files |
| Global usings | C# 10 | Project-wide using directives |
| Required members | C# 11 | Enforced initialization |
| Type aliases for any type | C# 12 | Aliases for tuples, arrays, generics |
| Primary constructors | C# 12 | Simplified constructor syntax |
Key Takeaways
Value vs Reference: Value types copy data; reference types share data. This affects equality comparison, parameter passing, and memory behavior.
Choose the right numeric type: Use int for general integers, decimal for financial calculations, and double for scientific computing.
Prefer type inference when types are obvious: var reduces noise but shouldn’t obscure what you’re working with.
Use nullable types intentionally: Nullable value types (int?) explicitly model optional values. Combined with nullable reference types (C# 8+), you can eliminate most null reference exceptions.
Avoid boxing: Use generic collections and methods to prevent unnecessary heap allocations from value type boxing.
Embrace modern namespace features: File-scoped namespaces reduce indentation noise, global usings eliminate repetitive imports, and type aliases provide semantic names for complex types like tuples.
Found this guide helpful? Share it with your team:
Share on LinkedIn