Anonymous types: compiler-generated internal classes, value equality via Equals but reference equality via ==, type unification, with-expressions, LINQ and EF Core projections, and when to switch to records or tuples.
An anonymous type, like new { o.Id, Total = o.Amount }, is a compiler-generated internal sealed class with read-only properties. It overrides Equals and GetHashCode to compare every property, but == is still reference equality because no operator is generated. Anonymous types with the same property names, types and order in one assembly share one type. They're perfect for local LINQ and EF Core projections. Since you can't name the type in a signature, I switch to a record or tuple as soon as the data has to leave the method.
Anonymous types are compiler-generated, unnamed classes created with new { ... }: var x = new { o.Id, o.Total };. They're perfect for local, temporary shapes — especially LINQ projections. I'd explain what the compiler generates, equality and immutability, the == vs Equals difference, when two anonymous types are the same type, their role in LINQ/EF Core, and their limits compared with tuples and records.
var summary = new { OrderId = o.Id, o.Total, Customer = o.Customer.Name };
The compiler generates an internal sealed class (name like <>f__AnonymousType0<...>) that is generic over the property types, with:
get only) with the given names (inferred from member access: o.Total → Total),Equals(object) and GetHashCode() over all properties,ToString(): { OrderId = 42, Total = 99.50, Customer = Ada },[DebuggerDisplay] attributes.You can only refer to the type via var (or generic inference), because it has no name you can write.
List<T> can still be mutated).with: var bigger = summary with { Total = 200m };.Equals compares all property values with EqualityComparer<T>.Default; GetHashCode combines them. So anonymous types work as keys in GroupBy, Distinct, ToDictionary, Join on multiple columns:var byRegionAndMonth = orders.GroupBy(o => new { o.Region, o.CreatedAt.Month });
== behaves differently from EqualsAnonymous types override Equals but don't overload ==. So == is reference equality:
var a = new { Id = 1, Name = "Ada" };
var b = new { Id = 1, Name = "Ada" };
a.Equals(b); // true - value equality
a == b; // false - different objects
(Records, by contrast, generate both.)
Two anonymous object expressions in the same assembly produce the same type if they have the same property names, in the same order, with the same types:
var a = new { Id = 1, Name = "x" };
var b = new { Id = 2, Name = "y" };
b = a; // OK - same type
var c = new { Name = "x", Id = 1 }; // different order -> different type
Across assemblies, they're different types (and internal), which is why returning them as object to another assembly and using dynamic fails.
var rows = await db.Orders
.Where(o => o.CreatedAt >= from)
.Select(o => new { o.Id, o.Total, CustomerName = o.Customer.Name })
.ToListAsync(ct);
SELECT of only those columns — no entity materialisation, no tracking.GroupBy keys, let-like shaping, joins).object/dynamic, losing type safety).Results.Ok(new { id, status })) and they serialise fine — convenient for small endpoints, but you lose OpenAPI type information and contract stability.| Anonymous type | Tuple (ValueTuple) |
Record | |
|---|---|---|---|
| Kind | Class (heap) | Struct (no allocation) | Class or struct |
| Named members | Yes (real properties) | Names are compile-time only | Yes |
| Usable in method signatures | No | Yes | Yes |
| Expression trees / EF projections | Yes | Limited (tuple literals not allowed in expression trees) | Yes (constructor or init) |
| Mutability | Immutable | Mutable fields | Immutable by default |
== |
Reference | Value (C# 7.3) | Value |
| Serialisation | Real property names | Item1/Item2 |
Real names |
| Cross-assembly | Internal, not shareable | Yes | Yes |
Rule of thumb: anonymous types for local LINQ projections, tuples for private multi-value returns, records for anything public, serialised, or shared.
A service method returned object containing an anonymous type, and a controller in another project used dynamic to read result.Total. It worked until the anonymous type's property was renamed in a refactor — the controller failed at run time with RuntimeBinderException. We replaced it with a record OrderSummary(int Id, decimal Total, string CustomerName) used in the EF projection; the rename became a compile error and the API's OpenAPI schema became accurate.
Checks knowledge of what the compiler generates and sensible usage.
Green flags: internal sealed generated class; Equals versus ==; type unification rules; LINQ/EF projections and composite keys; knows when to use records or tuples instead.
Red flags: thinks anonymous types are dynamic; returns them from APIs; unaware of read-only properties.
[CompilerGenerated, DebuggerDisplay("\\{ OrderId = {OrderId}, Total = {Total} }")]
internal sealed class <>f__AnonymousType0<<OrderId>j__TPar, <Total>j__TPar>
{
private readonly <OrderId>j__TPar <OrderId>i__Field;
private readonly <Total>j__TPar <Total>i__Field;
public <OrderId>j__TPar OrderId => <OrderId>i__Field;
public <Total>j__TPar Total => <Total>i__Field;
public override bool Equals(object? value) { ... EqualityComparer<T>.Default for each field ... }
public override int GetHashCode() { ... combines field hashes with constants ... }
public override string ToString() { ... }
}
Being generic over property types means one generated class definition is reused for all anonymous types with the same property names in the same order (instantiated with different types).
In IQueryable projections, new { o.Id, o.Total } becomes a NewExpression with Members information. EF Core maps each member to a column and constructs the anonymous object in the shaper. That's why anonymous types work in EF Core projections while tuple literals ((o.Id, o.Total)) can't be used in expression trees (CS8143).
GroupBy and composite keysAnonymous types as keys rely on generated Equals/GetHashCode. In EF Core, GroupBy(o => new { o.Region, o.Year }) translates to GROUP BY [Region], [Year]. In LINQ to Objects, the keys are hashed via the generated GetHashCode.
with on anonymous types (C# 10)anon with { Total = 0 } creates a new instance of the same anonymous type. Because properties are read-only, with is the only way to "change" one.
System.Text.Json serialises anonymous types by reading their public properties — names preserved. Deserialisation into anonymous types isn't directly supported (no parameterless constructor/setters), though you can deserialise into an anonymous-type "template" with some libraries (Newtonsoft's DeserializeAnonymousType).
Each anonymous object is a heap allocation. In hot LINQ pipelines over in-memory data (e.g. Select(x => new { x.A, x.B }) on millions of items), consider value tuples or structs instead to avoid allocations — but note tuples lose names at run time.
== and Equals.ToQueryString().namespace Advanced.AnonymousTypes;
public sealed record Sale(string Region, int Month, decimal Amount);
public sealed record SalesKey(string Region, int Month);
public static class Program
{
public static void Main()
{
var a = new { Id = 1, Name = "Ava" };
var b = new { Id = 1, Name = "Ava" };
Console.WriteLine(a.Equals(b)); // True: value equality
Console.WriteLine(a == b); // False: reference equality, no operator ==
Console.WriteLine(a.GetType() == b.GetType()); // True: same shape, same type
var reordered = new { Name = "Ava", Id = 1 };
Console.WriteLine(a.GetType() == reordered.GetType()); // False: order matters
var renamed = a with { Name = "Noah" }; // C# 10
Console.WriteLine(renamed); // { Id = 1, Name = Noah }
List<Sale> sales = [new("AU", 1, 100m), new("AU", 1, 50m), new("NZ", 2, 70m)];
// Composite key: works because of value equality
var totals = sales
.GroupBy(s => new { s.Region, s.Month })
.Select(g => new { g.Key.Region, g.Key.Month, Total = g.Sum(s => s.Amount) })
.ToList();
foreach (var t in totals) Console.WriteLine($"{t.Region}/{t.Month}: {t.Total}");
// Crossing a method boundary: switch to a named type
IReadOnlyDictionary<SalesKey, decimal> publicResult = totals.ToDictionary(t => new SalesKey(t.Region, t.Month), t => t.Total);
Console.WriteLine(publicResult[new SalesKey("AU", 1)]);
}
}
What happens: methods return object holding anonymous types, read via dynamic or reflection.
Why it is wrong: no type safety; renames break at run time.
Do instead: use named records for results that leave the method.
==What happens: if (keyA == keyB) with anonymous types.
Why it is wrong: reference equality; equal values compare false.
Do instead: use Equals.
What happens: expecting new { A, B } and new { B, A } to be the same type.
Why it is wrong: order matters for type identity.
Do instead: keep consistent ordering or use a named type.
What happens: Select(o => (o.Id, o.Total)) in an IQueryable.
Why it is wrong: tuple literals aren't allowed in expression trees.
Do instead: use anonymous types or records.
What happens: minimal APIs return anonymous objects for important contracts. Why it is wrong: OpenAPI loses schema information; contracts change silently. Do instead: use typed response records.
What happens: anonymous types holding mutable lists treated as immutable. Why it is wrong: the list can still be mutated. Do instead: use immutable collections for nested data.
What happens: projecting millions of items to anonymous objects. Why it is wrong: heap allocation per item. Do instead: value tuples or structs when performance matters.
dynamic to access anonymous types from other assembliesWhat happens: dynamic d = service.GetAnon(); d.Total.
Why it is wrong: anonymous types are internal — binder fails across assemblies.
Do instead: named types.
Anonymous types are a local tool: perfect for shaping data inside a LINQ or EF Core query, and wrong for anything that leaves the method. Knowing the == versus Equals difference and how composite keys translate to SQL GROUP BY shows depth. In code review I accept anonymous types inside queries and push back when they leak into public signatures as object or dynamic.
C# tuples like (int Id, string Name) are System.ValueTuple structs with public, mutable fields Item1 and Item2. The names you write exist only at compile time: they're stored in a TupleElementNames attribute, so reflection, serialisers and dynamic only see Item1 and Item2. Tuples have value equality and support ==, and since names aren't part of the type, (int A, int B) converts to (int X, int Y). System.Text.Json ignores fields by default, so a tuple serialises as {}. I use tuples for private multiple returns, and records for anything public or serialised.
A record is a type whose identity is its data. public record Money(decimal Amount, string Currency); makes the compiler generate init-only properties, a constructor, Deconstruct, value-based Equals/GetHashCode/==, a readable ToString, and a hidden clone method used by with expressions for non-destructive mutation. record (or record class, C# 9) is a reference type; record struct (C# 10) is a value type whose positional properties are mutable unless you write readonly record struct. Equality includes an EqualityContract so a base and derived record are never equal. Watch out: with is a shallow copy, collection members compare by reference, and records are a poor fit for EF Core entities, which need identity-based equality.
Last updated 30 Oct 2026.