Variables in C#
Understand var, explicit types, nullable reference types, const, and readonly in C#.
Declaring Variables
C# is statically typed — every variable has a fixed type known at compile time. This lets the compiler catch type errors before your program runs, gives you accurate IntelliSense, and makes code easier to reason about. You can declare the type explicitly or let the compiler infer it with var.
// Explicit type — the type is stated clearly on the left
int age = 30;
string name = "Alice";
bool isActive = true;
double price = 9.99;
decimal balance = 1_234_567.89m; // m suffix = decimal literal
// var — compiler infers the type from the right side
var count = 0; // int
var message = "Hello"; // string
var ratio = 3.14; // double
var is not dynamic — it is just syntactic sugar. The type is still fixed at compile time. Once a var variable is assigned, its type cannot change.
var x = 10;
x = "hello"; // compile error: cannot convert string to int
Common Value Types
C# has a range of numeric types to suit different precision and memory needs. Choosing the right one prevents silent rounding errors and keeps memory usage appropriate for the data.
// Integer types — choose based on the range you need
byte b = 255; // 0 to 255
short s = 32_000; // ±32,767
int i = 2_147_483_647; // most common integer type
long l = 9_000_000_000L; // L suffix for long literal
// Floating point — choose based on precision
float f = 3.14f; // f suffix, ~7 digits precision (small, fast)
double d = 3.14159265; // ~15 digits precision (default for decimals)
decimal m = 19.99m; // exact decimal, use for money
// Other primitives
char c = 'A'; // single Unicode character
bool ok = true; // true or false
Use decimal for financial calculations — double and float use binary floating-point arithmetic, which cannot represent all decimal fractions exactly.
// This is a real problem, not a theoretical one
double wrong = 0.1 + 0.2; // 0.30000000000000004
decimal right = 0.1m + 0.2m; // 0.3 — exact
Nullable Types
Nullable Value Types
By default, value types cannot hold null because they have no concept of “absence” — an int always contains a number. Adding ? wraps the value type in a Nullable<T> struct that adds a boolean flag to track whether a value is present. This is useful when you need to distinguish “no value” from zero.
int requiredAge = 30; // cannot be null
int? optionalAge = null; // can be null — used for optional/missing data
if (optionalAge.HasValue)
Console.WriteLine(optionalAge.Value);
// Null-coalescing: use default if null
int age = optionalAge ?? 0;
// Null-conditional: only access members if not null
int? length = optionalAge?.ToString().Length;
Nullable Reference Types
With <Nullable>enable</Nullable> in your project file, the compiler tracks nullability of reference types at compile time. This catches potential NullReferenceException bugs before they happen in production, which is one of the most common sources of crashes in C# applications.
string nonNullable = "hello"; // compiler assumes never null
string? nullable = null; // explicitly nullable — you are saying "this might be null"
// This warns: possible null reference — the compiler protects you
Console.WriteLine(nullable.Length);
// Safe patterns
if (nullable != null)
Console.WriteLine(nullable.Length); // OK — compiler knows it's not null here
Console.WriteLine(nullable?.Length ?? 0); // null-conditional + coalescing
// Null-forgiving operator (use sparingly — suppresses the warning)
Console.WriteLine(nullable!.Length); // you assert it's not null, compiler trusts you
The null-forgiving ! operator should only be used when you know for certain the value is not null but the compiler cannot prove it.
const
const declares a compile-time constant. The value is evaluated once at compile time and embedded directly in the compiled binary. This makes const access essentially free at runtime, but it also means the value must be a literal that the compiler knows without running any code.
const double Pi = 3.14159265358979;
const int MaxRetries = 3;
const string AppName = "MyApp";
// Usage — Pi is replaced with its literal value by the compiler
double circumference = 2 * Pi * radius;
Rules for const:
- Only primitive types and
string - Must be initialized at declaration
- Implicitly
static— accessed via the class, not an instance - Value is embedded in every assembly that references it (a versioning gotcha)
public class Config
{
public const int TimeoutSeconds = 30;
}
// Called like:
int t = Config.TimeoutSeconds;
readonly
readonly fields are set once — either at declaration or in a constructor — and cannot change after that. Unlike const, readonly values can be computed at runtime, making them suitable for values that depend on constructor arguments or environment state.
public class DatabaseConnection
{
private readonly string _connectionString;
public DatabaseConnection(string connectionString)
{
_connectionString = connectionString; // OK in constructor — set once
}
public void Open()
{
// _connectionString = "new string"; // compile error — readonly after construction!
}
}
readonly differs from const in key ways:
public class Server
{
// const: fixed at compile time, same value everywhere in every binary
public const int DefaultPort = 8080;
// readonly: set at runtime in constructor, can vary per instance
public readonly int Port;
// readonly static: computed once when the class is first used
public static readonly DateTime StartTime = DateTime.UtcNow;
public Server(int port) => Port = port;
}
Variable Scope and Naming Conventions
C# has well-established naming conventions that most codebases follow. Consistent naming makes it immediately clear where a variable lives and how it should be used — a private field looks different from a constant, which looks different from a local variable.
public class OrderService
{
// Fields: _camelCase with underscore prefix
private readonly IOrderRepository _repository;
private int _processedCount;
// Constants: PascalCase
private const int MaxOrderSize = 1000;
// Properties: PascalCase
public int ProcessedCount => _processedCount;
public void ProcessOrder(int orderId)
{
// Local variables: camelCase — no prefix
var order = _repository.GetById(orderId);
int itemCount = order.Items.Count;
if (itemCount > MaxOrderSize)
throw new InvalidOperationException($"Order exceeds max size of {MaxOrderSize}");
_processedCount++;
}
}
Type Inference with Complex Types
var really earns its place when the type on the right side is already obvious and verbose to repeat. It keeps the focus on what you are doing rather than on the machinery of generic type parameters.
// Without var — repeating the same type twice adds noise
Dictionary<string, List<int>> lookup = new Dictionary<string, List<int>>();
// With var — the right side already tells you the type
var lookup = new Dictionary<string, List<int>>();
// Target-typed new (.NET 5+) — another approach: type on left, infer on right
Dictionary<string, List<int>> lookup2 = new();
// In LINQ — var is almost mandatory because the return type is complex
var results = someList
.Where(x => x.IsActive)
.GroupBy(x => x.Category)
.ToDictionary(g => g.Key, g => g.ToList());
Discard Variable
Sometimes an API requires you to receive a value that you do not actually need. The discard _ signals to the compiler (and to readers) that you are intentionally ignoring the value, rather than just forgetting to use it.
// Ignore one return value from a tuple
var (name, _) = GetNameAndAge();
// Ignore an out parameter you don't care about
if (int.TryParse(input, out _))
Console.WriteLine("Valid number");
// Ignore a lambda parameter
Action<int> printSomething = _ => Console.WriteLine("Hello");