Type inference in C# 14: var and its rules, anonymous types, target-typed new and collection expressions, generic method inference, natural lambda types, and the readability and LINQ traps that guide when to be explicit.
Type inference lets the compiler work out a type from context. var takes the static type of the initialiser, so the variable is still strongly typed; it is required for anonymous types. Target-typed new() and collection expressions (List<int> xs = [1, 2]) go the other way, taking the type from the declaration. Generic arguments are inferred from method arguments, and since C# 10 lambdas have a natural type. I use var when the type is obvious from the right-hand side and write the type when it is not, or when the exact type changes behaviour, like IQueryable<T> versus IEnumerable<T> in EF Core.
Type inference means the compiler works out the static type for you at compile time. var is still strongly and statically typed — it is not dynamic and not JavaScript's var. The real question is readability: inference should remove noise, never hide information the reader needs.
| Feature | Example | Inferred from |
|---|---|---|
| Implicitly typed locals (C# 3) | var orders = new List<Order>(); |
The initialiser |
| Generic method type inference | Max(3, 7) → Max<int> |
Argument types |
| Lambda parameter types | orders.Where(o => o.Total > 0) |
Target delegate type |
| Natural type of lambdas (C# 10) | var parse = (string s) => int.Parse(s); → Func<string,int> |
Lambda signature |
Target-typed new (C# 9) |
List<Order> orders = new(); |
Declared target type |
| Target-typed conditional (C# 9) | int? x = flag ? 1 : null; |
Target type |
default literal (C# 7.1) |
CancellationToken ct = default; |
Target type |
| Collection expressions (C# 12) | List<int> ids = [1, 2, 3]; |
Target type |
| Implicitly typed arrays | var a = new[] { 1, 2, 3 }; |
Best common element type |
out var, deconstruction, foreach (var ...) |
foreach (var (key, value) in dict) |
Method/enumerator signature |
varfor, foreach, using, out var) — not fields, parameters or return types.null alone (var x = null; is an error).var count = 0; count = "x"; does not compile.var declares a nullable reference type (var name = GetName(); is string?), but flow analysis tracks the actual null-state, so you rarely notice.varThe type is obvious from the right-hand side, or naming it adds noise:
var customer = new Customer("Ada"); // type stated on the right
var lookup = new Dictionary<string, List<OrderSummary>>(); // avoid repeating a long name
var query = db.Orders.Where(o => o.Status == Paid); // IQueryable<Order> - name is noise
var summary = orders.Select(o => new { o.Id, o.Total }); // anonymous type - var is REQUIRED
foreach (var (sku, qty) in basket) { ... }
decimal total = CalculateTotal(); vs var total = CalculateTotal(); (is it double? decimal? Money?).var price = 19.99; is a double — for money write decimal price = 19.99m;.IReadOnlyList<Order> orders = repo.GetAll(); documents intent and restricts what you can call.IEnumerable vs IQueryable matters: IQueryable<Order> q = ... makes it obvious the query is still server-side.async results: var result = GetOrderAsync(id); without await is a Task<Order>, a classic bug that explicit typing makes obvious.new and collection expressionsTarget-typed new() is the mirror image of var: type on the left, inferred on the right. It shines for fields and properties, where var is not allowed:
private readonly Dictionary<Guid, OrderState> _states = new();
private readonly List<string> _errors = []; // C# 12 collection expression
I avoid it when the target is far away (return new(...); from a method whose return type is several lines up) or in arguments where readers cannot tell what is being created (Process(new(), new(5));).
The choice is mostly style, so it belongs in .editorconfig, enforced at build time, not in code review debates:
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
A bug I have seen more than once: var orders = _repository.GetOrdersAsync(customerId); followed by if (orders == null) and passing orders around. Because there was no await, orders was a Task<List<Order>>; it compiled because the next method accepted object for logging, and the log showed System.Threading.Tasks.Task1[...]. Analyser CS4014 does not fire when the task is assigned. Writing List
A quick fluency check with a trap for the unwary.
Green flags
var (static) from dynamic.new and natural lambda types.IQueryable<T> versus IEnumerable<T> trap.Red flags
var x = 9.99 is a double.var is compile-time onlyvar total = 10m;
compiles to exactly the same IL as decimal total = 10m; — the local slot is typed [System.Runtime]System.Decimal. There is no runtime cost, no reflection, no late binding. Contrast with dynamic, which compiles to object plus DLR call sites.
Because the type is decided once, inference can lock in a narrower type than you intended:
var items = new List<Order>(); // List<Order>, not IList<Order>
var x = 5; // int - later x = 5_000_000_000 won't compile
var shape = new Circle(); // Circle - cannot later assign a Square
For Max(3, 7.5), the compiler runs a two-phase algorithm from the specification:
int and double become candidate bounds for T).double, since int converts implicitly to double.If no single best type exists (e.g. Max("a", 5)), inference fails with CS0411 and you must specify Max<object>(...).
Lambdas participate in phases: input types are inferred first from other arguments, then the lambda body is bound to infer output types. That is why Select(o => o.Total) works: TSource is known from the source, then TResult is inferred from the lambda body.
Inference never uses the return type or the variable being assigned to: T Create<T>() cannot be called as Order o = Create();.
var parse = (string s) => int.Parse(s); // Func<string, int>
var log = Console.WriteLine; // error: ambiguous overloads
var now = DateTime.UtcNow.ToString; // Func<string> if unambiguous
var maybe = string? (bool b) => b ? "y" : null; // explicit return type
The compiler picks Func<...>/Action<...> when they fit, or synthesises an internal delegate type when they don't (e.g. ref parameters). This also made minimal APIs possible: app.MapGet("/orders/{id}", (int id, IOrderService svc) => ...) infers a delegate with full parameter metadata for binding.
C# 12 added default parameter values in lambdas, and C# 14 allows parameter modifiers (ref, out, scoped) without explicit types.
new[] { 1, 2, 3 } → int[]; new[] { 1, 2.5 } → double[]; new[] { "a", 1 } → error (no best type; object is not chosen automatically).flag ? new Circle() : new Square() — before C# 9 an error; with target typing, Shape s = flag ? new Circle() : new Square(); works because the target type is known.[1, 2, 3]) have no natural type — var x = [1, 2, 3]; does not compile, by design, because the right collection type depends on the target.var and nullable reference typesWith NRT enabled, var locals are declared as nullable (string?), deliberately. The idea: the variable's declared type allows null, but the flow state after var s = "hello"; is not-null, so no warnings. This avoids spurious warnings when a variable is later assigned a maybe-null value. It means var can never be used to force non-nullability.
Microsoft's .NET runtime coding guidelines use var only when the type is explicitly named on the right side (new, explicit cast) — the runtime team optimises for reviewers reading diffs without IDE support. Many application teams use var more widely, relying on IDE inlay hints. Both are defensible; the senior point is to pick a rule, encode it in .editorconfig, and apply it consistently, with exceptions for numerics, interfaces, and async results.
var in var q = db.Orders.Where(...) and then add .AsEnumerable() — see how the inferred type changes and what that means for SQL translation.var price = 9.99; and pass it to a method taking decimal.var nums = [1, 2, 3]; and read the error..editorconfig rules above with :warning and build.namespace Types;
public sealed record Order(int Id, decimal Total);
public static class Inference
{
// Fields cannot use var; target-typed new keeps them short
private static readonly Dictionary<string, List<Order>> ByCustomer = new();
public static void Main()
{
var orders = new List<Order> { new(1, 120m), new(2, 40m) }; // obvious type
int[] ids = [1, 2, 3]; // collection expression needs a target
decimal threshold = 100m; // explicit for money
// Generic inference: TResult = decimal
var totals = orders.Select(o => o.Total);
// Anonymous type requires var
var summary = new { Count = orders.Count, Sum = orders.Sum(o => o.Total) };
Console.WriteLine(summary); // { Count = 2, Sum = 160 }
// Natural lambda type (C# 10)
var isBig = (Order o) => o.Total > threshold; // Func<Order, bool>
Console.WriteLine(orders.Count(isBig));
// Literal trap
var price = 9.99; // double!
Console.WriteLine(price.GetType().Name); // Double
Console.WriteLine($"{ids.Length} {totals.Sum()} {ByCustomer.Count}");
}
// Keep EF Core queries as IQueryable<T> so filters run in SQL
public static IQueryable<Order> BigOrders(IQueryable<Order> source, decimal min)
=> source.Where(o => o.Total >= min);
}
var as dynamic typingWhat happens: developers from JavaScript assume var variables can change type, or avoid var believing it is slower.
Why it is wrong: var is fully static; the IL is identical to an explicit declaration.
Do instead: explain and use it as a readability tool only.
var with numeric literals for moneyWhat happens: var price = 19.99; or var rate = 0.1;.
Why it is wrong: these are double, introducing binary rounding into financial code.
Do instead: use explicit decimal with the m suffix.
await behind varWhat happens: var order = _repo.GetAsync(id); and later order.Total.
Why it is wrong: order is a Task<Order>; depending on usage it either fails to compile far away or silently logs/serialises a Task.
Do instead: await on the same line, and enable analysers such as VSTHRD and CA2012 where relevant.
IQueryable / IEnumerable boundaryWhat happens: var orders = db.Orders.Where(...); passed to helpers that call .AsEnumerable() or .ToList() at different points.
Why it is wrong: readers cannot see where SQL ends and in-memory processing starts, leading to whole-table loads.
Do instead: type the boundary explicitly when it matters (IQueryable<Order>), and materialise deliberately.
var with non-obvious method resultsWhat happens: var result = Process(input); where result could be a tuple, a result type or a bool.
Why it is wrong: reviewers in a pull request (without IDE hints) cannot tell what they are reading.
Do instead: use explicit types when the method name does not make the type obvious, or improve the method name.
What happens: var shape = new Circle(); then later wanting to assign a Square.
Why it is wrong: var infers the most specific type of the initialiser.
Do instead: declare the abstraction explicitly: Shape shape = new Circle();.
new() in argumentsWhat happens: Send(new(), new("x", 5), new());
Why it is wrong: the reader cannot see which types are created without navigating to the method signature.
Do instead: use target-typed new for declarations where the type is visible on the left; name types in arguments.
What happens: Order o = Deserialize(json); with T Deserialize<T>(string json).
Why it is wrong: C# never infers type arguments from the assignment target; CS0411.
Do instead: specify the type argument explicitly: Deserialize<Order>(json).
var in code reviewWhat happens: repeated review comments about style.
Why it is wrong: it wastes time and causes inconsistency.
Do instead: agree a convention, encode it in .editorconfig with severity, and let dotnet format and the build enforce it.
var for anonymous-type alternatives in public flowsWhat happens: anonymous types created with var and passed around via object or dynamic.
Why it is wrong: anonymous types are internal and cannot be named outside the method, so they leak as object and lose type safety.
Do instead: use a record or tuple when data must leave the method.
Type inference is a readability tool; seniors care about where it hides important types.
| Situation | Hidden type that matters |
|---|---|
| EF Core query composition | IQueryable<T> vs IEnumerable<T> (SQL vs memory) |
Async call without await |
Task<T> vs T |
| Numeric literals | double vs decimal, int vs long |
foreach (var x in dataTable.Rows) |
object from non-generic collections |
| Natural lambda types | Func<> vs Expression<Func<>> (only expression trees translate to SQL) |
.editorconfig and dotnet format so reviews focus on behaviour.new() and collection expressions make explicit declarations short, removing most arguments for var in fields and properties.dynamic tells the compiler to skip type checking and resolve members, operators and overloads at run time. Under the hood the variable is just object; the compiler emits call sites that use the C# runtime binder on the DLR, which caches the binding for each run-time type. Compared with var (static inference) and object (static, needs casts), dynamic moves errors to run time as RuntimeBinderException, removes IntelliSense, is slower, and does not work with Native AOT. I only use it for COM or Office interop, scripting hosts or quick prototypes; for JSON I'd use JsonNode or typed records.
C# has integral types (byte to long, plus nint, Int128), binary floating point (float, double, Half) and decimal. I use int by default, long for IDs and counters that can pass 2.1 billion, double for measurements and science, and decimal for money because it is base-10 and exact for values like 0.1. Integer overflow is unchecked by default, so int.MaxValue + 1 silently wraps; I use checked where wrapping would be a bug. Doubles never throw: dividing by zero gives Infinity or NaN, and Math.Round defaults to banker's rounding.
Last updated 30 Oct 2026.