Chapter 03 Csharp 3 Linq · How it works

13 min read
Mid-level4 min read
Rapid overview

How it works


3.1 Automatically implemented properties

Auto-properties remove boilerplate when a property is just a simple backing field:

public string Name { get; set; }

Interview nuance:

  • Auto-properties are great for simple state exposure; once you need invariants/validation, you usually move to an explicit backing field or validation in the setter/constructor.
  • In C# 3 specifically, auto-properties don’t support initializers or true read-only auto-properties (later versions do).

3.2 Implicit typing

3.2.1 Typing terminology (what interviewers mean)

Keep these terms crisp:

  • Static typing vs dynamic typing: when binding/type checks happen (compile time vs runtime).
  • Explicit typing vs implicit typing: whether you must write the type name in source, or the compiler infers it from context. Both are static; see "Implicit vs explicit typing" below.
  • Implicit vs explicit conversion is a different axis: whether a value of one type turns into another without a cast. Interviewers often mix the two up.

3.2.2 Implicitly typed local variables (var)

var is still statically typed. The compiler infers the type from the initializer at compile time and the variable keeps that type for its whole life; var does not mean "variant", loosely typed, or late-bound.

var language = "C#";          // string
var count = 5;                // int  (not long, not decimal)
var list = new List<int>();   // List<int>, not IList<int>
language = 42;                // CS0029: still a string

The rules (Microsoft Learn, "Implicitly typed local variables"):

  • Locals only: method-scope locals, plus for, foreach and using declarations. Never fields, parameters or return types. Fields are excluded because the compiler records each field's type before it analyses any initializer.
  • Declared and initialised in one statement. var x; is an error.
  • No null literal: var x = null; has no type to infer. var x = (string?)null; compiles.
  • One declarator per statement: var a = 1, b = 2; is an error.
  • No self-reference: var i = (i = 20); is an error (int i = (i = 20); is legal).
  • Lambdas and method groups: historically not allowed. Since C# 10 they are, if they have a natural type: var parse = (string s) => int.Parse(s); compiles (Func<string,int>), but var f = x => x; doesn't, because x has no type.
  • Nullable context: with nullable reference types on, var always infers the nullable reference type (var s = "a"; is string?); flow analysis still knows it's non-null right now.
  • var is a contextual keyword: if a type named var is in scope, it binds to that type instead.

When var is required: when the type has no name you can write, i.e. an anonymous type (var p = new { Name, Age };) or a query that projects one (IEnumerable<anonymous>), including the foreach variable that walks it.

When it helps: the type is obvious from the right-hand side (new, a cast, as), or it's long and noisy (IEnumerable<IGrouping<string, Student>>) and the variable name carries the meaning.

When it hurts readability:

  • The type isn't visible and matters: var result = service.Process(order); — is it a Task? A bool? A DTO?
  • The IQueryable<T> vs IEnumerable<T> boundary: whether the next .Where runs in SQL or in memory depends on the static type.
  • Numeric literals: var total = 0; is int; summing prices into it truncates or overflows where you meant decimal.
  • You wanted the abstraction: var items = new List<Order>(); is List<Order>, so code quietly couples to the concrete type.

Microsoft's own coding convention: use var when the type is obvious from the right side or precise type isn't important; don't use it when the type isn't apparent, and don't rely on the variable name to convey the type.

var vs dynamic vs object

What it isCompile-time inference; not a typeThe root type; everything converts to itA static type that turns member binding off until runtime
Static type of the variableWhatever the initializer isobjectdynamic (emitted as object + binder calls)
Can you call .Length on a string?Yes, checked by the compilerNo, cast firstYes, resolved at runtime; typos throw RuntimeBinderException
Value typesStored unboxedBoxedBoxed
Can be a field / parameter / returnNoYesYes
Can be reassigned to another typeNoYes (anything)Yes (anything)
CostNone at runtimeCasts, boxingRuntime binder (cached call sites), boxing, no IntelliSense

Implicit vs explicit typing (not the same as implicit vs explicit conversion)

Two separate questions hide behind the word "implicit":

QuestionImplicitExplicitWhen it's decided
Typing: who writes the variable's type?The compiler infers it (var, target-typed new())You write it (int x = 5;)Compile time, either way
Conversion: does a value change type without a cast?Yes, for safe widening conversions (intlong)A cast is required for narrowing ones ((int)3.9)Compile time picks the conversion; it runs at runtime

var x = 5; is implicit typing: x is int. long y = x; is an implicit conversion: an int value widened to long. Neither involves dynamic, which is the only one that moves anything to runtime.

Explicit declarations vs var

int count = 5;                 // explicit typing
var count2 = 5;                // implicit typing — also int, also static
long big = 5;                  // explicit type + implicit int→long conversion of the literal
var big2 = 5L;                 // implicit typing needs the suffix to get long
decimal price = 9.99m;         // without the m suffix: CS0664, double doesn't convert implicitly to decimal

Both forms produce identical IL; the choice is only about who writes the type.

Target typing: the type flows from the left instead

The opposite direction of var: you state the type on the left and the right-hand side takes it from there.

  • Target-typed new() (C# 9): List<Order> orders = new();, Dictionary<string, int> map = new();, also in field initializers and arguments. var x = new(); is an error: there is nothing to infer from on either side.
  • Target-typed conditional (C# 9): int? n = flag ? 1 : null; compiles because the target int? gives both branches a type.
  • Lambdas with a natural type (C# 10): var parse = (string s) => int.Parse(s); infers Func<string, int>; var f = x => x; still fails, and Func<int, int> f = x => x; works because the target types the parameter.
  • Collection expressions (C# 12): int[] a = [1, 2, 3];, List<int> l = [1, 2, 3]; — but var c = [1, 2, 3]; fails, because a collection expression has no natural type.
  • default literal: int n = default;.

Rule of thumb: var needs the right side to carry the type; target typing needs the left side to carry it. You can't drop both.

Implicit vs explicit conversions

  • Implicit (widening): byteintlongfloat/double; any integral type → any floating type; intdecimal. The docs: predefined implicit conversions always succeed and never throw. Caveat: int/longfloat and longdouble can lose precision (never magnitude): float f = 16_777_217; stores 16777216.
  • Explicit (narrowing): needs a cast because it can lose data or throw. (int)3.9 is 3 (rounds toward zero); (byte)300 is 44 in an unchecked context.
  • There's no implicit conversion between decimal and float/double in either direction, and none from double or decimal to anything else.
  • An integer constant converts implicitly to a smaller type if it fits: byte b = 13; compiles, byte b = 300; is CS0031.
  • checked / unchecked: integral narrowing and arithmetic overflow throw OverflowException inside checked(...) and silently wrap (discard high bits) in unchecked. The default for non-constant expressions is unchecked unless the project sets <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>. decimal → integral throws on overflow regardless; double → integral out of range gives an unspecified value when unchecked.
int big = int.MaxValue;
int wrapped = big + 1;                 // -2147483648 (unchecked default)
int boom = checked(big + 1);           // OverflowException
byte low = unchecked((byte)300);       // 44
  • User-defined implicit / explicit operators: a type can declare public static implicit operator decimal(Money m) or public static explicit operator Digit(byte b). Microsoft's rule: make it implicit only if it can never throw and never loses information; otherwise explicit. The is and as operators ignore user-defined conversions; only a cast invokes an explicit one.

dynamic: the contrast case

dynamic d = GetThing(); d.Frobnicate(); compiles with no checks. The member lookup, overload resolution and any conversions happen at runtime through the C# runtime binder, and a missing member throws RuntimeBinderException. That is not inference: var fixes a real static type at compile time, dynamic is a static type whose purpose is to switch checking off. Use it for COM interop, dynamic-language interop, or ExpandoObject, not to avoid writing a type name.

When to prefer an explicit type over var

  • The type isn't obvious from the right-hand side (var x = repo.Load(id);).
  • You want a different type than the initializer's: an interface (IReadOnlyList<Order> items = new List<Order>();), a wider numeric type (long total = 0;, decimal sum = 0;), or a nullable (int? count = null;).
  • The static type changes behaviour: IQueryable<T> vs IEnumerable<T>, float vs double vs decimal arithmetic, Span<T> vs array.
  • Public-facing samples and code reviews where readers don't have an IDE's hover.

Use var (or target-typed new()) when the type is already on the line: new, a cast, as, a generic factory call like GetService<IClock>(), or an anonymous type.

3.2.3 Implicitly typed arrays

Useful when the compiler can infer a single element type:

var numbers = new[] { 1, 2, 3 }; // inferred as int[]

Interview trap:

  • Mixed numeric literals can change the inferred element type (e.g., new[] { 1, 2L }long[]).

3.3 Object and collection initializers

3.3.1 Why they matter

Initializers let you build objects/collections in a single expression, which:

  • Reduces “set this then set that” noise.
  • Makes projection code (LINQ results) much cleaner.

3.3.2 Object initializers

var p = new Person { FirstName = "Ada", LastName = "Lovelace" };

Interview nuance:

  • This is still “construction + assignments” (not constructor parameters), so it doesn’t enforce invariants the way constructors do.

3.3.3 Collection initializers

var list = new List<int> { 1, 2, 3 };

3.4 Anonymous types

Anonymous types give you a set of read-only properties in one object without declaring a type first. The compiler generates the class and its name; you can't write that name in source, which is why you hold one in var.

var person  = new { Name = "Alice", Age = 30 };
var product = new { productName, price };        // names inferred from the variables
var summary = new { p.Name, Length = p.Name.Length }; // inferred from member access

3.4.1 What the compiler generates

  • An internal sealed class deriving from object, with public get-only properties and a constructor. Roughly:
internal sealed class <>f__AnonymousType0<TName, TAge>
{
    public TName Name { get; }
    public TAge Age { get; }
    // ctor, Equals, GetHashCode, ToString
}
  • Value-based Equals and GetHashCode over all properties, so they work as dictionary keys, in Distinct, and in GroupBy/Join composite keys.
  • ToString that prints the values: { Name = Alice, Age = 30 }.
  • Type unification: two initializers in the same assembly with the same property names, types and order produce the same type. new { A = 1, B = 2 } and new { B = 2, A = 1 } are different types.
  • Immutable (shallowly): properties are read-only. A property holding a List<T> can still have items added.

Trap: == on two anonymous objects compares references (it's a class with no operator overload). a.Equals(b) is the value comparison.

var a = new { Name = "Alice", Age = 30 };
var b = new { Name = "Alice", Age = 30 };
a.Equals(b);   // True  — generated value equality
a == b;        // False — reference comparison

3.4.2 with expressions (C# 10+)

Anonymous types support non-destructive mutation:

var older = person with { Age = 31 };   // new instance; person unchanged

3.4.3 Scope and limitations

  • Method-local: you can't name the type, so it can't be a method return type, parameter type, or field type. Returning it as object compiles but throws away static typing; getting it back needs reflection or dynamic. Don't.
  • No methods, events or custom operators, and no mutable properties.
  • A property can't be initialised with null, a lambda, or a pointer (they have no type to infer), unless you cast: new { Middle = (string?)null }.
  • If you need the shape outside the method (API response, cache entry, message), declare a record.

3.4.4 LINQ projections

This is what anonymous types were built for in C# 3:

var rows = orders
    .Where(o => o.Total > 100)
    .Select(o => new { o.Id, Customer = o.Customer.Name, o.Total });

var byCustomer = orders.GroupBy(o => new { o.CustomerId, o.Year }); // composite key

With EF Core an anonymous projection is translated into a SQL SELECT of just those columns, because anonymous types are allowed inside expression trees (tuples are not).

3.4.5 Anonymous types vs tuples vs records

Kindinternal sealed class (heap)public structclass or struct, you choose
Named membersYes (properties)Yes, but names are compile-time only (Item1, Item2 at runtime)Yes
MutabilityRead-onlyMutable fieldsinit-only for positional record; read-write for record struct unless readonly
EqualityValue Equals; == is referenceValue Equals and == (names ignored)Value Equals and ==
Usable across method boundariesNo (can't name it)YesYes
DeconstructionNoYesYes (positional)
Expression trees (EF, IQueryable)YesNoYes
withYes (C# 10)YesYes

Microsoft's current guidance: prefer tuples for most new local groupings (value type, deconstruction); keep anonymous types for expression-tree scenarios such as EF projections or when you want reference semantics; use a record the moment the shape crosses a method boundary. When serialization matters, declare a class or struct.


3.5 Lambda expressions

Lambdas are an inline way to express behavior:

Func<int, int> square = x => x * x;

3.5.1 Syntax

  • Expression lambdas: x => x * 2
  • Statement lambdas: x => { var y = x * 2; return y; }

3.5.2 Capturing variables (closures)

Closures capture variables, not values. Interview-relevant pitfalls:

  • Capturing loop variables incorrectly (classic foreach/for issues in older versions).
  • Capturing a mutable variable shared across async continuations.

Rule of thumb:

  • Prefer creating a local copy inside the loop if you’re capturing it (or use modern C# behavior consciously).

3.5.3 Expression trees

Lambdas can be represented as:

  • Delegates (executable code)
  • Expression trees (data describing the code)

Why it matters:

  • LINQ providers (e.g., EF) can translate expression trees to SQL instead of executing them in memory.

Interview trap:

  • “Why did my query run client-side?” → often because you converted to IEnumerable<T> too early or used a method the provider can’t translate.

3.6 Extension methods

Extension methods let you “add” methods to existing types without modifying them.

3.6.1 Declaring an extension method

public static class StringExtensions
{
    public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(s);
}

3.6.2 Invoking extension methods

Looks like an instance call:

if (input.IsBlank()) { /* ... */ }

3.6.3 Chaining calls (the LINQ style)

LINQ is largely “extension method pipelines”:

var top = products
    .Where(p => p.StockCount > 0)
    .OrderByDescending(p => p.Price)
    .Select(p => new { p.Name, p.Price });

Interview nuance:

  • Extension methods are resolved at compile time. If you accidentally use Enumerable. vs Queryable., you can change execution location (in-memory vs provider).

3.7 Query expressions (query syntax)

Query expressions are syntax sugar over method calls. They translate to calls like Where, Select, SelectMany, Join, etc.

3.7.1 Query expressions translate from C# to C#

Practical takeaway:

  • Query syntax and method syntax are equivalent; choose whichever is clearer for the specific query.

3.7.2 Range variables and transparent identifiers

When you chain multiple from/join clauses, the compiler may synthesize intermediate shapes to carry state forward.

Interview angle:

  • Understanding that translation exists helps debug odd-looking types and projection behavior.

3.8 The end result: LINQ

One query can involve multiple features at once:

  • var (because the result type might be anonymous)
  • anonymous types (projection)
  • query syntax (readability)
  • lambdas + extension methods (translation target)
  • expression trees (provider translation for IQueryable<T>)

Key interview competency:

  • Explain where code executes (database/provider vs in-memory) and why.

See also