Mert Özen Further With Every Line
Backend Object-Oriented Programming in C# — #14

Partial, Nested Types, Anonymous Types and Tuples in C#

Mert Özen Nov 7, 2026 9 min 5 views
Partial, Nested Types, Anonymous Types and Tuples in C#

Equality and limits of anonymous types, returning several values with tuples, why tuple names exist only for the compiler, partial classes and methods, nested types, and when to pick which.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We create three anonymous objects. a and b are exactly the same; in c only the order of the properties is different. What do the four lines print?

var a = new { Title = "Dune", Price = 12.99m };
var b = new { Title = "Dune", Price = 12.99m };
var c = new { Price = 12.99m, Title = "Dune" };

Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(a.GetType() == b.GetType());
Console.WriteLine(a.GetType() == c.GetType());

Anonymous Types: Nameless but Typed

Output:

False
True
True
False

When you write new { ... }, the compiler generates a class without a name for you. Within the same assembly, two anonymous objects with the same names, the same types and the same order share the same class; that is why a and b have the same type. In c the order is different, so the compiler generated a separate class. The generated class overrides Equals and GetHashCode based on the property values, but it does not define an == operator. The result: Equals checks value equality, while == checks the reference, as it does for classes. We saw why operators are a separate mechanism two posts ago.

The natural home of anonymous types is shaping an intermediate result in LINQ:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var books = new[]
{
    new Book("978-0441013593", "Dune", 12.99m),
    new Book("978-0141439518", "Emma", 7.50m),
    new Book("978-0547928227", "The Hobbit", 9.99m)
};

var cheap = books
    .Where(b => b.Price < 10m)
    .Select(b => new { b.Title, b.Price });

foreach (var item in cheap)
    Console.WriteLine(item);

var first = cheap.First();
var onSale = first with { Price = 5m };
Console.WriteLine(onSale);

public record Book(string Isbn, string Title, decimal Price);
{ Title = Emma, Price = 7.50 }
{ Title = The Hobbit, Price = 9.99 }
{ Title = Emma, Price = 5 }

In new { b.Title, b.Price } we did not give property names; the compiler inferred them from the expressions. The properties of anonymous types are read-only; if you try to change one, the build stops:

var item = new { Title = "Dune", Price = 12.99m };
item.Price = 9.99m;
Program.cs(2,1): error CS0200: Property or indexer '<anonymous type: string Title, decimal Price>.Price' cannot be assigned to -- it is read only

If you want a modified copy, there has been with since C# 10; the onSale line above shows it. The biggest limitation is the missing name. You cannot write an anonymous type as a method's return type or as a parameter, because there is no name to write. Since var only works for local variables, anonymous types in practice live inside a single method. If you need to carry the data out, it is time for a tuple or a record.

Tuples: Returning More Than One Value

Returning two values from a method used to require out parameters or a small helper class. Since C# 7 there are value tuples:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var prices = new[] { 12.99m, 7.50m, 9.99m };

var range = PriceRange(prices);
Console.WriteLine(range);
Console.WriteLine($"{range.Min} - {range.Max}");

var (min, max) = PriceRange(prices);
Console.WriteLine(max - min);

decimal low;
(low, var high) = PriceRange(prices);
Console.WriteLine($"{low}..{high}");

string first = "Dune", second = "Emma";
(first, second) = (second, first);
Console.WriteLine($"{first} {second}");

range.Min = 0m;
Console.WriteLine(range);

static (decimal Min, decimal Max) PriceRange(IEnumerable<decimal> values) => (values.Min(), values.Max());
(7.50, 12.99)
7.50 - 12.99
5.49
7.50..12.99
Emma Dune
(0, 12.99)

The (decimal Min, decimal Max) in the return type describes both the type and the names of the fields. You can keep the result in a single variable and read it with range.Min, or split it into its parts right away with var (min, max). The third form is a small C# 10 addition: in a deconstruction you can mix an existing variable (low) with a newly declared one (var high). Before this addition, they all had to be either new or existing.

Swapping two variables is a single line with a tuple too. The last two lines show an important detail: a value tuple is a struct and its fields are mutable. range.Min = 0m compiles. Do not expect the immutability habit you got from records in tuples.

Tuple Names Exist Only for the Compiler

How real are the names Min and Max? Let's see:

using System.Globalization;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

(decimal Min, decimal Max) range = (7.50m, 12.99m);
(decimal Cheapest, decimal Priciest) other = (7.50m, 12.99m);

Console.WriteLine(range == other);
Console.WriteLine(range.Item1);

object boxed = range;
Console.WriteLine(boxed.GetType());
Console.WriteLine(JsonSerializer.Serialize(range));
True
7.50
System.ValueTuple`2[System.Decimal,System.Decimal]
{}

Two tuples with different names came out equal with ==; tuple equality (C# 7.3) does not look at the names but at the values of the fields in order. Item1 is still accessible, because the real type of the tuple is ValueTuple<decimal, decimal> and the real names of its fields are Item1 and Item2. Min and Max are aliases the compiler offers you in source code; in compiled code they survive only as a TupleElementNames attribute added to member signatures (method return types, parameters, fields), and they do not exist on the runtime object.

The last line is a practical consequence of this: by default System.Text.Json only writes public properties, and ValueTuple has fields, not properties. The result is an empty JSON object. If you are thinking of returning a tuple from an API, remember this line. It is one of the reasons records are used instead of tuples for response models.

Partial Classes and Partial Methods

partial lets you split the definition of a class into several parts. The compiler merges all the parts and produces a single class:

var book = new Book("Dune");
book.Rename("Dune Messiah");
Console.WriteLine(book.Title);

public partial class Book
{
    public Book(string title) => Title = title;

    public string Title { get; private set; }

    public void Rename(string newTitle)
    {
        OnRenaming(Title, newTitle);
        Title = newTitle;
    }

    partial void OnRenaming(string oldTitle, string newTitle);
}

public partial class Book
{
    partial void OnRenaming(string oldTitle, string newTitle) =>
        Console.WriteLine($"{oldTitle} -> {newTitle}");
}
Dune -> Dune Messiah
Dune Messiah

The parts usually live in separate files; in this example we showed them in one file. The main use case is when part of the code is generated by a tool: the form code produced by the WinForms designer, the classes produced by gRPC and Entity Framework tooling, and source generators. The tool rewrites its own file from scratch every time, you touch your own part, and neither of you overwrites the other's code. We will see source generators in the Advanced series.

OnRenaming is a partial method: its signature is in one part, its body in another. If a partial method has no access modifier and returns void, and no body is written, the compiler removes the call completely; generated code can say "hook in here if you want". With C# 9, partial methods can also have return values, out parameters and access modifiers; but then the body is mandatory:

public partial class Book
{
    public partial bool IsValid();
}
Program.cs(3,25): error CS8795: Partial method 'Book.IsValid()' must have an implementation part because it has accessibility modifiers.

A warning: do not use partial to split a two-thousand-line class into four files and call it "organized". The class is still two thousand lines and still does too much; you just search for things in four files now. The cure for that is the single responsibility and composition we talked about in the earlier posts.

Nested Types: A Class Inside a Class

You can define a type inside another type. This has two benefits: the type visibly belongs where it is, and it can access the private members of the outer type:

var order = new Order.Builder(1001)
    .AddLine("978-0441013593", 2)
    .AddLine("978-0141439518", 1)
    .Build();

Console.WriteLine($"{order.Id}: {order.Lines.Count} lines");
Console.WriteLine(order.Lines[0]);
Console.WriteLine(typeof(Order.Builder).FullName);

public sealed class Order
{
    private readonly List<Line> _lines = new();

    private Order(int id) => Id = id;

    public int Id { get; }

    public IReadOnlyList<Line> Lines => _lines;

    public sealed record Line(string Isbn, int Quantity);

    public sealed class Builder
    {
        private readonly Order _order;

        public Builder(int id) => _order = new Order(id);

        public Builder AddLine(string isbn, int quantity)
        {
            _order._lines.Add(new Line(isbn, quantity));
            return this;
        }

        public Order Build() => _order;
    }
}
1001: 2 lines
Line { Isbn = 978-0441013593, Quantity = 2 }
Order+Builder

The constructor of Order is private; nobody outside can write new Order(...). But since Builder is defined inside Order, it can both call that constructor and add to the private _lines list. This way order lines can only be added through the builder. From outside, the nested types are accessed as Order.Builder and Order.Line; the full name in reflection is Order+Builder.

Use nested types for small helpers that have no meaning without the outer type. Burying a type that is meaningful on its own and used in many places inside a class forces you to write Outer.Inner on every use and makes it harder to discover.

Which One When

Anonymous types, tuples and records do similar things; when choosing, look at how far you will carry the data. If you are shaping an intermediate result inside a single method, usually in a LINQ query, use an anonymous type. If you are returning two or three values from a private or internal helper method and the values get deconstructed right away, use a tuple. If the data crosses a public API, gets serialized or travels through several layers, use a record, which has a name, documentation and immutability.

Partial and nested types answer a different question: where should the code live? Partial for separating tool-generated code from hand-written code, nested for a helper tightly bound to its outer type.

Common Pitfalls

Comparing anonymous objects with ==. As you saw in the opening example, == checks the reference; use Equals for value comparison.

Thinking tuple names live at runtime. Serialization, reflection and dynamic do not see the names; in JSON you get an empty object or Item1. Use a record for data that leaves your code.

Leaving the format of money to decimal. In the anonymous type example, Emma's price printed as 7.50, while the copy we made with with printed Price = 5, not 5.00. A decimal carries a scale along with its value, and that scale depends on where the value came from: the 7.50m literal carries two digits, 5m carries zero. Write every money value you show to a user with an explicit format such as ToString("0.00"); do not rely on the scale.

Long tuples. Someone calling a method that returns (string, string, decimal, int, bool) cannot tell what the fifth field means. If you have more than three fields, or the same tuple travels through several methods, write a record.

A Small Experiment

★☆☆ From the books array, produce anonymous objects holding each book's title and its price including VAT (Price * 1.2m) and print them. Then do the same with a list of tuples.

★★☆ Extend PriceRange to (decimal Min, decimal Max, decimal Average) so it also returns the average price. If the caller only needs Average, throw the others away with discards (_) in the deconstruction.

★★★ What happens if AddLine is called on Order.Builder after Build()? Make the builder throw an exception after Build, and write a small test that proves it.

We have now seen all the tools for defining types. In the next post we put them together and look at three common design patterns: factory, which gathers object creation in one place; strategy, which splits behavior into replaceable parts; and observer, which announces changes. All with modern C#.