This article covers advanced C# syntax gotchas that catch even experienced .NET developers off guard. We walk through records vs structs, delegates and events, default interface methods, IQueryable pitfalls, LINQ closures, and several other nuances that matter in production code and senior-level interviews.
Hi, my name is Vlad, and in this article, I’ll prove that you don’t know C# syntax well enough :)
Let’s walk through a few language nuances - not necessarily used every day, but very real and important in practice.
"A record is like a struct, just better."
This is, of course, incorrect. Why does this reveals weak understanding?
People often confuse the equality model (value-based equality) with the runtime type category (reference vs. value). By default, a record is a reference type - in other words, a class. It lives on the heap, has object identity, is managed by the GC, and so on.
public record Person(string Name);var a = new Person("Mykola");var b = new Person("Mykola");Console.WriteLine(a == b); // True (value-based equality)Console.WriteLine(ReferenceEquals(a, b)); // False (different objects)
How it works:
The compiler generates Equals, GetHashCode, and the ==/!= operators so that equality is based on member values.
A positional record declaration ((string Name)) generates init properties and a Deconstruct method.
So "under the hood" this is still a regular class.
But there is also a record struct
public readonly record struct Point(int X, int Y);var p1 = new Point(1, 2);var p2 = new Point(1, 2);Console.WriteLine(p1.Equals(p2)); // True
A record struct is a value type, but with record-like behavior. It typically provides:
value-based equality
"with" operator support
Deconstruct support
A record is not fully immutable
Yes - but also no.
A record is still a class. It simply gives you convenient syntax to declare a primary constructor without a body, and the compiler generates init properties and a deconstructor for you. But the type is still a class, and nothing prevents you from adding mutable state.
This is perfectly valid C#:
public record User(string Login){ public int Age { get; set; } // mutable!}var u = new User("neo") { Age = 30 };u.Age = 31; // totally valid
What you cannot do is mutate an init property after initialization. In that case, you need to create a new instance using "with".
“with” can update both positional members and properties declared in the record body:
var u2 = u with { Login = "trinity", Age = 25 };
Can classes have the modifiers private, protected, protected internal, or private protected?
They can - but only when they are nested inside another class.
Top-level classes can only be public or internal. Other access modifiers are not allowed at the top level because they only make sense when a type is a member of another type.
Nested types, on the other hand, can use any access modifier because they become members of the containing class:
public class Outer{ private class Secret { } protected class ProtectedInner { } internal class InternalInner { } private protected class PPInner { }}
"in/out in delegates doesn’t change anything"
Variance (co/contra-variance) is supported by default for delegate type parameters in terms of signature compatibility - the ability to use a derived or base type in place of the original type in a compatible position. However, it is not automatically applied to assigning delegate instances unless the generic delegate itself is declared variant.
So, let’s break down what in and out have to do with it.
Assume the following class hierarchy:
public class Animal { public string Name { get; set; } }public class Cat : Animal { }public class Dog : Animal { }
Now define four delegates:
Two delegates represent a factory method signature that returns Animal:
public delegate TAnimal OutAnimalFactory<out TAnimal>();public delegate TAnimal AnimalFactory<TAnimal>();
Two delegates represent a method that only accepts Animal as an argument:
public delegate void InAnimalProcessor<in TAnimal>(TAnimal arg);public delegate void AnimalProcessor<TAnimal>(TAnimal arg);
Key rules:
The out keyword can be used only for output positions (return types).
The in keyword can be used only for input positions (method parameters).
So what do in and out actually give you in a delegate signature?
out - output position: the result can be more specific. In other words, instead of returning just Animal, the method can return Cat or Dog.
in - input position: the parameter can be more general. In other words, a method that accepts Animal can be used where Cat or Dog is expected.
OutAnimalFactory<Cat> makeCat = () => new Cat();OutAnimalFactory<Animal> okFactory = makeCat; // ✅ compilingInAnimalProcessor<Animal> processAny = a => Console.WriteLine($"Got a {a.GetType().Name}");InAnimalProcessor<Cat> okProcessor = processAny; // ✅ compilingAnimalFactory<Cat> invMakeCat = () => new Cat();AnimalFactory<Animal> badFactory = invMakeCat; // ❌will not work without outAnimalProcessor<Animal> invProcessor = a => Console.WriteLine($"Got a {a.GetType().Name}");AnimalProcessor<Cat> badProcessor = invProcessor; // ❌ will not work without in
"Custom delegates are no longer needed - we have Func/Action"
In many cases, Func<> and Action<> are indeed enough (and they are already variant: Func<out TResult>, Action<in T>). But there are scenarios where a custom delegate is either required or significantly better.
You typically need a custom delegate when you want:
ref/in/out parameters (Func/Action do not support them)
attributes on the delegate itself or on its parameters
a readable, explicit name in a public API
a signature that precisely matches an event contract or a specific pattern
Example using out and "Try" semantics:
public delegate bool TryParseRef(ReadOnlySpan<char> s, out int value);
Or custom delegates in UI programming:
public delegate void ClosingHandler(object sender, ref bool cancel);public delegate void KeyPressedHandler(object sender, int keyCode, ref bool handled);
A more accurate way to phrase it:
Func<> and Action<> are the default choice. Custom delegates still matter in desktop development and in very targeted API design scenarios where signature, semantics, or tooling support are important.
Interfaces can have virtual and abstract members
Modern C# has supported this for a while.
Default Interface Methods (C# 8+)
Interfaces can declare methods with bodies. Semantically, these behave like "virtual" members: an implementing type can inherit the default implementation (unless it provides its own).
public interface ILogger{ void Log(string msg); // abstract (no body) void Info(string msg) => Log("[INFO] " + msg); // has a body}
A class must implement Log, while Info can be inherited as-is.
static abstract/virtual members in interfaces (C# 11+)
C# 11 introduced static abstract members in interfaces - the foundation for "generic math" and compile-time constraints over operators and static APIs.
public interface IAddable<T>{ static abstract T operator +(T a, T b);}public static T Sum<T>(T a, T b) where T : IAddable<T> => a + b;
So, in modern C#, an interface is not always a "pure abstraction". It can also serve as a mechanism for backward-compatibility with code (via default implementations) and for expressing powerful generic constraints (via static abstract members).
IQueryable can be in-memory
Why would you use IQueryable against an in-memory collection in real scenarios?
I have the following example: one filtering pipeline for EF and in-memory (expression-based specifications)
You store filters as Expression<Func<T, bool>> so that:
In EF, the expression can be translated to SQL
In tests or offline mode, the same expression can be applied to a list
Example:
Expression<Func<User, bool>> IsAdult() => u => u.Age >= 18;IQueryable<User> Apply(IQueryable<User> src) => src.Where(IsAdult());//Now you can use the same Apply(...) method in both environments:// Production: EFvar dbQuery = Apply(dbContext.Users);// Tests / offline: in-memoryvar memQuery = Apply(usersList.AsQueryable());
In this setup, in-memory IQueryable is primarily an API unification tool - the same query composition method works both for EF-backed queries and for in-memory collections.
Count() does not always enumerate the entire collection
Did you assume that Count() in LINQ always performs a full iteration over the collection?
In reality, it doesn’t - not in every case. Let’s take a look at the source code of Count() to see why.
As you can see, Count() tries to avoid enumerating the source until it has no other option.
// Licensed to the .NET Foundation under one or more agreements.// The .NET Foundation licenses this file to you under the MIT license.// See the LICENSE file in the project root for more information.public static int Count<TSource>(this IEnumerable<TSource> source) { if (source == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source); } if (source is ICollection<TSource> collectionoft) //<< here { return collectionoft.Count; } if (source is IIListProvider<TSource> listProv) //<< here { return listProv.GetCount(onlyIfCheap: false); } if (source is ICollection collection) //<< here { return collection.Count; } int count = 0; using (IEnumerator<TSource> e = source.GetEnumerator()) { checked { while (e.MoveNext()) { count++; } } } return count; }
LINQ Zip / Chunk / ToLookup / Repeat
Do you use these LINQ methods?
Zip - pairwise merge
var names = new[] { "A", "B", "C" };var ages = new[] { 10, 20 };var zipped = names.Zip(ages); // ("A", 10), ("B", 20) - stops at the shorter sequence
Zip combines two sequences element-by-element. Enumeration ends as soon as either sequence runs out of elements.
Chunk - split into batches
var data = Enumerable.Range(1, 10);foreach (var chunk in data.Chunk(3)){ Console.WriteLine(string.Join(",", chunk));}
Chunk splits a sequence into arrays of a fixed size (the last chunk can be smaller).
ToLookup - "dictionary key -> many values"
var words = new[] { "cat", "car", "dog" };var lookup = words.ToLookup(w => w[0]);Console.WriteLine(string.Join(",", lookup['c'])); // cat,car
ToLookup materializes a lookup structure where each key maps to a collection of values. Conceptually, it’s like a Dictionary<TKey, List<TValue>>, but with a dedicated API and behavior.
Repeat - repeat a value N times
var zeros = Enumerable.Repeat(0, 5); // 0,0,0,0,0Repeat produces a sequence with the same value repeated a specified number of times.
Flags in enums
Flags can be combined using bit masks (powers of two).
Adding [Flags] improves both semantics and formatting - for example, composite values are displayed as comma-separated names instead of raw integers.
Frozen collections are optimized for read-heavy workloads. You create them once, and after that they do not change.
var frozen = new Dictionary<int, string>{ [1] = "a"}.ToFrozenDictionary();
Immutable (System.Collections.Immutable) - persistent data structures
//Immutable collections are persistent: any "mutation" returns a new collection instance.var imm = ImmutableList<int>.Empty.Add(1).Add(2);var imm2 = imm.Add(3); // imm is unchanged
Concurrent - mutation-friendly collections for parallel access
Concurrent collections are designed for multi-threaded access with mutations. They provide thread-safe, typically atomic operations, but they do not give you full "transactional" semantics across multiple operations.
BlockingCollection - producer/consumer with blocking and bounded capacity
BlockingCollection<T> supports producer-consumer patterns with blocking and optional capacity limits. In modern codebases, it’s often replaced by Channel<T>, depending on the use case.
Pattern matching is multi-dimensional
Don’t overlook this - pattern matching is a powerful tool borrowed from functional programming.
Consider the following code. It is perfectly valid C#:
var data = new[] { 2, 5, 1, 0 };if (data is [_, > 0, ..] or [.., <= 0, _]){ Console.WriteLine("condition met");}
How to read it:
[_, > 0, ..] - an array with at least 2 elements where the second element is greater than 0
[.., <= 0, _] - an array where the second-to-last element is less than or equal to 0
.. - "the rest" (a slice) of any length
Another example:
bool EndsWithSingleIntList(List<List<int>> lol) => lol is [.., [_]];
[_] - a list with exactly one element
Can you "get an object back" from the GC?
GC is not an archive. If an object has been collected, its memory has been freed or reused, and you no longer have a reference - you cannot bring it back.
What actually exists in practice:
A) WeakReference - a reference without keeping the object alive
public static class Program{ static WeakReference<object> weak; public static void method() { weak = new WeakReference<object>(new object()); } public static void Main() { method(); GC.Collect(); GC.WaitForPendingFinalizers(); Console.WriteLine(weak.TryGetTarget(out _)); //false }}
A WeakReference lets you reference an object without preventing it from being collected. If the object survives, you can still retrieve it. If it’s gone - it’s gone.
B) "Resurrection" in a finalizer
It is technically possible to resurrect an object from a finalizer, but it is almost always a bad idea. It breaks predictability and makes GC behavior harder to reason about.
Frequently Asked Questions
What are the most common C# syntax mistakes senior developers make?
Even experienced C# developers confuse records with structs, misuse delegates and events, misunderstand IQueryable vs IEnumerable, overlook default interface methods side effects, and write LINQ that triggers multiple enumerations or unexpected closures.
What is the difference between a record and a record struct in C#?
A record is a reference type with value-based equality. A record struct is a value type with the same equality semantics. They differ in memory allocation, default mutability, and how they behave with collections and pattern matching.
Why is IQueryable dangerous compared to IEnumerable?
IQueryable builds expression trees that translate to SQL at execution time. Returning IQueryable from a repository lets callers silently add filters or projections that change the generated query, potentially causing N+1 problems or loading entire tables into memory.
Can you have method bodies in C# interfaces?
Yes, since C# 8. Default interface methods let you add a method body directly in the interface. However, the default implementation is only accessible through the interface reference, not through the concrete class, which surprises many developers.
What is the difference between a delegate and an event in C#?
A delegate is a type-safe function pointer that anyone can invoke. An event wraps a delegate and restricts external code to only adding or removing handlers — it cannot be invoked or reassigned from outside the declaring class.
Thanks for reading all the way to the end - and I hope you won’t be asking these questions in interviews :)
Senior Software Engineer & Microsoft MVP with 15+ years building scalable backend systems. Founder of Ukraine's largest .NET community. Writes about clean architecture, design patterns, EF Core, Azure, and engineering career growth.