Chapter 03 Csharp 3 Linq · How it works
13 min read- How it works
- 3.1 Automatically implemented properties
- 3.2 Implicit typing
- 3.2.1 Typing terminology (what interviewers mean)
- 3.2.2 Implicitly typed local variables (`var`)
- `var` vs `dynamic` vs `object`
- Implicit vs explicit typing (not the same as implicit vs explicit conversion)
- Explicit declarations vs `var`
- Target typing: the type flows from the left instead
- Implicit vs explicit **conversions**
- `dynamic`: the contrast case
- When to prefer an explicit type over `var`
- 3.2.3 Implicitly typed arrays
- 3.3 Object and collection initializers
- 3.3.1 Why they matter
- 3.3.2 Object initializers
- 3.3.3 Collection initializers
- 3.4 Anonymous types
- 3.4.1 What the compiler generates
- 3.4.2 `with` expressions (C# 10+)
- 3.4.3 Scope and limitations
- 3.4.4 LINQ projections
- 3.4.5 Anonymous types vs tuples vs records
- 3.5 Lambda expressions
- 3.5.1 Syntax
- 3.5.2 Capturing variables (closures)
- 3.5.3 Expression trees
- 3.6 Extension methods
- 3.6.1 Declaring an extension method
- 3.6.2 Invoking extension methods
- 3.6.3 Chaining calls (the LINQ style)
- 3.7 Query expressions (query syntax)
- 3.7.1 Query expressions translate from C# to C#
- 3.7.2 Range variables and transparent identifiers
- 3.8 The end result: LINQ
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,foreachandusingdeclarations. 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
nullliteral: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>), butvar f = x => x;doesn't, becausexhas no type. - Nullable context: with nullable reference types on,
varalways infers the nullable reference type (var s = "a";isstring?); flow analysis still knows it's non-null right now. varis a contextual keyword: if a type namedvaris 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 aTask? Abool? A DTO? - The
IQueryable<T>vsIEnumerable<T>boundary: whether the next.Whereruns in SQL or in memory depends on the static type. - Numeric literals:
var total = 0;isint; summing prices into it truncates or overflows where you meantdecimal. - You wanted the abstraction:
var items = new List<Order>();isList<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 is | Compile-time inference; not a type | The root type; everything converts to it | A static type that turns member binding off until runtime |
|---|---|---|---|
| Static type of the variable | Whatever the initializer is | object | dynamic (emitted as object + binder calls) |
Can you call .Length on a string? | Yes, checked by the compiler | No, cast first | Yes, resolved at runtime; typos throw RuntimeBinderException |
| Value types | Stored unboxed | Boxed | Boxed |
| Can be a field / parameter / return | No | Yes | Yes |
| Can be reassigned to another type | No | Yes (anything) | Yes (anything) |
| Cost | None at runtime | Casts, boxing | Runtime 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":
| Question | Implicit | Explicit | When 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 (int → long) | 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 targetint?gives both branches a type. - Lambdas with a natural type (C# 10):
var parse = (string s) => int.Parse(s);infersFunc<string, int>;var f = x => x;still fails, andFunc<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];— butvar c = [1, 2, 3];fails, because a collection expression has no natural type. defaultliteral: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):
byte→int→long→float/double; any integral type → any floating type;int→decimal. The docs: predefined implicit conversions always succeed and never throw. Caveat:int/long→floatandlong→doublecan lose precision (never magnitude):float f = 16_777_217;stores16777216. - Explicit (narrowing): needs a cast because it can lose data or throw.
(int)3.9is3(rounds toward zero);(byte)300is44in an unchecked context. - There's no implicit conversion between
decimalandfloat/doublein either direction, and none fromdoubleordecimalto 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 throwOverflowExceptioninsidechecked(...)and silently wrap (discard high bits) inunchecked. 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/explicitoperators: a type can declarepublic static implicit operator decimal(Money m)orpublic static explicit operator Digit(byte b). Microsoft's rule: make itimplicitonly if it can never throw and never loses information; otherwiseexplicit. Theisandasoperators 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>vsIEnumerable<T>,floatvsdoublevsdecimalarithmetic,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 classderiving fromobject, withpublicget-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
EqualsandGetHashCodeover all properties, so they work as dictionary keys, inDistinct, and inGroupBy/Joincomposite keys. ToStringthat 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 }andnew { 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
objectcompiles but throws away static typing; getting it back needs reflection ordynamic. 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
| Kind | internal sealed class (heap) | public struct | class or struct, you choose |
|---|---|---|---|
| Named members | Yes (properties) | Yes, but names are compile-time only (Item1, Item2 at runtime) | Yes |
| Mutability | Read-only | Mutable fields | init-only for positional record; read-write for record struct unless readonly |
| Equality | Value Equals; == is reference | Value Equals and == (names ignored) | Value Equals and == |
| Usable across method boundaries | No (can't name it) | Yes | Yes |
| Deconstruction | No | Yes | Yes (positional) |
| Expression trees (EF, IQueryable) | Yes | No | Yes |
with | Yes (C# 10) | Yes | Yes |
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.vsQueryable., 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.