Skip to main content
ANVISoftware Solutions
Lesson 39 of 62Intermediate12 min

Expression-Bodied Members

By the end of this lesson

Write single-expression members concisely without losing clarity.

When a member's whole body is one expression, the braces and the return keyword carry no information. C# lets you replace them with =>, which is read aloud as "goes to".

This is not a new capability. public decimal DueAmount => Total - AmountPaid; compiles to exactly the same thing as a get block with a return statement in it. The only question is which version a reader understands faster, and the answer is not always the shorter one.

Where the arrow form earns its place
C#
public class Invoice
{
    private readonly List<InvoiceLine> _lines = new List<InvoiceLine>();

    public string Reference { get; }
    public DateOnly IssuedOn { get; }
    public decimal AmountPaid { get; private set; }

    public Invoice(string reference, DateOnly issuedOn)
    {
        Reference = reference;
        IssuedOn = issuedOn;
    }

    // One expression each. Nothing is hidden and nothing is computed twice.
    public IReadOnlyList<InvoiceLine> Lines => _lines;
    public bool IsEmpty => _lines.Count == 0;
    public DateOnly DueDate => IssuedOn.AddDays(30);
    public bool IsOverdue => DueDate < DateOnly.FromDateTime(DateTime.Today) && AmountPaid < Total;

    public bool WasIssuedBefore(DateOnly date) => IssuedOn < date;

    public override string ToString() => $"{Reference} issued {IssuedOn:yyyy-MM-dd}";
}
  • A property written with => is get-only. There is no setter, so nobody can assign to it, and the expression runs on every read.
  • Lines => _lines; exposes the private list as a read-only view. The arrow form makes it clear at a glance that nothing else is going on.
  • IsOverdue combines two comparisons, which is still one expression and still one idea: "past the due date and not fully paid".
  • A method takes the same form. WasIssuedBefore is one comparison, and a block would add three lines saying nothing.
  • ToString is the member most worth converting. The block version of a one-line ToString is four lines of punctuation around one line of content.
  • One member of this class is missing here: Total. It is the interesting case, and it appears in the next example.
Where the block form reads better
C#
// Block form, because there is more than one idea here.
public decimal Total
{
    get
    {
        decimal total = 0m;

        foreach (InvoiceLine line in _lines)
        {
            total += line.LineTotal;
        }

        return total;
    }
}

public void RecordPayment(decimal amount)
{
    if (amount <= 0m)
    {
        throw new ArgumentOutOfRangeException(nameof(amount), "A payment must be positive.");
    }

    if (AmountPaid + amount > Total)
    {
        throw new InvalidOperationException($"Payment exceeds the balance on {Reference}.");
    }

    AmountPaid += amount;
}

// Forced into one expression. It compiles. It is harder to read and harder to change.
public string StatusLabel => AmountPaid >= Total ? "Paid"
    : AmountPaid > 0m ? (IsOverdue ? "Part paid, overdue" : "Part paid")
    : IsOverdue ? "Overdue" : "Awaiting payment";
  • Total needs a loop, and a loop is a statement rather than an expression, so the arrow form is not available. That restriction happens to agree with readability here.
  • RecordPayment has two checks and then a change. Three ideas cannot be squeezed into one expression without conditional operators, and should not be.
  • StatusLabel is the interesting case, because it can be written either way. The nested conditional version puts four outcomes and three tests on three lines, and adding a fifth outcome means re-reading all of it.
  • A switch expression would be the better answer for StatusLabel — it is still one expression, so the arrow form stays, but each case sits on its own line. When a single expression grows conditional operators inside conditional operators, that is the signal to reach for pattern matching or a block.

Members that support the arrow form:

  • Methods, including ones that return void
  • Read-only properties, and indexers
  • get and set accessors individually, so set => _total = value; is allowed inside a full property
  • Constructors and finalizers
  • Operators, including user-defined conversions
  • Local functions declared inside a method

Summary

  • => replaces braces and return when a member's body is a single expression
  • It is formatting, not a feature: the compiler produces the same code either way
  • A property written with => is get-only and re-evaluates on every read
  • => on a collection property creates a new collection per read; = initialises one, once
  • Prefer a block when there is a check, more than one step, or anything worth commenting

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Take a Product class with a unit price, a quantity in stock and a reorder level. Write these four members and choose the form for each: StockValue, IsBelowReorderLevel, a ToString that shows the name and price, and a Restock method that refuses a negative quantity.

Write down why you chose the block form for the ones you did.

Show solution

The first three are single expressions with nothing hidden, so the arrow form removes noise. Restock needs a check before it changes anything, which is two ideas, so it keeps its braces.

There is a judgement call in StockValue. Because it is computed on every read, it can never disagree with the price and the quantity — that is the reason to prefer a computed property over a stored field here, and the arrow form makes the derivation visible in one line.

If Restock ever needs to be an expression, that is a hint the validation is being moved somewhere else rather than removed. Validation that disappears to make a member shorter has not gone anywhere good.

C#
public class Product
{
    public string Name { get; }
    public decimal UnitPrice { get; }
    public int QuantityInStock { get; private set; }
    public int ReorderLevel { get; }

    public Product(string name, decimal unitPrice, int reorderLevel)
    {
        Name = name;
        UnitPrice = unitPrice;
        ReorderLevel = reorderLevel;
    }

    public decimal StockValue => UnitPrice * QuantityInStock;
    public bool IsBelowReorderLevel => QuantityInStock < ReorderLevel;

    public override string ToString() => $"{Name} at {UnitPrice:N2}";

    // Block form: a check, then a change.
    public void Restock(int quantity)
    {
        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity), "Restock quantity must be positive.");
        }

        QuantityInStock += quantity;
    }
}

Think about it

Think about it

A colleague reports a bug: items added to invoice.Tags vanish immediately. The property is declared public List<string> Tags => new List<string>();

Explain what is happening, and why the compiler gave no warning.

Show solution

=> declares a computed property, so the expression runs on every read. Each read produces a different empty list. invoice.Tags.Add("urgent") reads the property, gets a fresh list, adds to it, and then nothing holds a reference to that list. The next read produces another empty one.

The compiler is silent because the code is valid and its intent is not knowable. Returning a new object from a property getter is legitimate — a method returning a fresh copy of a collection does exactly this on purpose. The compiler cannot tell that this particular one was meant to store state.

The fix is one character: = instead of =>. { get; } = new List<string>() initialises the backing field once when the object is created. The deeper fix, if the tags are part of the invoice's state, is a private readonly field exposed as IReadOnlyList<string> with an AddTag method, so nobody can modify the collection from outside at all.

Saved in this browser only.