with Expressions and Deconstruction

Copy a record while changing one property, and split a record back into separate variables.

Syntaxvar copy = original with { PropertyName = newValue };

An immutable type raises an obvious question: how do you change one field? You do not. You make a copy that differs in that one field. C# has a dedicated expression for it.

with

original with { Status = "shipped" } produces a new instance identical to original except for the listed members. Nothing about original changes, which is why this is called nondestructive mutation. The longhand is a constructor call repeating every other value by hand, and the two produce equal results.

The braces use object initializer syntax. A property can only appear there if it has an init or set accessor, which positional record properties always do. with works on records, on any struct, and on anonymous types; the struct and anonymous-type cases were added in C# 10.

Deconstruction

Deconstruction is the reverse: it pulls a value apart into separate variables. var (id, quantity, status) = order; calls the record's generated Deconstruct method, which has one out parameter per positional parameter. You can write the types out instead of var, mix the two, or assign into variables that already exist. Because deconstruction is a method call, it also works inside a positional pattern: order is (_, > 10, _).

What trips people up

Only positional parameters get a generated Deconstruct. A property you declared yourself in the record body is not included, and a record with no brackets gets no Deconstruct at all unless you write one.

Example

Example · csharp
Order original = new Order("A-100", 2, "pending");

// Shorthand: copy everything, change Status.
Order shipped = original with { Status = "shipped" };

Console.WriteLine(original);     // unchanged
Console.WriteLine(shipped);
Console.WriteLine(original == shipped);

// Longhand: the same result, every other value repeated by hand.
Order byHand = new Order(original.Id, original.Quantity, "shipped");
Console.WriteLine(byHand == shipped);

// Deconstruction splits the record back into separate variables.
var (id, quantity, status) = shipped;
Console.WriteLine($"{id} x{quantity} is {status}");

record Order(string Id, int Quantity, string Status);

// Output:
// Order { Id = A-100, Quantity = 2, Status = pending }
// Order { Id = A-100, Quantity = 2, Status = shipped }
// False
// True
// A-100 x2 is shipped

When to use it

  • Produce an updated copy of an immutable configuration object with one setting overridden.
  • Build test data from one baseline record, varying a single field per test case.
  • Return a modified domain value from a method without the caller's instance changing underneath it.
  • Match on two properties of a record at once in a switch expression.

More examples

The shallow copy, and computed properties

Two bugs in one screen. The first is the shallow copy: with duplicates the fields, and a field holding an array holds an address, so the copy points at the same array. Copy the array yourself if the two instances must be independent. The second concerns derived values. Written as `public double Distance { get; } = Math.Sqrt(X * X + Y * Y);` the value would be computed once at construction and then carried unchanged into every with copy, so flattened.Distance would wrongly print 5. Compute derived values on access.

Example · csharp
string[] tags = ["urgent"];
Ticket first = new Ticket("T-1", tags);
Ticket second = first with { Id = "T-2" };

// with copied the reference, not the array. Both tickets share it.
second.Tags[0] = "later";
Console.WriteLine(first.Tags[0]);

// Distance is computed on access, so it is correct after with.
Console.WriteLine(new Point(3, 4).Distance);
Point flattened = new Point(3, 4) with { Y = 0 };
Console.WriteLine(flattened.Distance);

record Ticket(string Id, string[] Tags);

record Point(int X, int Y)
{
    // An expression-bodied property: recalculated every time it is read.
    public double Distance => Math.Sqrt(X * X + Y * Y);
}

// Output:
// later
// 5
// 3

Three ways to deconstruct

All three forms call the same Deconstruct method. The pattern form is the one worth remembering: once a type can be deconstructed, a switch expression can match on several of its values at once without naming them. Note that patterns are tested top to bottom, so a 35-degree, 60-percent reading reports too hot rather than too humid.

Example · csharp
Reading reading = new Reading(21, 55);

// Shorthand: var outside the brackets, types inferred.
var (temperature, humidity) = reading;
Console.WriteLine($"{temperature}C {humidity}%");

// Longhand: the generated method the shorthand calls.
reading.Deconstruct(out int t, out int h);
Console.WriteLine(t + h);

// Deconstruction inside a positional pattern. _ means "any value".
string verdict = reading switch
{
    (> 30, _) => "too hot",
    (_, > 50) => "too humid",
    _ => "fine"
};
Console.WriteLine(verdict);

record Reading(int Temperature, int Humidity);

// Output:
// 21C 55%
// 76
// too humid

with on a struct and on an anonymous type

A struct is copied on assignment anyway, so with on a struct is mostly a readability win: it names the one thing that differs instead of repeating the whole initializer. The anonymous-type case earns its place inside a LINQ chain, where you want to carry a projected shape forward with one value replaced. These types get no Deconstruct method, so with works on them but deconstruction does not.

Example · csharp
// C# 10 extended `with` beyond records to any struct
// and to anonymous types.
Config config = new Config { Retries = 3, TimeoutSeconds = 10 };
Config patient = config with { TimeoutSeconds = 60 };

Console.WriteLine($"{config.Retries} {config.TimeoutSeconds}");
Console.WriteLine($"{patient.Retries} {patient.TimeoutSeconds}");

// An anonymous type: a type with no name, created from its members.
var point = new { X = 1, Y = 2 };
var shifted = point with { X = 9 };
Console.WriteLine(shifted);

struct Config
{
    public int Retries { get; init; }
    public int TimeoutSeconds { get; init; }
}

// Output:
// 3 10
// 3 60
// { X = 9, Y = 2 }

Discussion

  • Be the first to comment on this lesson.